From 0ebc55ae667a2d1f798e04fed700928ee3a5fa32 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 28 Jul 2025 16:08:29 -0400 Subject: [PATCH 001/232] feat: Define Workflow and Executor APIs --- .../Core/CompletedValueTaskSource.cs | 27 ++ .../Workflows/Core/DisposableObject.cs | 59 +++ .../Workflows/Core/ExecutionContext.cs | 17 + .../Workflows/Core/Executor.cs | 275 ++++++++++++++ .../Workflows/Core/Message.cs | 117 ++++++ .../Workflows/Core/MessageHandler.cs | 37 ++ .../Workflows/Core/MessageRouting.cs | 342 +++++++++++++++++ .../Workflows/Core/TypeErasure.cs | 60 +++ .../Workflows/WorkflowBuilder.cs | 353 ++++++++++++++++++ .../Workflows/WorkflowBuilderExtensions.cs | 156 ++++++++ 10 files changed, 1443 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs new file mode 100644 index 0000000000..e33b614d20 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// Helper class to work around lack of proper ValueTask support in .NET Framework. +/// +internal static class CompletedValueTaskSource +{ + internal static ValueTask Completed => +#if NET5_0_OR_GREATER + ValueTask.CompletedTask; +#else + new(Task.CompletedTask); +#endif + + internal static ValueTask FromResult(T result) + { +#if NET5_0_OR_GREATER + return new ValueTask(result); +#else + return new ValueTask(Task.FromResult(result)); +#endif + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs new file mode 100644 index 0000000000..d147b7d78d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// Provides a base class implementing the interface using +/// the virtual Dispose pattern. +/// +public class DisposableObject : IAsyncDisposable +{ + /// + /// Implements invocation of the DisposeAsync method when the object is finalized to + /// dispose unmanaged resources properly. + /// + ~DisposableObject() + { + // Finalizer calls DisposeAsync to ensure resources are released. + // This is a safety net in case DisposeAsync was not called. +#pragma warning disable CA2012 // Use ValueTasks correctly: Uses OnCompleted to properly handle the ValueTask return. + ValueTask disposeTask = this.DisposeAsync(false); +#pragma warning restore CA2012 // Use ValueTasks correctly + + if (!disposeTask.IsCompleted) + { + using (ManualResetEvent barrier = new(false)) + { + disposeTask.GetAwaiter().OnCompleted(() => barrier.Set()); + + // Wait for the DisposeAsync to complete. + barrier.WaitOne(); // TODO: Timeout? + } + } + + Debug.Assert( + disposeTask.IsCompleted, + "DisposeAsync should have completed in order to pass to this line."); +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + disposeTask.GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + } + + /// + protected virtual ValueTask DisposeAsync(bool disposing) + { + return CompletedValueTaskSource.Completed; + } + + /// + public async ValueTask DisposeAsync() + { + await this.DisposeAsync(true).ConfigureAwait(false); + GC.SuppressFinalize(this); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs new file mode 100644 index 0000000000..6a1d3f8415 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// Provides services for subclasses. +/// +public interface IExecutionContext +{ + /// + /// . + /// + /// + Task MagicAsync(); +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs new file mode 100644 index 0000000000..d5ea990084 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs @@ -0,0 +1,275 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// A tag interface for objects that have a unique identifier within an appropriate namespace. +/// +public interface IIdentified +{ + /// + /// The unique identifier. + /// + string Id { get; } +} + +/// +/// . +/// +public record ExecutorCapabilities +{ + /// + /// . + /// + public string Id { get; init; } + /// + /// . + /// + public string Name { get; init; } + /// + /// . + /// + public Type ExecutorType { get; init; } + /// + /// . + /// + public ISet HandledMessageTypes { get; init; } + /// + /// . + /// + public bool IsInitialized { get; init; } + /// + /// . + /// + public ISet StateKeys { get; init; } + + /// + /// . + /// + public ExecutorCapabilities() + { + this.Id = string.Empty; + this.Name = string.Empty; + this.ExecutorType = typeof(Executor); + this.HandledMessageTypes = new HashSet(); + this.IsInitialized = false; + this.StateKeys = new HashSet(); + } + + /// + /// . + /// + /// + /// + /// + /// + /// + /// + public ExecutorCapabilities(string id, string name, Type executorType, ISet handledMessageTypes, bool isInitialized, ISet stateKeys) + { + this.Id = id; + this.Name = name; + this.ExecutorType = executorType; + this.HandledMessageTypes = handledMessageTypes; + this.IsInitialized = isInitialized; + this.StateKeys = stateKeys; + } +} + +/// +/// . +/// +[DebuggerDisplay("{GetType().Name}{Id}({Name})")] +public abstract class Executor : DisposableObject, IIdentified +{ + /// + /// . + /// + public string Id { get; } + + /// + /// . + /// + public string Name { get; } + + private MessageRouter MessageRouter { get; init; } + private Dictionary State { get; } = new(); + + /// + /// . + /// + /// + /// + protected Executor(string? id = null, string? name = null) + { + this.Name = name ?? this.GetType().Name; + this.Id = id ?? $"{this.Name}{Guid.NewGuid():N}"; + + this.MessageRouter = MessageRouter.BindMessageHandlers(this, checkType: true); + } + + /// + /// . + /// + /// + /// + /// + /// + /// + public async ValueTask ExecuteAsync(object message, IExecutionContext context) + { + CallResult? result = await this.MessageRouter.RouteMessageAsync(message, context, requireRoute: true) + .ConfigureAwait(false); + + if (result == null) + { + throw new NotSupportedException( + $"No handler found for message type {message.GetType().Name} in executor {this.GetType().Name}."); + } + + if (!result.IsSuccess) + { + throw new TargetInvocationException($"Error invoking handler for {message.GetType()}", result.Exception!); + } + + if (result.IsVoid) + { + return null; // Void result. + } + + return result.Result; + } + + private bool _initialized = false; + + /// + /// . + /// + public ISet InputTypes => this.MessageRouter.IncomingTypes; + + /// + /// . + /// + [SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "")] + public ISet OutputTypes => throw new NotImplementedException(); + + /// + /// . + /// + /// + /// + public bool CanHandle(Type messageType) => this.MessageRouter.CanHandle(messageType); + + /// + /// . + /// + /// + /// + public async ValueTask InitializeAsync(IExecutionContext context) + { + if (this._initialized) + { + return; + } + + await this.InitializeOverride(context).ConfigureAwait(false); + + this._initialized = true; + } + + /// + /// . + /// + public ExecutorCapabilities Capabilities + => new() + { + Id = this.Id, + Name = this.Name, + ExecutorType = this.GetType(), + HandledMessageTypes = new HashSet(this.InputTypes), + IsInitialized = this._initialized, + StateKeys = new HashSet(this.State.Keys) + }; + + /// + /// . + /// + /// + public ReadOnlyDictionary CurrentState => new(this.State); + + /// + /// . + /// + /// + /// + public void RestoreState(IDictionary state) + { + if (state == null) + { + throw new ArgumentNullException(nameof(state), "State cannot be null."); + } + + this.State.Clear(); + + foreach (KeyValuePair kvp in state) + { + this.State[kvp.Key] = kvp.Value; + } + } + + /// + /// . + /// + /// + protected virtual ValueTask PrepareForCheckpointAsync() + { + return CompletedValueTaskSource.Completed; + } + + /// + /// . + /// + /// + protected virtual ValueTask AfterCheckpointRestoreAsync() + { + return CompletedValueTaskSource.Completed; + } + + /// + /// . + /// + /// + /// + protected virtual ValueTask InitializeOverride(IExecutionContext context) + { + // Default implementation does nothing. + return CompletedValueTaskSource.Completed; + } + + private async ValueTask FlushReduceRemainingAsync() + { + return; + } + + /// + /// . + /// + /// + /// + protected override async ValueTask DisposeAsync(bool disposing = false) + { + this._initialized = false; + + await this.FlushReduceRemainingAsync().ConfigureAwait(false); + + await base.DisposeAsync(disposing).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs new file mode 100644 index 0000000000..3b8eda2482 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +using ExecutorId = string; +// TODO: Unclear whether this should be forcibly a serializable type. +using MetadataValueT = object; +using RetryExceptionT = System.InvalidOperationException; +using TopicId = string; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// . +/// +public record MessageMetadata +{ + /// + /// . + /// + public string CorrelationId { get; init; } = Guid.NewGuid().ToString(); + /// + /// . + /// + public ExecutorId? SourceId { get; init; } + /// + /// . + /// + public ExecutorId? TargetId { get; init; } + /// + /// . + /// + public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; + /// + /// . + /// + public string IsoTimestamp => this.Timestamp.ToString("o"); + /// + /// . + /// + public TopicId? Topic { get; init; } + /// + /// . + /// + public int Priority { get; init; } = 0; // Higher values indicate higher priority. + /// + /// . + /// + public TimeSpan? Timeout { get; init; } = null; + + /// + /// . + /// + public int Retries { get; init; } = 0; + /// + /// . + /// + public int MaxRetries { get; init; } = 3; + + /// + /// . + /// + public IDictionary CustomData { get; init; } = new Dictionary(); +} + +/// +/// . +/// +/// +public record Message +{ + /// + /// . + /// + public TContent Content { get; init; } + + /// + /// . + /// + public Type ContentType => typeof(TContent); + + /// + /// . + /// + public MessageMetadata Metadata { get; init; } + + /// + /// . + /// + /// + /// + /// + public Message(TContent content, MessageMetadata metadata) + { + this.Content = content ?? throw new ArgumentNullException(nameof(content)); + this.Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); + } + + /// + /// Creates a new message instance for a new target. + /// + /// The identifier of the target executor to associate with the message. + /// A new instance with the updated target identifier. + public Message WithTarget(ExecutorId targetId) + => this with { Metadata = this.Metadata with { TargetId = targetId } }; + + /// + /// Create a copy of this message for next retry attempt. + /// + /// A copy of this message with incremented retry count. + /// If the maximum number of retries has been exceeded. + public Message WithRetry() + => this.Metadata.Retries < this.Metadata.MaxRetries + ? this with { Metadata = this.Metadata with { Retries = this.Metadata.Retries + 1 } } + : throw new RetryExceptionT($"Maximum retries ({this.Metadata.MaxRetries}) exceeded for message with ID '{this.Metadata.CorrelationId}'."); +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs new file mode 100644 index 0000000000..009a5aabc6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// A message handler interface for handling messages of type . +/// +/// +public interface IMessageHandler +{ + /// + /// Handles the incoming message asynchronously. + /// + /// The message to handle. + /// The execution context. + /// A task that represents the asynchronous operation. + ValueTask HandleAsync(TMessage message, IExecutionContext context); +} + +/// +/// A message handler interface for handling messages of type and +/// returning a result. +/// +/// The type of message to handle. +/// The type of result returned after handling the message. +public interface IMessageHandler +{ + /// + /// Handles the incoming message asynchronously. + /// + /// The message to handle. + /// The execution context. + /// A task that represents the asynchronous operation. + ValueTask HandleAsync(TMessage message, IExecutionContext context); +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs new file mode 100644 index 0000000000..4fc27244d8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs @@ -0,0 +1,342 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +using HandlerInfosT = + System.Collections.Generic.Dictionary< + System.Type, + Microsoft.Agents.Orchestration.Workflows.Core.MessageHandlerInfo + >; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// This class represents the result of a call to a +/// or . +/// +public sealed class CallResult +{ + /// + /// Indicates whether the call was void (i.e., no result expected). This only applies to + /// calls to handlers. + /// + public bool IsVoid { get; init; } + + /// + /// If the call was successful, this property contains the result of the call. For calls to + /// void handlers, this will be null. + /// + public object? Result { get; init; } = null; + + /// + /// If the call failed, this property contains the exception that was raised during the call. + /// + public Exception? Exception { get; init; } = null; + + /// + /// Indicates whether the call was successful. A call is considered successful if it returned + /// without throwing an exception. + /// + public bool IsSuccess => this.Exception == null; + + private CallResult(bool isVoid = false) + { + // Private constructor to enforce use of static methods. + this.IsVoid = isVoid; + } + + /// + /// Create a indicating a successful that returned a result (non-void). + /// + /// The result to return. + /// A indicating the result of the call. + public static CallResult ReturnResult(object? result = null) + { + return new() { Result = result }; + } + + /// + /// Create a indicating a successful call that returned no result (void). + /// + /// A indicating the result of the call. + public static CallResult ReturnVoid() + { + return new(isVoid: true); + } + + /// + /// Create a indicating that an exception was raised during the call. + /// + /// A boolean specifying whether the call was void (was not expected to return + /// a value). + /// The exception that was raised during the call. + /// A indicating the result of the call. + /// Thrown when is null. + public static CallResult RaisedException(bool wasVoid, Exception exception) + { + if (exception == null) + { + throw new ArgumentNullException(nameof(exception), "Exception cannot be null."); + } + + return new(wasVoid) { Exception = exception }; + } +} + +internal struct MessageHandlerInfo +{ + public Type InType { get; init; } + public Type? OutType { get; init; } = null; + + public MethodInfo HandlerInfo { get; init; } + public Func>? Unwrapper { get; init; } = null; + + [SuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality " + + "when AOT compiling.", Justification = "")] + public MessageHandlerInfo(MethodInfo handlerInfo) + { + // The method is one of the following: + // - ValueTask HandleAsync(TMessage message, IExecutionContext context) + // - ValueTask HandleAsync(TMessage message, IExecutionContext context) + this.HandlerInfo = handlerInfo; + + ParameterInfo[] parameters = handlerInfo.GetParameters(); + if (parameters.Length != 2) + { + throw new ArgumentException("Handler method must have exactly two parameters: TMessage and IExecutionContext.", nameof(handlerInfo)); + } + + if (parameters[1].ParameterType != typeof(IExecutionContext)) + { + throw new ArgumentException("Handler method's second parameter must be of type IExecutionContext.", nameof(handlerInfo)); + } + + this.InType = parameters[0].ParameterType; + + Type decoratedReturnType = handlerInfo.ReturnType; + if (decoratedReturnType.IsGenericType && decoratedReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) + { + // If the return type is ValueTask, extract TResult. + Type[] returnRawTypes = decoratedReturnType.GetGenericArguments(); + Debug.Assert( + returnRawTypes.Length == 1, + "ValueTask should have exactly one generic argument."); + + this.OutType = returnRawTypes.Single(); + this.Unwrapper = ValueTaskTypeErasure.UnwrapperFor(this.OutType); + } + else if (decoratedReturnType == typeof(ValueTask)) + { + // If the return type is ValueTask, there is no output type. + this.OutType = null; + } + else + { + throw new ArgumentException("Handler method must return ValueTask or ValueTask.", nameof(handlerInfo)); + } + } + + public Func> Bind(Executor executor, bool checkType = false) + { + Type? resultType = this.OutType; + MethodInfo handlerMethod = this.HandlerInfo; + Func>? unwrapper = this.Unwrapper; + + return InvokeHandlerAsync; + + // Create a delegate that binds the handler to the executor. + async ValueTask InvokeHandlerAsync(object message) + { + bool expectingVoid = resultType == null || resultType == typeof(void); + + try + { + object? maybeValueTask = handlerMethod.Invoke(executor, new object[] { message, executor }); + + if (expectingVoid) + { + if (maybeValueTask is ValueTask vt) + { + await vt.ConfigureAwait(false); + return CallResult.ReturnVoid(); + } + + throw new InvalidOperationException( + $"Handler method {handlerMethod.Name} is expected to return ValueTask or ValueTask, but returned " + + $"{maybeValueTask?.GetType().Name ?? "null"}."); + } + + Debug.Assert(resultType != null, "Expected resultType to be non-null when not expecting void."); + if (unwrapper == null) + { + throw new InvalidOperationException( + $"Handler method {handlerMethod.Name} is expected to return ValueTask<{resultType!.Name}>, but no unwrapper is available."); + } + + if (maybeValueTask == null) + { + throw new InvalidOperationException( + $"Handler method {handlerMethod.Name} returned null, but a ValueTask<{resultType!.Name}> was expected."); + } + + object? result = await unwrapper(maybeValueTask).ConfigureAwait(false); + + if (checkType && result != null && !resultType.IsInstanceOfType(result)) + { + throw new InvalidOperationException( + $"Handler method {handlerMethod.Name} returned an incompatible type: expected {resultType.Name}, got {result.GetType().Name}."); + } + + return CallResult.ReturnResult(result); + } + catch (Exception ex) + { + // If the handler throws an exception, return it in the CallResult. + return CallResult.RaisedException(wasVoid: expectingVoid, exception: ex); + } + } + } +} + +internal class MessageRouter +{ + // TODO: The goal of the cache is to allow SourceGenerators to do the reflection to bind the handlers in the router. + internal static readonly Dictionary> s_routerFactoryCache = new(); + + private Dictionary>> BoundHandlers { get; init; } = new(); + + [SuppressMessage("Trimming", + "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + + "of the source method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + [SuppressMessage("Trimming", + "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of " + + "method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + private static HandlerInfosT ReflectHandlers(Type executorType) + { + // This method reflects over the methods of the executor type to find message handlers. + HandlerInfosT handlers = new(); + + // Get all implementations of IMessageHandler or IMessageHandler + // and create a MessageHandlerInfo for each. + if (!typeof(Executor).IsAssignableFrom(executorType)) + { + throw new ArgumentException($"Type {executorType.FullName} is not a valid Executor type.", nameof(executorType)); + } + + if (executorType.IsAbstract || executorType.IsInterface) + { + throw new ArgumentException($"Type {executorType.FullName} cannot be abstract or an interface.", nameof(executorType)); + } + + // Iterate all interfaces implemented by the executor type. + foreach (Type interfaceType in executorType.GetInterfaces()) + { + // Check if the interface is a message handler. + if (!interfaceType.IsGenericType) + { + continue; + } + + if (interfaceType.IsGenericType && (interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || + interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>))) + { + // Get the generic arguments of the interface. + Type[] genericArguments = interfaceType.GetGenericArguments(); + if (genericArguments.Length < 1 || genericArguments.Length > 2) + { + continue; // Invalid handler signature. + } + Type inType = genericArguments[0]; + Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; + MethodInfo? method = interfaceType.GetMethod("HandleAsync"); + if (method != null) + { + MessageHandlerInfo info = new(method) { InType = inType, OutType = outType }; + handlers[inType] = info; + } + } + } + + return handlers; + } + + [SuppressMessage("Trimming", + "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + + "of the source method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + internal static HandlerInfosT ReflectHandlers() where TExecutor : Executor + => ReflectHandlers(typeof(TExecutor)); + + internal static MessageRouter BindMessageHandlers(Executor executor, bool checkType) + { + if (s_routerFactoryCache.TryGetValue(executor.GetType(), out var factory)) + { + return factory(); + } + + // If no factory is found, reflect over the handlers + HandlerInfosT handlers = ReflectHandlers(executor.GetType()); + + Dictionary>> boundHandlers = new(); + foreach (Type inType in handlers.Keys) + { + MessageHandlerInfo handlerInfo = handlers[inType]; + Func> boundHandler = handlerInfo.Bind(executor, checkType); + boundHandlers.Add(inType, boundHandler); // TODO: Turn the error here into something more actionable. + } + + return new MessageRouter(boundHandlers); + } + + internal MessageRouter(Dictionary>> handlers) + { + this.BoundHandlers = handlers; + } + + /// + /// . + /// + /// + /// + /// + /// + /// + /// + public async ValueTask RouteMessageAsync(object message, IExecutionContext context, bool requireRoute = true) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message), "Message cannot be null."); + } + + // TODO: Implement base type delegation + CallResult? result = null; + if (this.BoundHandlers.TryGetValue(message.GetType(), out Func>? handler)) + { + result = await handler(message).ConfigureAwait(false); + } + + return result; + } + + public bool CanHandle(Type candidateType) + { + if (candidateType == null) + { + throw new ArgumentNullException(nameof(candidateType), "Candidate type cannot be null."); + } + + // Check if the router can handle the candidate type. + return this.BoundHandlers.ContainsKey(candidateType); + } + + public HashSet IncomingTypes => [.. this.BoundHandlers.Keys]; +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs new file mode 100644 index 0000000000..62edbb4d85 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Reflection; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +internal static class ValueTaskTypeErasure +{ + internal static Func> CreateErasingUnwrapper() + { + return UnwrapAndEraseAsync; + + static async ValueTask UnwrapAndEraseAsync(object maybeValueTask) + { + if (maybeValueTask is ValueTask vt) + { + // If the input is a ValueTask, unwrap it. + TResult result = await vt.ConfigureAwait(false); + return (object?)result; + } + + throw new InvalidOperationException($"Expected ValueTask or ValueTask<{typeof(TResult).Name}>, but got {maybeValueTask.GetType().Name}."); + } + } + +#if NET5_0_OR_GREATER + // This suppression is qualified because for some reason VS is not recognizing the attribute's presence, treating the + // import as an error (due to unnecessary using). + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode("Calls System.Reflection.MethodInfo.MakeGenericMethod(params Type[])")] +#endif + internal static Func> UnwrapperFor(Type resultType) + { + // This method creates a type-erased unwrapper for ValueTask. + // It uses reflection to create a delegate that can handle any TResult type. + + // TODO: AOT: This method is marked with RequiresDynamicCodeAttribute, which will not work well in NativeAOT + // scenarios; the solution is to break this up into a Cached/Reflector version (like the MessageRouter does + // with handlers), and SourceGenerate the UnwrapAndEraseAsync-equivalent method for each TResult type. + + // Note that this is only necessary because ValueTask is a class-generic, rather than an interface + // type, which means that the type cannot be co/contravariantly used (e.g. ValueTask is not a valid + // supertype of ValueTask or ValueTask, T != object?). + + MethodInfo createMethod = + typeof(ValueTaskTypeErasure) + .GetMethod(nameof(CreateErasingUnwrapper), BindingFlags.NonPublic | BindingFlags.Static) + !.MakeGenericMethod(resultType); + + // Invoke createMethod (as static) to get the delegate. + object? maybeUnwrapper = createMethod.Invoke(null, Array.Empty()); + if (maybeUnwrapper is not Func> unwrapper) + { + throw new InvalidOperationException($"Expected a Func> delegate, but got {maybeUnwrapper?.GetType().Name ?? "null"}."); + } + + return unwrapper; + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs new file mode 100644 index 0000000000..cc17f2b335 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs @@ -0,0 +1,353 @@ +// Copyright (c) Microsoft. All rights reserved. + +#pragma warning disable IDE0005 // Using directive is unnecessary. +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Workflows.Core; +#pragma warning restore IDE0005 // Using directive is unnecessary. + +using ConditionalT = System.Func; + +namespace Microsoft.Agents.Orchestration.Workflows; + +internal delegate TExecutor ExecutorProvider() + where TExecutor : Executor; + +internal struct EdgeKey : IEquatable +{ + public string SourceId { get; init; } + public string TargetId { get; init; } + + public EdgeKey(string sourceId, string targetId) + { + this.SourceId = sourceId ?? throw new ArgumentNullException(nameof(sourceId)); + this.TargetId = targetId ?? throw new ArgumentNullException(nameof(targetId)); + } + + public bool Equals(EdgeKey other) => this.SourceId == other.SourceId && this.TargetId == other.TargetId; + public override bool Equals(object? obj) => obj is EdgeKey other && this.Equals(other); + public override int GetHashCode() => HashCode.Combine(this.SourceId, this.TargetId); +} + +/// +/// . +/// +public class ExecutionResult +{ +} + +internal sealed class ExecutorIsh : + IIdentified, + IEquatable, + IEquatable, + IEquatable +{ + public enum Type + { + Unbound, + Executor, + //Function, + //Agent, + //ProcessStep + } + + public Type ExecutorType { get; init; } + + private readonly string? _idValue; + private readonly Executor? _executorValue; + //private readonly Func? _functionValue; + + public ExecutorIsh(Executor executor) + { + this.ExecutorType = Type.Executor; + this._executorValue = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public ExecutorIsh(string id) + { + this.ExecutorType = Type.Unbound; + this._idValue = id ?? throw new ArgumentNullException(nameof(id)); + } + + public bool IsUnbound => this.ExecutorType == Type.Unbound; + + public string Id => this.ExecutorType switch + { + Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), + Type.Executor => this._executorValue!.Id, + //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), + //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), + //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), + _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + }; + + public ExecutorProvider ExecutorProvider => this.ExecutorType switch + { + Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), + Type.Executor => () => this._executorValue!, + //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), + //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), + //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), + _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + }; + + //public ExecutorIsh(Func function) + //{ + // this.ExecutorType = Type.Function; + // this._functionValue = function ?? throw new ArgumentNullException(nameof(function)); + //} + + // Implicit conversions into ExecutorIsh + public static implicit operator ExecutorIsh(Executor executor) + { + return new ExecutorIsh(executor); + } + + // How do we AoT compile this? + //public static implicit operator ExecutorIsh(Func function) + //{ + // return new ExecutorIsh(function); + //} + + public static implicit operator ExecutorIsh(string id) + { + return new ExecutorIsh(id); + } + + public bool Equals(ExecutorIsh? other) + { + return other is not null && + other.Id == this.Id; + } + + public bool Equals(IIdentified? other) + { + return other is not null && + other.Id == this.Id; + } + + public bool Equals(string? other) + { + return other is not null && + other == this.Id; + } + + public override bool Equals(object? obj) + { + if (obj is null) + { + return false; + } + + if (obj is ExecutorIsh ish) + { + return this.Equals(ish); + } + else if (obj is IIdentified identified) + { + return this.Equals(identified); + } + else if (obj is string str) + { + return this.Equals(str); + } + + return false; + } + + public override int GetHashCode() + { + return this.Id.GetHashCode(); + } + + public override string ToString() + { + return this.ExecutorType switch + { + Type.Unbound => $"'{this.Id}':", + Type.Executor => $"'{this.Id}':{this._executorValue!.GetType()}", + //Type.Function => $"ExecutorIsh for Function with ID '{this.Id}'", + //Type.Agent => $"ExecutorIsh for Agent with ID '{this.Id}'", + //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", + _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + }; + } +} + +internal class FlowEdge(ExecutorIsh source, ExecutorIsh sink, ConditionalT? conditional) : IEquatable +{ + public ExecutorIsh Source { get; init; } = source ?? throw new ArgumentNullException(nameof(source)); + public ExecutorIsh Sink { get; } = sink ?? throw new ArgumentNullException(nameof(sink)); + public Func? Condition { get; } = conditional; + + public bool Equals(FlowEdge? other) + { + return other is null + ? false + : this.Source.Equals(other.Source) && this.Sink.Equals(other.Sink); + } + + public override bool Equals(object? obj) => obj is FlowEdge other && this.Equals(other); + public override int GetHashCode() => HashCode.Combine(this.Source.GetHashCode(), this.Sink.GetHashCode()); +} + +internal class Workflow +{ + public Dictionary> Executors { get; internal init; } = new(); + public Dictionary> Edges { get; internal init; } = new(); + +#if NET9_0_OR_GREATER + required +#endif + public string StartExecutorId + { get; init; } + +#if NET9_0_OR_GREATER + required +#endif + public Type InputType + { get; init; } = typeof(object); + + public Workflow(string startExecutorId, Type type) + { + this.StartExecutorId = startExecutorId ?? throw new ArgumentNullException(nameof(startExecutorId)); + this.InputType = type ?? throw new ArgumentNullException(nameof(type)); + + // TODO: How do we (1) ensure the types are happy, and (2) work under AOT? + } + +#if NET9_0_OR_GREATER + public Workflow() + { } +#endif +} + +// Just a decorator for the purposes of keeping type type where we can +internal class Workflow : Workflow +{ + public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) + { + } + +#if NET9_0_OR_GREATER + public Workflow() + { + this.InputType = typeof(T); + } +#endif +} + +internal class WorkflowBuilder +{ + private readonly Dictionary> _executors = new(); + private readonly Dictionary> _edges = new(); + private readonly HashSet _unboundExecutors = new(); + + private readonly string _startExecutorId; + + public WorkflowBuilder(ExecutorIsh start) + { + this._startExecutorId = this.Track(start).Id; + } + + private ExecutorIsh Track(ExecutorIsh executorish) + { + ExecutorProvider provider = executorish.ExecutorProvider; + + // If the executor is unbound, create an entry for it, unless it already exists. + // Otherwise, update the entry for it, and remove the unbound tag + if (executorish.IsUnbound && !this._executors.ContainsKey(executorish.Id)) + { + // If this is an unbound executor, we need to track it separately + this._unboundExecutors.Add(executorish.Id); + this._executors[executorish.Id] = provider; + } + else if (!executorish.IsUnbound) + { + // If we already have an executor with this ID, we need to update it (todo: should we throw on double binding?) + this._executors[executorish.Id] = provider; + } + + return executorish; + } + + private void UpdateExecutor(string id, ExecutorProvider provider) + { + this._executors[id] = provider; + } + + public WorkflowBuilder BindExecutor(Executor executor) + { + if (!this._unboundExecutors.Contains(executor.Id)) + { + throw new InvalidOperationException( + $"Executor with ID '{executor.Id}' is already bound or does not exist in the workflow."); + } + + this._executors[executor.Id] = () => executor; + this._unboundExecutors.Remove(executor.Id); + return this; + } + + public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func? condition = null) + { + // Add an edge from source to target with an optional condition. + // This is a low-level builder method that does not enforce any specific executor type. + // The condition can be used to determine if the edge should be followed based on the input. + + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (target == null) + { + throw new ArgumentNullException(nameof(target)); + } + + if (!this._edges.TryGetValue(source.Id, out HashSet? edges)) + { + edges = new HashSet(); + this._edges[source.Id] = edges; + } + + edges.Add(new FlowEdge(this.Track(source), this.Track(target), condition)); + return this; + } + + public Workflow Build() + { + if (this._unboundExecutors.Count > 0) + { + throw new InvalidOperationException( + $"Workflow cannot be built because there are unbound executors: {string.Join(", ", this._unboundExecutors)}."); + } + + // Grab the start node, and make sure it has the right type? + if (!this._executors.TryGetValue(this._startExecutorId, out ExecutorProvider? startProvider)) + { + // TODO: This should never be able to be hit + throw new InvalidOperationException($"Start executor with ID '{this._startExecutorId}' is not bound."); + } + + // TODO: Delay-instantiate the start executor, and ensure it is of type T. + Executor startExecutor = startProvider(); + + if (!startExecutor.InputTypes.Any(t => t.IsAssignableFrom(typeof(T)))) + { + // We have no handlers for the input type T, which means the built workflow will not be able to + // process messages of the desired type + } + + return new Workflow(this._startExecutorId) // Why does it not see the default ctor? + { + Executors = this._executors, + Edges = this._edges, + StartExecutorId = this._startExecutorId, + InputType = typeof(T) + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs new file mode 100644 index 0000000000..69f7af5712 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Workflows.Core; + +namespace Microsoft.Agents.Orchestration.Workflows; + +internal static class Check +{ + public static T NotNull(T? value, [CallerArgumentExpression(nameof(value))] string? paramExpr = null) where T : class + { + if (value is null) + { + throw new ArgumentNullException(nameof(value), $"Value cannot be null: {paramExpr}"); + } + + return value; + } +} + +internal enum Activation +{ + WhenAll, +} + +internal static class WorkflowBuilderExtensions +{ + public static WorkflowBuilder AddLoop(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh loopBody, Func condition) + { + Check.NotNull(builder); + Check.NotNull(source); + Check.NotNull(loopBody); + Check.NotNull(condition); + + builder.AddEdge(source, loopBody, condition); + builder.AddEdge(loopBody, source); + + return builder; + } + + public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors) + { + Check.NotNull(builder); + Check.NotNull(source); + + for (int i = 0; i < executors.Length; i++) + { + Check.NotNull(executors[i], nameof(executors) + $"[{i}]"); + builder.AddEdge(source, executors[i]); + source = executors[i]; + } + + return builder; + } + + private class FanOutMessage(object message) + { + public object Content = message ?? throw new ArgumentNullException(nameof(message), "Message cannot be null"); + } + + private class FanInMessage(IEnumerable? message = null) + { + public static readonly FanInMessage Pending = new(); + + public bool IsCompleted => this.Result is not null; + public IEnumerable? Result = message; + } + + private class FanOutExecutor : Executor, IMessageHandler + { + public ValueTask HandleAsync(object message, IExecutionContext context) + { + return new ValueTask(new FanOutMessage(message)); + } + } + + public static WorkflowBuilder AddFanOut(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] targets) + { + Check.NotNull(builder); + Check.NotNull(source); + + FanOutExecutor fanOut = new(); + builder.AddEdge(source, fanOut); + + foreach (var target in targets) + { + Check.NotNull(target); + builder.AddEdge(fanOut, target); + } + + return builder; + } + + private class FanInExecutor : Executor, + IMessageHandler + { +#if NET9_0_OR_GREATER + required +#endif + public int SourceCount + { get; init; } + + public Activation Activation { get; init; } = Activation.WhenAll; + + private readonly List _messages = []; + public ValueTask HandleAsync(FanOutMessage message, IExecutionContext context) + { + this._messages.Add(message.Content); + + if (this._messages.Count >= this.SourceCount) + { + return new ValueTask(new FanInMessage(this._messages.ToArray())); + } + + return CompletedValueTaskSource.FromResult(FanInMessage.Pending); + } + } + + private class FanInUnwrapper : Executor, + IMessageHandler> + { + public ValueTask> HandleAsync(FanInMessage message, IExecutionContext context) + { + return CompletedValueTaskSource.FromResult(message.Result!); + } + } + + public static WorkflowBuilder AddFanIn(this WorkflowBuilder builder, ExecutorIsh target, Activation activation = Activation.WhenAll, params ExecutorIsh[] sources) + { + Check.NotNull(builder); + Check.NotNull(target); + + FanInExecutor fanIn = new() + { + Activation = activation, + SourceCount = sources.Length + }; + FanInUnwrapper unwrapper = new(); + + builder.AddEdge(fanIn, unwrapper, IsFanInCompleted); + builder.AddEdge(unwrapper, target); + + foreach (var source in sources) + { + Check.NotNull(source); + builder.AddEdge(source, fanIn); + } + + return builder; + + static bool IsFanInCompleted(object? message) => message is FanInMessage fanIn && fanIn.IsCompleted; + } +} From 0a0c3754169346bfa42d10d130a0250798456e52 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 28 Jul 2025 18:59:59 -0400 Subject: [PATCH 002/232] feat: Define IExecutionContext and Events --- .../Workflows/Core/Events.cs | 152 ++++++++++++++++++ .../Workflows/Core/ExecutionContext.cs | 44 ++++- .../Workflows/Core/MessageRouting.cs | 21 +++ .../Workflows/WorkflowBuilderExtensions.cs | 3 +- 4 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs new file mode 100644 index 0000000000..51a23842c8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// . +/// +public record WorkflowEvent(object? Data = null); + +/// +/// . +/// +public record WorkflowStartedEvent : WorkflowEvent; + +/// +/// . +/// +public record WorkflowCompletedEvent : WorkflowEvent; + +/// +/// . +/// +public record ExecutorEvent : WorkflowEvent +{ + /// + /// The identifier of the executor that generated this event. + /// +#if NET9_0_OR_GREATER + required +#endif + public string ExecutorId + { get; init; } + + /// + /// . + /// + public ExecutorEvent(string executorId, object? data = null) : base(data) + { + this.ExecutorId = executorId ?? throw new ArgumentNullException(nameof(executorId), "Executor ID cannot be null."); + } + +#if NET9_0_OR_GREATER + /// + /// . + /// + public ExecutorEvent() + { } +#endif +} + +/// +/// . +/// +public record ExecutorInvokeEvent : ExecutorEvent +{ + /// + /// . + /// + public ExecutorInvokeEvent(string executorId, object? data = null) : base(data) + { + this.ExecutorId = executorId ?? throw new ArgumentNullException(nameof(executorId), "Executor ID cannot be null."); + } + +#if NET9_0_OR_GREATER + /// + /// . + /// + public ExecutorInvokeEvent() + { } +#endif +} + +/// +/// . +/// +public record ExecutorCompleteEvent : ExecutorEvent +{ + /// + /// . + /// + public ExecutorCompleteEvent(string executorId, object? data = null) : base(data) + { + this.ExecutorId = executorId ?? throw new ArgumentNullException(nameof(executorId), "Executor ID cannot be null."); + } + +#if NET9_0_OR_GREATER + /// + /// . + /// + public ExecutorCompleteEvent() + { } +#endif +} + +// TODO: This is a placeholder for streaming chat message content. +/// +/// . +/// +public class StreamingChatMessageContent +{ } + +/// +/// . +/// +public record AgentRunStreamingEvent : ExecutorEvent +{ + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the executor that generated this event. + /// + public AgentRunStreamingEvent(string executorId, StreamingChatMessageContent? content = null) : base(executorId, data: content) + { + this.Content = content; + } + + /// + /// Gets the content of the streaming chat message. + /// + public StreamingChatMessageContent? Content { get; } +} + +// TODO: This is a placeholder for non-streaming chat message content. +/// +/// . +/// +public class ChatMessageContent +{ +} + +/// +/// . +/// +public record AgentRunEvent : ExecutorEvent +{ + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the executor that generated this event. + /// + public AgentRunEvent(string executorId, ChatMessageContent? content = null) : base(executorId, data: content) + { + this.Content = content; + } + + /// + /// Gets the content of the chat message. + /// + public ChatMessageContent? Content { get; } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs index 6a1d3f8415..60beaa88ee 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; using System.Threading.Tasks; namespace Microsoft.Agents.Orchestration.Workflows.Core; @@ -10,8 +11,45 @@ namespace Microsoft.Agents.Orchestration.Workflows.Core; public interface IExecutionContext { /// - /// . + /// Send a message from the executor to the context. /// - /// - Task MagicAsync(); + /// The id of the sender of the message. + /// The message to be sent. + /// A representing the asynchronous operation. + ValueTask SendMessageAsync(string sourceId, object message); + + /// + /// Drain all messages from the context. + /// + /// A representing the asynchronous operation, containing + /// a dictionary mapping executor IDs to lists of messages. + ValueTask>> DrainMessagesAsync(); + + /// + /// Check if there are any message in the context. + /// + /// A representing the asynchronous operation, containing + /// true if there are messages. false if there are not. + ValueTask HasMessagesAsync(); + + /// + /// Add an event to the execution context. + /// + /// The event to be added. + /// A representing the asynchronous operation. + ValueTask AddEventAsync(WorkflowEvent workflowEvent); + + /// + /// Drain all events from the context. + /// + /// A representing the asynchronous operation, containing + /// a list of all events. + ValueTask> DrainEventsAsync(); + + /// + /// Check if there are any events in the context. + /// + /// A representing the asynchronous operation, containing + /// true if there are events. false if there are not. + ValueTask HasEventsAsync(); } diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs index 4fc27244d8..53a22b88d4 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs @@ -16,6 +16,27 @@ namespace Microsoft.Agents.Orchestration.Workflows.Core; +/// +/// This attribute indicates that a message handler streams messages during its execution. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public sealed class StreamsMessageAttribute : Attribute +{ + /// + /// The type of the message that the handler yields. + /// + public Type Type { get; } + + /// + /// Indicates that the message handler yields streaming messages during the course of execution. + /// + public StreamsMessageAttribute(Type type) + { + // This attribute is used to mark executors that yield messages. + this.Type = type ?? throw new ArgumentNullException(nameof(type), "Type cannot be null."); + } +} + /// /// This class represents the result of a call to a /// or . diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs index 69f7af5712..a0084486b9 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs @@ -28,12 +28,11 @@ internal enum Activation internal static class WorkflowBuilderExtensions { - public static WorkflowBuilder AddLoop(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh loopBody, Func condition) + public static WorkflowBuilder AddLoop(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh loopBody, Func? condition = null) { Check.NotNull(builder); Check.NotNull(source); Check.NotNull(loopBody); - Check.NotNull(condition); builder.AddEdge(source, loopBody, condition); builder.AddEdge(loopBody, source); From 07bd947e2eb7edc74e312c27b57b41afb6c980ed Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 28 Jul 2025 19:00:15 -0400 Subject: [PATCH 003/232] feat: Simple Workflow Demos --- .../Sample/02_Simple_Workflow_Sequential.cs | 42 +++++++++ .../Sample/02a_Simple_Workflow_Condition.cs | 85 +++++++++++++++++ .../Sample/02b_Simple_Workflow_Loop.cs | 93 +++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs new file mode 100644 index 0000000000..66fc4c5c85 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Workflows.Core; + +namespace Microsoft.Agents.Orchestration.Workflows.Sample; + +internal static class Step2EntryPoint +{ + public static ValueTask RunAsync() + { + UppercaseExecutor uppercase = new(); + ReverseTextExecutor reverse = new(); + + WorkflowBuilder builder = new(uppercase); + builder.AddEdge(uppercase, reverse); + + Workflow workflow = builder.Build(); + // async foreach (var event in workflow.RunAsync("hello world")) + // await Console.Out.WriteLineAsync(event); + + return CompletedValueTaskSource.Completed; + } +} + +internal class UppercaseExecutor : Executor, IMessageHandler +{ + public ValueTask HandleAsync(string message, IExecutionContext context) + { + return CompletedValueTaskSource.FromResult(message.ToUpperInvariant()); + } +} + +internal class ReverseTextExecutor : Executor, IMessageHandler +{ + public ValueTask HandleAsync(string message, IExecutionContext context) + { + char[] charArray = message.ToCharArray(); + System.Array.Reverse(charArray); + return CompletedValueTaskSource.FromResult(new string(charArray)); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs new file mode 100644 index 0000000000..47c8620ade --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Workflows.Core; + +namespace Microsoft.Agents.Orchestration.Workflows.Sample; + +internal static class Step2aEntryPoint +{ + public static ValueTask RunAsync() + { + string[] spamKeywords = { "spam", "advertisement", "offer" }; + + DetectSpamExecutor detectSpam = new(spamKeywords); + RespondToMessageExecutor respondToMessage = new(); + RemoveSpamExecutor removeSpam = new(); + + Workflow workflow = new WorkflowBuilder(detectSpam) + .AddEdge(detectSpam, respondToMessage, isSpam => isSpam is true) // If not spam, respond + .AddEdge(detectSpam, removeSpam, isSpam => isSpam is false) // If spam, remove + .Build(); + + // async foreach (var event in workflow.RunAsync("This is a spam message.")) + // await Console.Out.WriteLineAsync(event); + + return CompletedValueTaskSource.Completed; + } +} + +internal class DetectSpamExecutor : Executor, IMessageHandler +{ + public string[] SpamKeywords { get; } + + public DetectSpamExecutor(params string[] spamKeywords) + { + this.SpamKeywords = spamKeywords; + } + + public ValueTask HandleAsync(string message, IExecutionContext context) + { +#if NET5_0_OR_GREATER + bool isSpam = this.SpamKeywords.Any(keyword => message.Contains(keyword, StringComparison.OrdinalIgnoreCase)); +#else + bool isSpam = this.SpamKeywords.Any(keyword => message.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0); +#endif + + return CompletedValueTaskSource.FromResult(isSpam); + } +} + +internal class RespondToMessageExecutor : Executor, IMessageHandler +{ + public async ValueTask HandleAsync(bool message, IExecutionContext context) + { + if (message) + { + // This is SPAM, and should not have been routed here + throw new InvalidOperationException("Received a spam message that should not be getting a reply."); + } + + await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay + + await context.AddEventAsync(new WorkflowCompletedEvent { Data = "Message processed successfully." }) + .ConfigureAwait(false); + } +} + +internal class RemoveSpamExecutor : Executor, IMessageHandler +{ + public async ValueTask HandleAsync(bool message, IExecutionContext context) + { + if (!message) + { + // This is NOT SPAM, and should not have been routed here + throw new InvalidOperationException("Received a non-spam message that should not be getting removed."); + } + + await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay + + await context.AddEventAsync(new WorkflowCompletedEvent { Data = "Spam message removed." }) + .ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs new file mode 100644 index 0000000000..328b20702f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Workflows.Core; + +namespace Microsoft.Agents.Orchestration.Workflows.Sample; + +internal static class Step2bEntryPoint +{ + public static ValueTask RunAsync() + { + GuessNumberExecutor guessNumber = new(1, 100); + JudgeExecutor judge = new(42); // Let's say the target number is 42 + + Workflow workflow = new WorkflowBuilder(guessNumber) + .AddLoop(guessNumber, judge) + .Build(); + + // async foreach (var event in workflow.RunAsync(NumberSignal.Init)) + // await Console.Out.WriteLineAsync(event); + + return CompletedValueTaskSource.Completed; + } +} + +internal enum NumberSignal +{ + Init, + Above, + Below, + Matched +} + +internal class GuessNumberExecutor : Executor, IMessageHandler +{ + public int LowerBound { get; private set; } + public int UpperBound { get; private set; } + + public GuessNumberExecutor(int lowerBound, int upperBound) + { + this.LowerBound = lowerBound; + this.UpperBound = upperBound; + } + + private int NextGuess => (this.LowerBound + this.UpperBound) / 2; + + private int _currGuess = -1; + public async ValueTask HandleAsync(NumberSignal message, IExecutionContext context) + { + switch (message) + { + case NumberSignal.Matched: + await context.AddEventAsync(new WorkflowCompletedEvent { Data = $"Guessed the number: {this._currGuess}" }) + .ConfigureAwait(false); + break; + + case NumberSignal.Above: + this.UpperBound = this._currGuess - 1; + break; + case NumberSignal.Below: + this.LowerBound = this._currGuess + 1; + break; + } + + return this._currGuess = this.NextGuess; + } +} + +internal class JudgeExecutor : Executor, IMessageHandler +{ + private readonly int _targetNumber; + + public JudgeExecutor(int targetNumber) + { + this._targetNumber = targetNumber; + } + + public ValueTask HandleAsync(int message, IExecutionContext context) + { + if (message == this._targetNumber) + { + return CompletedValueTaskSource.FromResult(NumberSignal.Matched); + } + else if (message < this._targetNumber) + { + return CompletedValueTaskSource.FromResult(NumberSignal.Below); + } + else + { + return CompletedValueTaskSource.FromResult(NumberSignal.Above); + } + } +} From 5c7a5d48d5db65ae453ddf2ab3b0e63dcd6d4733 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 29 Jul 2025 15:28:41 -0400 Subject: [PATCH 004/232] refactor: Move Workflows classes to separate assembly --- dotnet/agent-framework-dotnet.slnx | 2 ++ .../Core/CompletedValueTaskSource.cs | 2 +- .../Core/DisposableObject.cs | 2 +- .../Core/Events.cs | 12 ++----- .../Core/ExecutionContext.cs | 2 +- .../Core/Executor.cs | 2 +- .../Core/Message.cs | 2 +- .../Core/MessageHandler.cs | 2 +- .../Core/MessageRouting.cs | 4 +-- .../Core/TypeErasure.cs | 2 +- .../Microsoft.Agents.Workflow.csproj | 32 +++++++++++++++++++ .../WorkflowBuilder.cs | 4 +-- .../WorkflowBuilderExtensions.cs | 4 +-- ...Microsoft.Agents.Workflow.UnitTests.csproj | 17 ++++++++++ .../Sample/02_Simple_Workflow_Sequential.cs | 8 ++--- .../Sample/02a_Simple_Workflow_Condition.cs | 10 +++--- .../Sample/02b_Simple_Workflow_Loop.cs | 8 ++--- 17 files changed, 80 insertions(+), 35 deletions(-) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/CompletedValueTaskSource.cs (91%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/DisposableObject.cs (97%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/Events.cs (89%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/ExecutionContext.cs (97%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/Executor.cs (99%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/Message.cs (98%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/MessageHandler.cs (96%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/MessageRouting.cs (99%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/TypeErasure.cs (97%) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/WorkflowBuilder.cs (99%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/WorkflowBuilderExtensions.cs (97%) create mode 100644 dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj rename dotnet/{src/Microsoft.Agents.Orchestration/Workflows => tests/Microsoft.Agents.Workflow.UnitTests}/Sample/02_Simple_Workflow_Sequential.cs (79%) rename dotnet/{src/Microsoft.Agents.Orchestration/Workflows => tests/Microsoft.Agents.Workflow.UnitTests}/Sample/02a_Simple_Workflow_Condition.cs (88%) rename dotnet/{src/Microsoft.Agents.Orchestration/Workflows => tests/Microsoft.Agents.Workflow.UnitTests}/Sample/02b_Simple_Workflow_Loop.cs (89%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 6420da7f0a..91cbf5db1f 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -116,6 +116,7 @@ + @@ -128,6 +129,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs similarity index 91% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs index e33b614d20..543ead1e98 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs @@ -2,7 +2,7 @@ using System.Threading.Tasks; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// Helper class to work around lack of proper ValueTask support in .NET Framework. diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs similarity index 97% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs index d147b7d78d..a754870660 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs @@ -5,7 +5,7 @@ using System.Threading; using System.Threading.Tasks; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// Provides a base class implementing the interface using diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs similarity index 89% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs index 51a23842c8..041944ea36 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs @@ -2,7 +2,7 @@ using System; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// . @@ -58,10 +58,7 @@ public record ExecutorInvokeEvent : ExecutorEvent /// /// . /// - public ExecutorInvokeEvent(string executorId, object? data = null) : base(data) - { - this.ExecutorId = executorId ?? throw new ArgumentNullException(nameof(executorId), "Executor ID cannot be null."); - } + public ExecutorInvokeEvent(string executorId, object? data = null) : base(executorId, data) { } #if NET9_0_OR_GREATER /// @@ -80,10 +77,7 @@ public record ExecutorCompleteEvent : ExecutorEvent /// /// . /// - public ExecutorCompleteEvent(string executorId, object? data = null) : base(data) - { - this.ExecutorId = executorId ?? throw new ArgumentNullException(nameof(executorId), "Executor ID cannot be null."); - } + public ExecutorCompleteEvent(string executorId, object? data = null) : base(executorId, data) { } #if NET9_0_OR_GREATER /// diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs similarity index 97% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs index 60beaa88ee..2c165505eb 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Threading.Tasks; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// Provides services for subclasses. diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs similarity index 99% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index d5ea990084..46084d5afc 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -8,7 +8,7 @@ using System.Reflection; using System.Threading.Tasks; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// A tag interface for objects that have a unique identifier within an appropriate namespace. diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs similarity index 98% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs index 3b8eda2482..c3c6bd2bbe 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs @@ -9,7 +9,7 @@ using RetryExceptionT = System.InvalidOperationException; using TopicId = string; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// . diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs similarity index 96% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs index 009a5aabc6..a02c1fa042 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs @@ -2,7 +2,7 @@ using System.Threading.Tasks; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// A message handler interface for handling messages of type . diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs similarity index 99% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs index 53a22b88d4..9818599161 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs @@ -11,10 +11,10 @@ using HandlerInfosT = System.Collections.Generic.Dictionary< System.Type, - Microsoft.Agents.Orchestration.Workflows.Core.MessageHandlerInfo + Microsoft.Agents.Workflows.Core.MessageHandlerInfo >; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// This attribute indicates that a message handler streams messages during its execution. diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/TypeErasure.cs similarity index 97% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/TypeErasure.cs index 62edbb4d85..8166a7a5c6 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/TypeErasure.cs @@ -4,7 +4,7 @@ using System.Reflection; using System.Threading.Tasks; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; internal static class ValueTaskTypeErasure { diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj new file mode 100644 index 0000000000..8d7a3265b0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj @@ -0,0 +1,32 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + Microsoft.Agents.Workflow + alpha + + + + true + true + + + + + + + Microsoft Agent Workflow Framework + Contains the Microsoft Agent Workflow Framework. + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs similarity index 99% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs rename to dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index cc17f2b335..d7167c53c6 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -6,12 +6,12 @@ using System.Linq; using System.Text; using System.Threading.Tasks; -using Microsoft.Agents.Orchestration.Workflows.Core; +using Microsoft.Agents.Workflows.Core; #pragma warning restore IDE0005 // Using directive is unnecessary. using ConditionalT = System.Func; -namespace Microsoft.Agents.Orchestration.Workflows; +namespace Microsoft.Agents.Workflows; internal delegate TExecutor ExecutorProvider() where TExecutor : Executor; diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs similarity index 97% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs rename to dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index a0084486b9..b0a14f0f0e 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -4,9 +4,9 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Threading.Tasks; -using Microsoft.Agents.Orchestration.Workflows.Core; +using Microsoft.Agents.Workflows.Core; -namespace Microsoft.Agents.Orchestration.Workflows; +namespace Microsoft.Agents.Workflows; internal static class Check { diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj new file mode 100644 index 0000000000..f4e4b48d84 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj @@ -0,0 +1,17 @@ + + + + $(ProjectsTargetFrameworks) + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs similarity index 79% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs rename to dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs index 66fc4c5c85..d9af8f9f3a 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System.Threading.Tasks; -using Microsoft.Agents.Orchestration.Workflows.Core; +using Microsoft.Agents.Workflows.Core; -namespace Microsoft.Agents.Orchestration.Workflows.Sample; +namespace Microsoft.Agents.Workflows.Sample; internal static class Step2EntryPoint { @@ -23,7 +23,7 @@ public static ValueTask RunAsync() } } -internal class UppercaseExecutor : Executor, IMessageHandler +internal sealed class UppercaseExecutor : Executor, IMessageHandler { public ValueTask HandleAsync(string message, IExecutionContext context) { @@ -31,7 +31,7 @@ public ValueTask HandleAsync(string message, IExecutionContext context) } } -internal class ReverseTextExecutor : Executor, IMessageHandler +internal sealed class ReverseTextExecutor : Executor, IMessageHandler { public ValueTask HandleAsync(string message, IExecutionContext context) { diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs similarity index 88% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs rename to dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs index 47c8620ade..42f23a7dd4 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs @@ -3,9 +3,9 @@ using System; using System.Linq; using System.Threading.Tasks; -using Microsoft.Agents.Orchestration.Workflows.Core; +using Microsoft.Agents.Workflows.Core; -namespace Microsoft.Agents.Orchestration.Workflows.Sample; +namespace Microsoft.Agents.Workflows.Sample; internal static class Step2aEntryPoint { @@ -29,7 +29,7 @@ public static ValueTask RunAsync() } } -internal class DetectSpamExecutor : Executor, IMessageHandler +internal sealed class DetectSpamExecutor : Executor, IMessageHandler { public string[] SpamKeywords { get; } @@ -50,7 +50,7 @@ public ValueTask HandleAsync(string message, IExecutionContext context) } } -internal class RespondToMessageExecutor : Executor, IMessageHandler +internal sealed class RespondToMessageExecutor : Executor, IMessageHandler { public async ValueTask HandleAsync(bool message, IExecutionContext context) { @@ -67,7 +67,7 @@ await context.AddEventAsync(new WorkflowCompletedEvent { Data = "Message process } } -internal class RemoveSpamExecutor : Executor, IMessageHandler +internal sealed class RemoveSpamExecutor : Executor, IMessageHandler { public async ValueTask HandleAsync(bool message, IExecutionContext context) { diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs similarity index 89% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs rename to dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs index 328b20702f..c235352725 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System.Threading.Tasks; -using Microsoft.Agents.Orchestration.Workflows.Core; +using Microsoft.Agents.Workflows.Core; -namespace Microsoft.Agents.Orchestration.Workflows.Sample; +namespace Microsoft.Agents.Workflows.Sample; internal static class Step2bEntryPoint { @@ -31,7 +31,7 @@ internal enum NumberSignal Matched } -internal class GuessNumberExecutor : Executor, IMessageHandler +internal sealed class GuessNumberExecutor : Executor, IMessageHandler { public int LowerBound { get; private set; } public int UpperBound { get; private set; } @@ -66,7 +66,7 @@ await context.AddEventAsync(new WorkflowCompletedEvent { Data = $"Guessed the nu } } -internal class JudgeExecutor : Executor, IMessageHandler +internal sealed class JudgeExecutor : Executor, IMessageHandler { private readonly int _targetNumber; From 87200c4e043922736f6cdfb2bad8e21956baf72c Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 30 Jul 2025 12:34:40 -0400 Subject: [PATCH 005/232] feat: Move FanOut/In to LowLevel API with new semantics --- .../WorkflowBuilder.cs | 165 +++++++++++++++--- .../WorkflowBuilderExtensions.cs | 133 +------------- 2 files changed, 143 insertions(+), 155 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index d7167c53c6..f62008f15f 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -7,6 +7,8 @@ using System.Text; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; + #pragma warning restore IDE0005 // Using directive is unnecessary. using ConditionalT = System.Func; @@ -16,21 +18,21 @@ namespace Microsoft.Agents.Workflows; internal delegate TExecutor ExecutorProvider() where TExecutor : Executor; -internal struct EdgeKey : IEquatable -{ - public string SourceId { get; init; } - public string TargetId { get; init; } +//internal struct EdgeKey : IEquatable +//{ +// public string SourceId { get; init; } +// public string TargetId { get; init; } - public EdgeKey(string sourceId, string targetId) - { - this.SourceId = sourceId ?? throw new ArgumentNullException(nameof(sourceId)); - this.TargetId = targetId ?? throw new ArgumentNullException(nameof(targetId)); - } +// public EdgeKey(string sourceId, string targetId) +// { +// this.SourceId = sourceId ?? throw new ArgumentNullException(nameof(sourceId)); +// this.TargetId = targetId ?? throw new ArgumentNullException(nameof(targetId)); +// } - public bool Equals(EdgeKey other) => this.SourceId == other.SourceId && this.TargetId == other.TargetId; - public override bool Equals(object? obj) => obj is EdgeKey other && this.Equals(other); - public override int GetHashCode() => HashCode.Combine(this.SourceId, this.TargetId); -} +// public bool Equals(EdgeKey other) => this.SourceId == other.SourceId && this.TargetId == other.TargetId; +// public override bool Equals(object? obj) => obj is EdgeKey other && this.Equals(other); +// public override int GetHashCode() => HashCode.Combine(this.SourceId, this.TargetId); +//} /// /// . @@ -177,6 +179,83 @@ public override string ToString() } } +internal record DirectEdgeData( + ExecutorIsh Source, + ExecutorIsh Sink, + Func? Condition) +{ + public static implicit operator FlowEdgeEx(DirectEdgeData data) + { + return new FlowEdgeEx(data); + } +} + +internal record FanOutEdgeData( + ExecutorIsh Source, + IEnumerable Sinks, + Func>? Partitioner) // TODO: Should this be IList (to imply an ordering?)? +{ + public static implicit operator FlowEdgeEx(FanOutEdgeData data) + { + return new FlowEdgeEx(data); + } +} + +internal enum FanInTrigger +{ + WhenAll, + WhenAny +} + +internal record FanInEdgeData( + IEnumerable Sources, + ExecutorIsh Sink, + FanInTrigger Trigger = FanInTrigger.WhenAll) +{ + public static implicit operator FlowEdgeEx(FanInEdgeData data) + { + return new FlowEdgeEx(data); + } +} + +internal class FlowEdgeEx +{ + public enum Type + { + Direct, + FanOut, + FanIn + } + + public Type EdgeType { get; init; } + public object Data { get; init; } + + public FlowEdgeEx(DirectEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.Direct; + } + + public FlowEdgeEx(FanOutEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.FanOut; + } + + public FlowEdgeEx(FanInEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.FanIn; + } + + public DirectEdgeData? DirectEdgeData => this.Data as DirectEdgeData; + public FanOutEdgeData? FanOutEdgeData => this.Data as FanOutEdgeData; + public FanInEdgeData? FanInEdgeData => this.Data as FanInEdgeData; +} + internal class FlowEdge(ExecutorIsh source, ExecutorIsh sink, ConditionalT? conditional) : IEquatable { public ExecutorIsh Source { get; init; } = source ?? throw new ArgumentNullException(nameof(source)); @@ -197,7 +276,7 @@ public bool Equals(FlowEdge? other) internal class Workflow { public Dictionary> Executors { get; internal init; } = new(); - public Dictionary> Edges { get; internal init; } = new(); + public Dictionary> Edges { get; internal init; } = new(); #if NET9_0_OR_GREATER required @@ -243,7 +322,7 @@ public Workflow() internal class WorkflowBuilder { private readonly Dictionary> _executors = new(); - private readonly Dictionary> _edges = new(); + private readonly Dictionary> _edges = new(); private readonly HashSet _unboundExecutors = new(); private readonly string _startExecutorId; @@ -292,29 +371,57 @@ public WorkflowBuilder BindExecutor(Executor executor) return this; } + private HashSet EnsureEdgesFor(string sourceId) + { + // Ensure that there is a set of edges for the given source ID. + // If it does not exist, create a new one. + if (!this._edges.TryGetValue(sourceId, out HashSet? edges)) + { + this._edges[sourceId] = edges = new HashSet(); + } + + return edges; + } + public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func? condition = null) { // Add an edge from source to target with an optional condition. // This is a low-level builder method that does not enforce any specific executor type. // The condition can be used to determine if the edge should be followed based on the input. + Throw.IfNull(source); + Throw.IfNull(target); - if (source == null) - { - throw new ArgumentNullException(nameof(source)); - } + this.EnsureEdgesFor(source.Id) + .Add(new DirectEdgeData(this.Track(source), this.Track(target), condition)); - if (target == null) - { - throw new ArgumentNullException(nameof(target)); - } + return this; + } - if (!this._edges.TryGetValue(source.Id, out HashSet? edges)) - { - edges = new HashSet(); - this._edges[source.Id] = edges; - } + public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func>? partitioner = null, params ExecutorIsh[] targets) + { + Throw.IfNull(source); + Throw.IfNullOrEmpty(targets); + + this.EnsureEdgesFor(source.Id) + .Add(new FanOutEdgeData( + this.Track(source), + targets.Select(target => this.Track(target)), + partitioner)); + + return this; + } + + public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = default, params ExecutorIsh[] sources) + { + Throw.IfNull(target); + Throw.IfNullOrEmpty(sources); + + this.EnsureEdgesFor(target.Id) + .Add(new FanInEdgeData( + sources.Select(source => this.Track(source)), + this.Track(target), + trigger)); - edges.Add(new FlowEdge(this.Track(source), this.Track(target), condition)); return this; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index b0a14f0f0e..1ce2649e2e 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -1,38 +1,17 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; -internal static class Check -{ - public static T NotNull(T? value, [CallerArgumentExpression(nameof(value))] string? paramExpr = null) where T : class - { - if (value is null) - { - throw new ArgumentNullException(nameof(value), $"Value cannot be null: {paramExpr}"); - } - - return value; - } -} - -internal enum Activation -{ - WhenAll, -} - internal static class WorkflowBuilderExtensions { public static WorkflowBuilder AddLoop(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh loopBody, Func? condition = null) { - Check.NotNull(builder); - Check.NotNull(source); - Check.NotNull(loopBody); + Throw.IfNull(builder); + Throw.IfNull(source); + Throw.IfNull(loopBody); builder.AddEdge(source, loopBody, condition); builder.AddEdge(loopBody, source); @@ -42,114 +21,16 @@ public static WorkflowBuilder AddLoop(this WorkflowBuilder builder, ExecutorIsh public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors) { - Check.NotNull(builder); - Check.NotNull(source); + Throw.IfNull(builder); + Throw.IfNull(source); for (int i = 0; i < executors.Length; i++) { - Check.NotNull(executors[i], nameof(executors) + $"[{i}]"); + Throw.IfNull(executors[i], nameof(executors) + $"[{i}]"); builder.AddEdge(source, executors[i]); source = executors[i]; } return builder; } - - private class FanOutMessage(object message) - { - public object Content = message ?? throw new ArgumentNullException(nameof(message), "Message cannot be null"); - } - - private class FanInMessage(IEnumerable? message = null) - { - public static readonly FanInMessage Pending = new(); - - public bool IsCompleted => this.Result is not null; - public IEnumerable? Result = message; - } - - private class FanOutExecutor : Executor, IMessageHandler - { - public ValueTask HandleAsync(object message, IExecutionContext context) - { - return new ValueTask(new FanOutMessage(message)); - } - } - - public static WorkflowBuilder AddFanOut(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] targets) - { - Check.NotNull(builder); - Check.NotNull(source); - - FanOutExecutor fanOut = new(); - builder.AddEdge(source, fanOut); - - foreach (var target in targets) - { - Check.NotNull(target); - builder.AddEdge(fanOut, target); - } - - return builder; - } - - private class FanInExecutor : Executor, - IMessageHandler - { -#if NET9_0_OR_GREATER - required -#endif - public int SourceCount - { get; init; } - - public Activation Activation { get; init; } = Activation.WhenAll; - - private readonly List _messages = []; - public ValueTask HandleAsync(FanOutMessage message, IExecutionContext context) - { - this._messages.Add(message.Content); - - if (this._messages.Count >= this.SourceCount) - { - return new ValueTask(new FanInMessage(this._messages.ToArray())); - } - - return CompletedValueTaskSource.FromResult(FanInMessage.Pending); - } - } - - private class FanInUnwrapper : Executor, - IMessageHandler> - { - public ValueTask> HandleAsync(FanInMessage message, IExecutionContext context) - { - return CompletedValueTaskSource.FromResult(message.Result!); - } - } - - public static WorkflowBuilder AddFanIn(this WorkflowBuilder builder, ExecutorIsh target, Activation activation = Activation.WhenAll, params ExecutorIsh[] sources) - { - Check.NotNull(builder); - Check.NotNull(target); - - FanInExecutor fanIn = new() - { - Activation = activation, - SourceCount = sources.Length - }; - FanInUnwrapper unwrapper = new(); - - builder.AddEdge(fanIn, unwrapper, IsFanInCompleted); - builder.AddEdge(unwrapper, target); - - foreach (var source in sources) - { - Check.NotNull(source); - builder.AddEdge(source, fanIn); - } - - return builder; - - static bool IsFanInCompleted(object? message) => message is FanInMessage fanIn && fanIn.IsCompleted; - } } From a816c9704396a0b76e4699ee0d078472d53fe50e Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 4 Aug 2025 13:51:54 -0400 Subject: [PATCH 006/232] feat: Implement Local Execution --- .../Core/CompletedValueTaskSource.cs | 4 - .../Microsoft.Agents.Workflow/Core/Edges.cs | 89 +++++ .../Core/ExecutionContext.cs | 55 --- .../Core/Executor.cs | 8 +- .../Core/IWorkflowContext.cs | 27 ++ .../Core/MessageHandler.cs | 4 +- .../Core/MessageRouting.cs | 25 +- .../Core/Workflow.cs | 78 ++++ .../Execution/EdgeRunner.cs | 171 +++++++++ .../Execution/IRunnerContext.cs | 18 + .../Execution/Identity.cs | 60 ++++ .../Execution/LocalRunner.cs | 191 ++++++++++ .../Execution/LocalRunnerContext.cs | 79 ++++ .../Execution/StepContext.cs | 24 ++ .../ExecutionResult.cs | 10 + .../Microsoft.Agents.Workflow/ExecutorIsh.cs | 144 ++++++++ .../Microsoft.Agents.Workflow.csproj | 7 + .../OutputCollectorExecutor.cs | 30 ++ .../StreamingAggregators.cs | 64 ++++ .../WorkflowBuilder.cs | 339 ++---------------- .../WorkflowBuilderExtensions.cs | 36 ++ .../Sample/02_Simple_Workflow_Sequential.cs | 4 +- .../Sample/02a_Simple_Workflow_Condition.cs | 6 +- .../Sample/02b_Simple_Workflow_Loop.cs | 4 +- 24 files changed, 1077 insertions(+), 400 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs index 543ead1e98..2c8cec1e81 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs @@ -18,10 +18,6 @@ internal static class CompletedValueTaskSource internal static ValueTask FromResult(T result) { -#if NET5_0_OR_GREATER return new ValueTask(result); -#else - return new ValueTask(Task.FromResult(result)); -#endif } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs new file mode 100644 index 0000000000..b17d8ebb54 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +using PredicateT = System.Func; +using PartitionerT = System.Func>; +using System; + +namespace Microsoft.Agents.Workflows.Core; + +internal record DirectEdgeData( + string SourceId, + string SinkId, + PredicateT? Condition = null) +{ + public static implicit operator FlowEdge(DirectEdgeData data) + { + return new FlowEdge(data); + } +} + +internal record FanOutEdgeData( + string SourceId, + List SinkIds, + PartitionerT? Partitioner = null) +{ + public static implicit operator FlowEdge(FanOutEdgeData data) + { + return new FlowEdge(data); + } +} + +internal enum FanInTrigger +{ + WhenAll, + WhenAny +} + +internal record FanInEdgeData( + IEnumerable SourceIds, + string SinkId, + FanInTrigger Trigger = FanInTrigger.WhenAll) +{ + internal Guid UniqueKey { get; } = Guid.NewGuid(); + + public static implicit operator FlowEdge(FanInEdgeData data) + { + return new FlowEdge(data); + } +} + +internal class FlowEdge +{ + public enum Type + { + Direct, + FanOut, + FanIn + } + + public Type EdgeType { get; init; } + public object Data { get; init; } + + public FlowEdge(DirectEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.Direct; + } + + public FlowEdge(FanOutEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.FanOut; + } + + public FlowEdge(FanInEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.FanIn; + } + + public DirectEdgeData? DirectEdgeData => this.Data as DirectEdgeData; + public FanOutEdgeData? FanOutEdgeData => this.Data as FanOutEdgeData; + public FanInEdgeData? FanInEdgeData => this.Data as FanInEdgeData; +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs deleted file mode 100644 index 2c165505eb..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// Provides services for subclasses. -/// -public interface IExecutionContext -{ - /// - /// Send a message from the executor to the context. - /// - /// The id of the sender of the message. - /// The message to be sent. - /// A representing the asynchronous operation. - ValueTask SendMessageAsync(string sourceId, object message); - - /// - /// Drain all messages from the context. - /// - /// A representing the asynchronous operation, containing - /// a dictionary mapping executor IDs to lists of messages. - ValueTask>> DrainMessagesAsync(); - - /// - /// Check if there are any message in the context. - /// - /// A representing the asynchronous operation, containing - /// true if there are messages. false if there are not. - ValueTask HasMessagesAsync(); - - /// - /// Add an event to the execution context. - /// - /// The event to be added. - /// A representing the asynchronous operation. - ValueTask AddEventAsync(WorkflowEvent workflowEvent); - - /// - /// Drain all events from the context. - /// - /// A representing the asynchronous operation, containing - /// a list of all events. - ValueTask> DrainEventsAsync(); - - /// - /// Check if there are any events in the context. - /// - /// A representing the asynchronous operation, containing - /// true if there are events. false if there are not. - ValueTask HasEventsAsync(); -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 46084d5afc..1767c0cbaa 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -100,7 +100,7 @@ public abstract class Executor : DisposableObject, IIdentified /// public string Name { get; } - private MessageRouter MessageRouter { get; init; } + internal MessageRouter MessageRouter { get; init; } private Dictionary State { get; } = new(); /// @@ -124,7 +124,7 @@ protected Executor(string? id = null, string? name = null) /// /// /// - public async ValueTask ExecuteAsync(object message, IExecutionContext context) + public async ValueTask ExecuteAsync(object message, IWorkflowContext context) { CallResult? result = await this.MessageRouter.RouteMessageAsync(message, context, requireRoute: true) .ConfigureAwait(false); @@ -173,7 +173,7 @@ protected Executor(string? id = null, string? name = null) /// /// /// - public async ValueTask InitializeAsync(IExecutionContext context) + public async ValueTask InitializeAsync(IWorkflowContext context) { if (this._initialized) { @@ -248,7 +248,7 @@ protected virtual ValueTask AfterCheckpointRestoreAsync() /// /// /// - protected virtual ValueTask InitializeOverride(IExecutionContext context) + protected virtual ValueTask InitializeOverride(IWorkflowContext context) { // Default implementation does nothing. return CompletedValueTaskSource.Completed; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs new file mode 100644 index 0000000000..decf8ce8d4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// Provides services for an during the execution of a workflow. +/// +public interface IWorkflowContext +{ + /// + /// . + /// + /// + /// + ValueTask AddEventAsync(WorkflowEvent workflowEvent); + + /// + /// . + /// + /// + /// + ValueTask SendMessageAsync(object message); + + // TODO: State management +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs index a02c1fa042..1da548d9ba 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs @@ -16,7 +16,7 @@ public interface IMessageHandler /// The message to handle. /// The execution context. /// A task that represents the asynchronous operation. - ValueTask HandleAsync(TMessage message, IExecutionContext context); + ValueTask HandleAsync(TMessage message, IWorkflowContext context); } /// @@ -33,5 +33,5 @@ public interface IMessageHandler /// The message to handle. /// The execution context. /// A task that represents the asynchronous operation. - ValueTask HandleAsync(TMessage message, IExecutionContext context); + ValueTask HandleAsync(TMessage message, IWorkflowContext context); } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs index 9818599161..f7335c99e9 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; using HandlerInfosT = System.Collections.Generic.Dictionary< @@ -133,7 +134,7 @@ public MessageHandlerInfo(MethodInfo handlerInfo) throw new ArgumentException("Handler method must have exactly two parameters: TMessage and IExecutionContext.", nameof(handlerInfo)); } - if (parameters[1].ParameterType != typeof(IExecutionContext)) + if (parameters[1].ParameterType != typeof(IWorkflowContext)) { throw new ArgumentException("Handler method's second parameter must be of type IExecutionContext.", nameof(handlerInfo)); } @@ -163,7 +164,7 @@ public MessageHandlerInfo(MethodInfo handlerInfo) } } - public Func> Bind(Executor executor, bool checkType = false) + public Func> Bind(Executor executor, bool checkType = false) { Type? resultType = this.OutType; MethodInfo handlerMethod = this.HandlerInfo; @@ -172,13 +173,13 @@ public Func> Bind(Executor executor, bool checkTyp return InvokeHandlerAsync; // Create a delegate that binds the handler to the executor. - async ValueTask InvokeHandlerAsync(object message) + async ValueTask InvokeHandlerAsync(object message, IWorkflowContext workflowContext) { bool expectingVoid = resultType == null || resultType == typeof(void); try { - object? maybeValueTask = handlerMethod.Invoke(executor, new object[] { message, executor }); + object? maybeValueTask = handlerMethod.Invoke(executor, new object[] { message, workflowContext }); if (expectingVoid) { @@ -230,7 +231,7 @@ internal class MessageRouter // TODO: The goal of the cache is to allow SourceGenerators to do the reflection to bind the handlers in the router. internal static readonly Dictionary> s_routerFactoryCache = new(); - private Dictionary>> BoundHandlers { get; init; } = new(); + private Dictionary>> BoundHandlers { get; init; } = new(); [SuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + @@ -306,18 +307,18 @@ internal static MessageRouter BindMessageHandlers(Executor executor, bool checkT // If no factory is found, reflect over the handlers HandlerInfosT handlers = ReflectHandlers(executor.GetType()); - Dictionary>> boundHandlers = new(); + Dictionary>> boundHandlers = new(); foreach (Type inType in handlers.Keys) { MessageHandlerInfo handlerInfo = handlers[inType]; - Func> boundHandler = handlerInfo.Bind(executor, checkType); + Func> boundHandler = handlerInfo.Bind(executor, checkType); boundHandlers.Add(inType, boundHandler); // TODO: Turn the error here into something more actionable. } return new MessageRouter(boundHandlers); } - internal MessageRouter(Dictionary>> handlers) + internal MessageRouter(Dictionary>> handlers) { this.BoundHandlers = handlers; } @@ -331,7 +332,7 @@ internal MessageRouter(Dictionary>> han /// /// /// - public async ValueTask RouteMessageAsync(object message, IExecutionContext context, bool requireRoute = true) + public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) { if (message == null) { @@ -340,14 +341,16 @@ internal MessageRouter(Dictionary>> han // TODO: Implement base type delegation CallResult? result = null; - if (this.BoundHandlers.TryGetValue(message.GetType(), out Func>? handler)) + if (this.BoundHandlers.TryGetValue(message.GetType(), out Func>? handler)) { - result = await handler(message).ConfigureAwait(false); + result = await handler(message, context).ConfigureAwait(false); } return result; } + public bool CanHandle(object message) => this.CanHandle(Throw.IfNull(message).GetType()); + public bool CanHandle(Type candidateType) { if (candidateType == null) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs new file mode 100644 index 0000000000..bb92518d7b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Core; + +internal class Workflow +{ + public Dictionary> ExecutorProviders { get; internal init; } = new(); + public Dictionary> Edges { get; internal init; } = new(); + +#if NET9_0_OR_GREATER + required +#endif + public string StartExecutorId + { get; init; } + +#if NET9_0_OR_GREATER + required +#endif + public Type InputType + { get; init; } = typeof(object); + + public Workflow(string startExecutorId, Type type) + { + this.StartExecutorId = startExecutorId ?? throw new ArgumentNullException(nameof(startExecutorId)); + this.InputType = type ?? throw new ArgumentNullException(nameof(type)); + + // TODO: How do we (1) ensure the types are happy, and (2) work under AOT? + } + +#if NET9_0_OR_GREATER + public Workflow() + { } +#endif +} + +internal class Workflow : Workflow +{ + public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) + { + } + +#if NET9_0_OR_GREATER + public Workflow() + { + this.InputType = typeof(T); + } +#endif + + internal Workflow Promote(OutputSink outputSource) + { + Throw.IfNull(outputSource); + + return new Workflow(this.StartExecutorId, outputSource) + { + StartExecutorId = this.StartExecutorId, + ExecutorProviders = this.ExecutorProviders, + Edges = this.Edges, + InputType = this.InputType, + }; + } +} + +internal class Workflow : Workflow +{ + private readonly OutputSink _output; + + internal Workflow(string startExecutorId, OutputSink outputSource) + : base(startExecutorId) + { + this._output = Throw.IfNull(outputSource); + } + + public TResult? RunningOutput => this._output.Result; +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs new file mode 100644 index 0000000000..05f6f6f887 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Execution; + +internal abstract class EdgeRunner( + IRunnerContext runContext, TEdgeData edgeData) +{ + protected IRunnerContext RunContext { get; } = Throw.IfNull(runContext); + protected TEdgeData EdgeData { get; } = Throw.IfNull(edgeData); +} + +internal class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData edgeData) : + EdgeRunner(runContext, edgeData) +{ + public IWorkflowContext WorkflowContext { get; } = runContext.Bind(edgeData.SinkId); + + private async ValueTask FindRouterAsync() + { + Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); + + return sink.MessageRouter; + } + + public async ValueTask> ChaseAsync(object message) + { + if (this.EdgeData.Condition != null && !this.EdgeData.Condition(message)) + { + return []; + } + + MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); + if (router.CanHandle(message)) + { + return [await router.RouteMessageAsync(message, this.WorkflowContext) + .ConfigureAwait(false)]; + } + + return []; + } +} + +internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeData) : + EdgeRunner(runContext, edgeData) +{ + private Dictionary BoundContexts { get; } + = edgeData.SinkIds.ToDictionary( + sinkId => sinkId, + sinkId => runContext.Bind(sinkId)); + + public async ValueTask> ChaseAsync(object message) + { + List targets = + this.EdgeData.Partitioner == null + ? this.EdgeData.SinkIds + : this.EdgeData.Partitioner(message, this.BoundContexts.Count).Select(i => this.EdgeData.SinkIds[i]).ToList(); + + CallResult?[] result = await Task.WhenAll(targets.Select(ProcessTargetAsync)).ConfigureAwait(false); + return result.Where(r => r is not null); + + async Task ProcessTargetAsync(string targetId) + { + Executor executor = await this.RunContext.EnsureExecutorAsync(targetId) + .ConfigureAwait(false); + + MessageRouter router = executor.MessageRouter; + if (router.CanHandle(message)) + { + return await router.RouteMessageAsync(message, this.BoundContexts[targetId]) + .ConfigureAwait(false); + } + + return null; + } + } +} + +internal record FanInEdgeState(FanInEdgeData EdgeData) +{ + private List? _pendingMessages + = EdgeData.Trigger == FanInTrigger.WhenAll ? [] : null; + + private HashSet? _unseen + = EdgeData.Trigger == FanInTrigger.WhenAll ? new(EdgeData.SourceIds) : null; + + public IEnumerable? ProcessMessage(string sourceId, object message) + { + if (this.EdgeData.Trigger == FanInTrigger.WhenAll) + { + this._pendingMessages!.Add(message); + this._unseen!.Remove(sourceId); + + if (this._unseen.Count == 0) + { + List result = this._pendingMessages; + + this._pendingMessages = []; + this._unseen = new(this.EdgeData.SourceIds); + + return result; + } + + return null; + } + + return [message]; + } +} + +internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData) : + EdgeRunner(runContext, edgeData) +{ + private IWorkflowContext BoundContext { get; } = runContext.Bind(edgeData.SinkId); + + public FanInEdgeState CreateState() => new(this.EdgeData); + + public async ValueTask ChaseAsync(string sourceId, object message, FanInEdgeState state) + { + IEnumerable? releasedMessages = state.ProcessMessage(sourceId, message); + if (releasedMessages is null) + { + // Not ready to process yet. + return null; + } + + Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); + + MessageRouter router = sink.MessageRouter; + if (router.CanHandle(message)) + { + return await router.RouteMessageAsync(message, this.BoundContext) + .ConfigureAwait(false); + } + return null; + } +} + +internal class InputEdgeRuner(IRunnerContext runContext, string sinkId) + : EdgeRunner(runContext, sinkId) +{ + public IWorkflowContext WorkflowContext { get; } = runContext.Bind(sinkId); + + private async ValueTask FindRouterAsync() + { + Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData) + .ConfigureAwait(false); + + return sink.MessageRouter; + } + + public async ValueTask ChaseAsync(object message) + { + MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); + if (router.CanHandle(message)) + { + return await router.RouteMessageAsync(message, this.WorkflowContext) + .ConfigureAwait(false); + } + + // TODO: Throw instead? + + return null; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs new file mode 100644 index 0000000000..78770036a9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal interface IRunnerContext +{ + ValueTask AddEventAsync(string executorId, WorkflowEvent workflowEvent); + ValueTask SendMessageAsync(string executorId, object message); + + // TODO: State Management + + StepContext Advance(); + IWorkflowContext Bind(string executorId); + ValueTask EnsureExecutorAsync(string executorId); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs new file mode 100644 index 0000000000..4c99a29cea --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Agents.Workflows.Execution; + +internal readonly struct Identity : IEquatable +{ + public static Identity None { get; } = new Identity(); + + public string? Id { get; init; } + + public bool Equals(Identity other) + { + return this.Id == null + ? other.Id == null + : other.Id != null && StringComparer.OrdinalIgnoreCase.Equals(this.Id, other.Id); + } + + public override bool Equals([NotNullWhen(true)] object? obj) + { + if (this.Id == null) + { + return obj == null; + } + + if (obj == null) + { + return false; + } + + if (obj is Identity id) + { + return id.Equals(this); + } + + if (obj is string idStr) + { + return StringComparer.OrdinalIgnoreCase.Equals(this.Id, idStr); + } + + return false; + } + + public override int GetHashCode() + { + return this.Id == null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(this.Id); + } + + public static implicit operator Identity(string? id) + { + return new Identity { Id = id }; + } + + public static implicit operator string?(Identity identity) + { + return identity.Id; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs new file mode 100644 index 0000000000..18e252c274 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class EdgeMap +{ + private readonly Dictionary _edgeRunners = new(); + private readonly Dictionary _fanInState = new(); + private readonly InputEdgeRuner _inputRunner; + + public EdgeMap(IRunnerContext runContext, Dictionary> workflowEdges, string startExecutorId) + { + foreach (FlowEdge edge in workflowEdges.Values.SelectMany(e => e)) + { + object edgeRunner = edge.EdgeType switch + { + FlowEdge.Type.Direct => new DirectEdgeRunner(runContext, edge.DirectEdgeData!), + FlowEdge.Type.FanOut => new FanOutEdgeRunner(runContext, edge.FanOutEdgeData!), + FlowEdge.Type.FanIn => new FanInEdgeRunner(runContext, edge.FanInEdgeData!), + _ => throw new NotSupportedException($"Unsupported edge type: {edge.EdgeType}") + }; + + this._edgeRunners[edge] = edgeRunner; + } + + this._inputRunner = new InputEdgeRuner(runContext, startExecutorId); + } + + public async ValueTask> InvokeEdgeAsync(FlowEdge edge, string sourceId, object message) + { + if (!this._edgeRunners.TryGetValue(edge, out object? edgeRunner)) + { + throw new InvalidOperationException($"Edge {edge} not found in the edge map."); + } + + IEnumerable edgeResults; + switch (edge.EdgeType) + { + // We know the corresponding EdgeRunner type given the FlowEdge EdgeType, as + // established in the EdgeMap() ctor; this avoid doing an as-cast inside of + // the depths of the message delivery loop for every edges (multiplicity N, + // in FanIn/Out cases) + // TODO: Once we have a fixed interface, if it is reasonably generalizable + // between the Runners, we can normalize it behind an IFace. + case FlowEdge.Type.Direct: + { + DirectEdgeRunner runner = (DirectEdgeRunner)this._edgeRunners[edge]; + edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); + break; + } + + case FlowEdge.Type.FanOut: + { + FanOutEdgeRunner runner = (FanOutEdgeRunner)this._edgeRunners[edge]; + edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); + break; + } + + case FlowEdge.Type.FanIn: + { + FanInEdgeState state = this._fanInState[edge]; + FanInEdgeRunner runner = (FanInEdgeRunner)this._edgeRunners[edge]; + edgeResults = [await runner.ChaseAsync(sourceId, message, state).ConfigureAwait(false)]; + break; + } + + default: + throw new InvalidOperationException("Unknown edge type"); + + } + + return edgeResults; + } + + // TODO: Should we promote Input to a true "FlowEdge" type? + public async ValueTask> InvokeInputAsync(object inputMessage) + { + return [await this._inputRunner.ChaseAsync(inputMessage).ConfigureAwait(false)]; + } + + public ValueTask> InvokeResponseAsync(object externalResponse) + { + throw new NotImplementedException(); + } +} + +internal class LocalRunner +{ + public LocalRunner(Workflow workflow) + { + this.Workflow = workflow ?? throw new ArgumentNullException(nameof(workflow)); + this.RunContext = new LocalRunnerContext(workflow); + + // 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.StartExecutorId); + } + + protected Workflow Workflow { get; init; } + protected LocalRunnerContext RunContext { get; init; } + protected EdgeMap EdgeMap { get; init; } + + // TODO: Better signature? + public event EventHandler? WorkflowEvent; + + private void RaiseWorkflowEvent(WorkflowEvent workflowEvent) + { + this.WorkflowEvent?.Invoke(this, workflowEvent); + } + + private bool IsResponse(object message) + { + return false; + } + + private ValueTask> RouteExternalMessageAsync(object message) + { +#pragma warning disable CS0219 // Variable is assigned but its value is never used + bool isHil = false; +#pragma warning restore CS0219 // Variable is assigned but its value is never used + + return this.IsResponse(message) + ? this.EdgeMap.InvokeResponseAsync(message) + : this.EdgeMap.InvokeInputAsync(message); + } + + public async Task RunAsync(TInput input) + { + await this.RunContext.AddExternalMessageAsync(input).ConfigureAwait(false); + + // Kick everything off by sending the first message to the start executor. + Executor startExecutor = await this.RunContext.EnsureExecutorAsync(this.Workflow.StartExecutorId) + .ConfigureAwait(false); + + for (StepContext currentStep = this.RunContext.Advance(); currentStep.HasMessages; currentStep = this.RunContext.Advance()) + { + // Deliver the messages and queue the next step + List>> edgeTasks = new(); + foreach (Identity sender in currentStep.QueuedMessages.Keys) + { + IEnumerable senderMessages = currentStep.QueuedMessages[sender]; + if (sender.Id is null) + { + edgeTasks.AddRange(senderMessages.Select(message => this.RouteExternalMessageAsync(message).AsTask())); + } + else + { + HashSet outgoingEdges = this.Workflow.Edges[sender.Id!]; // Id is not null when Identity is not .None + foreach (FlowEdge outgoingEdge in outgoingEdges) + { + edgeTasks.AddRange(senderMessages.Select(message => this.EdgeMap.InvokeEdgeAsync(outgoingEdge, sender.Id, message).AsTask())); + } + } + } + + IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); + + // After the message handler invocations, we may have some events to deliver + foreach (WorkflowEvent @event in this.RunContext.QueuedEvents) + { + // TODO + } + } + } +} + +internal class LocalRunner +{ + private readonly Workflow _workflow; + private readonly LocalRunner _innerRunner; + + public LocalRunner(Workflow workflow) + { + this._workflow = Throw.IfNull(workflow); + this._innerRunner = new LocalRunner(workflow); + } + + public async Task RunAsync(TInput input) + { + await this._innerRunner.RunAsync(input).ConfigureAwait(false); + } + + public TResult? RunningOutput => this._workflow.RunningOutput; +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs new file mode 100644 index 0000000000..450ae763e1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class LocalRunnerContext : IRunnerContext +{ + private StepContext _nextStep = new(); + private readonly Dictionary> _executorProviders; + private readonly Dictionary _executors = new(); + + public LocalRunnerContext(Workflow workflow, ILogger? logger = null) + { + this._executorProviders = Throw.IfNull(workflow).ExecutorProviders; + } + + public async ValueTask EnsureExecutorAsync(string executorId) + { + if (!this._executors.TryGetValue(executorId, out var executor)) + { + if (!this._executorProviders.TryGetValue(executorId, out var provider)) + { + throw new InvalidOperationException($"Executor with ID '{executorId}' is not registered."); + } + + this._executors[executorId] = executor = provider(); + + await executor.InitializeAsync(this.Bind(executor.Id)).ConfigureAwait(false); + } + + return executor; + } + + public ValueTask AddExternalMessageAsync([NotNull] TExternalInput message) + { + Throw.IfNull(message); + + this._nextStep.MessagesFor(Identity.None).Add(message); + return CompletedValueTaskSource.Completed; + } + + public StepContext Advance() + { + return Interlocked.Exchange(ref this._nextStep, new StepContext()); + } + + public ValueTask AddEventAsync(string executorId, WorkflowEvent workflowEvent) + { + this.QueuedEvents.Add(workflowEvent); + return CompletedValueTaskSource.Completed; + } + + public ValueTask SendMessageAsync(string executorId, object message) + { + this._nextStep.MessagesFor(message.GetType().Name).Add(message); + return CompletedValueTaskSource.Completed; + } + + public IWorkflowContext Bind(string executorId) + { + return new BoundContext(this, executorId); + } + + public readonly List QueuedEvents = new(); + + private class BoundContext(LocalRunnerContext RunnerContext, string ExecutorId) : IWorkflowContext + { + public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => RunnerContext.AddEventAsync(ExecutorId, workflowEvent); + public ValueTask SendMessageAsync(object message) => RunnerContext.SendMessageAsync(ExecutorId, message); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs new file mode 100644 index 0000000000..ba305a4b46 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class StepContext +{ + public Dictionary> QueuedMessages { get; } = new(); + + public bool HasMessages => this.QueuedMessages.Values.Any(messageList => messageList.Count > 0); + + public List MessagesFor(string? executorId) + { + if (!this.QueuedMessages.TryGetValue(executorId, out var messages)) + { + messages = new List(); + this.QueuedMessages[executorId] = messages; + } + + return messages; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs new file mode 100644 index 0000000000..a73bef38f1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows; + +/// +/// . +/// +public class ExecutionResult +{ +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs new file mode 100644 index 0000000000..f40fbf606f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows; + +internal sealed class ExecutorIsh : + IIdentified, + IEquatable, + IEquatable, + IEquatable +{ + public enum Type + { + Unbound, + Executor, + //Function, + //Agent, + //ProcessStep + } + + public Type ExecutorType { get; init; } + + private readonly string? _idValue; + private readonly Executor? _executorValue; + //private readonly Func? _functionValue; + + public ExecutorIsh(Executor executor) + { + this.ExecutorType = Type.Executor; + this._executorValue = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public ExecutorIsh(string id) + { + this.ExecutorType = Type.Unbound; + this._idValue = id ?? throw new ArgumentNullException(nameof(id)); + } + + public bool IsUnbound => this.ExecutorType == Type.Unbound; + + public string Id => this.ExecutorType switch + { + Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), + Type.Executor => this._executorValue!.Id, + //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), + //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), + //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), + _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + }; + + public ExecutorProvider ExecutorProvider => this.ExecutorType switch + { + Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), + Type.Executor => () => this._executorValue!, + //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), + //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), + //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), + _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + }; + + //public ExecutorIsh(Func function) + //{ + // this.ExecutorType = Type.Function; + // this._functionValue = function ?? throw new ArgumentNullException(nameof(function)); + //} + + // Implicit conversions into ExecutorIsh + public static implicit operator ExecutorIsh(Executor executor) + { + return new ExecutorIsh(executor); + } + + // How do we AoT compile this? + //public static implicit operator ExecutorIsh(Func function) + //{ + // return new ExecutorIsh(function); + //} + + public static implicit operator ExecutorIsh(string id) + { + return new ExecutorIsh(id); + } + + public bool Equals(ExecutorIsh? other) + { + return other is not null && + other.Id == this.Id; + } + + public bool Equals(IIdentified? other) + { + return other is not null && + other.Id == this.Id; + } + + public bool Equals(string? other) + { + return other is not null && + other == this.Id; + } + + public override bool Equals(object? obj) + { + if (obj is null) + { + return false; + } + + if (obj is ExecutorIsh ish) + { + return this.Equals(ish); + } + else if (obj is IIdentified identified) + { + return this.Equals(identified); + } + else if (obj is string str) + { + return this.Equals(str); + } + + return false; + } + + public override int GetHashCode() + { + return this.Id.GetHashCode(); + } + + public override string ToString() + { + return this.ExecutorType switch + { + Type.Unbound => $"'{this.Id}':", + Type.Executor => $"'{this.Id}':{this._executorValue!.GetType()}", + //Type.Function => $"ExecutorIsh for Function with ID '{this.Id}'", + //Type.Agent => $"ExecutorIsh for Agent with ID '{this.Id}'", + //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", + _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj index 8d7a3265b0..df4590d832 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj +++ b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj @@ -18,9 +18,11 @@ Microsoft Agent Workflow Framework Contains the Microsoft Agent Workflow Framework. + Microsoft.Agents.Workflows + @@ -29,4 +31,9 @@ + + + + + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs new file mode 100644 index 0000000000..3417b36161 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows; + +internal class OutputSink : Executor +{ + public TResult? Result { get; protected set; } = default; + + internal OutputSink(string? id = null) : base(id) + { } +} + +internal class OutputCollectorExecutor : OutputSink, IMessageHandler +{ + private readonly StreamingAggregator _aggregator; + public OutputCollectorExecutor(StreamingAggregator aggregator, string? id = null) : base(id) + { + this._aggregator = Throw.IfNull(aggregator); + } + + public ValueTask HandleAsync(TInput message, IWorkflowContext context) + { + this.Result = this._aggregator(message); + return CompletedValueTaskSource.Completed; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs b/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs new file mode 100644 index 0000000000..bd212f1856 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Agents.Workflows; + +internal delegate TResult? StreamingAggregator(TInput input); + +internal static class StreamingAggregators +{ + public static StreamingAggregator First(Func conversion, TResult? defaultValue = default) + { + bool hasRun = false; + TResult? local = defaultValue; + + return Aggregate; + + TResult? Aggregate(TInput input) + { + if (!hasRun) + { + local = conversion(input); + } + + return local; + } + } + + public static StreamingAggregator First(TInput? defaultValue = default) + => First(input => input, defaultValue); + + public static StreamingAggregator Last(Func conversion, TResult? defaultValue = default) + { + TResult? local = defaultValue; + + return Aggregate; + + TResult? Aggregate(TInput input) + { + local = conversion(input); + return local; + } + } + + public static StreamingAggregator Last(TInput? defaultValue = default) + => Last(input => input, defaultValue); + + public static StreamingAggregator> Union(Func conversion) + { + List results = new(); + + return Aggregate; + + IEnumerable Aggregate(TInput input) + { + results.Add(conversion(input)); + return results; + } + } + + public static StreamingAggregator> Union() + => Union(input => input); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index f62008f15f..d9932b0bca 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -6,323 +6,22 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Execution; using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; +using System.Collections.Concurrent; #pragma warning restore IDE0005 // Using directive is unnecessary. -using ConditionalT = System.Func; - namespace Microsoft.Agents.Workflows; internal delegate TExecutor ExecutorProvider() where TExecutor : Executor; -//internal struct EdgeKey : IEquatable -//{ -// public string SourceId { get; init; } -// public string TargetId { get; init; } - -// public EdgeKey(string sourceId, string targetId) -// { -// this.SourceId = sourceId ?? throw new ArgumentNullException(nameof(sourceId)); -// this.TargetId = targetId ?? throw new ArgumentNullException(nameof(targetId)); -// } - -// public bool Equals(EdgeKey other) => this.SourceId == other.SourceId && this.TargetId == other.TargetId; -// public override bool Equals(object? obj) => obj is EdgeKey other && this.Equals(other); -// public override int GetHashCode() => HashCode.Combine(this.SourceId, this.TargetId); -//} - -/// -/// . -/// -public class ExecutionResult -{ -} - -internal sealed class ExecutorIsh : - IIdentified, - IEquatable, - IEquatable, - IEquatable -{ - public enum Type - { - Unbound, - Executor, - //Function, - //Agent, - //ProcessStep - } - - public Type ExecutorType { get; init; } - - private readonly string? _idValue; - private readonly Executor? _executorValue; - //private readonly Func? _functionValue; - - public ExecutorIsh(Executor executor) - { - this.ExecutorType = Type.Executor; - this._executorValue = executor ?? throw new ArgumentNullException(nameof(executor)); - } - - public ExecutorIsh(string id) - { - this.ExecutorType = Type.Unbound; - this._idValue = id ?? throw new ArgumentNullException(nameof(id)); - } - - public bool IsUnbound => this.ExecutorType == Type.Unbound; - - public string Id => this.ExecutorType switch - { - Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), - Type.Executor => this._executorValue!.Id, - //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), - //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), - //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), - _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") - }; - - public ExecutorProvider ExecutorProvider => this.ExecutorType switch - { - Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), - Type.Executor => () => this._executorValue!, - //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), - //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), - //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), - _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") - }; - - //public ExecutorIsh(Func function) - //{ - // this.ExecutorType = Type.Function; - // this._functionValue = function ?? throw new ArgumentNullException(nameof(function)); - //} - - // Implicit conversions into ExecutorIsh - public static implicit operator ExecutorIsh(Executor executor) - { - return new ExecutorIsh(executor); - } - - // How do we AoT compile this? - //public static implicit operator ExecutorIsh(Func function) - //{ - // return new ExecutorIsh(function); - //} - - public static implicit operator ExecutorIsh(string id) - { - return new ExecutorIsh(id); - } - - public bool Equals(ExecutorIsh? other) - { - return other is not null && - other.Id == this.Id; - } - - public bool Equals(IIdentified? other) - { - return other is not null && - other.Id == this.Id; - } - - public bool Equals(string? other) - { - return other is not null && - other == this.Id; - } - - public override bool Equals(object? obj) - { - if (obj is null) - { - return false; - } - - if (obj is ExecutorIsh ish) - { - return this.Equals(ish); - } - else if (obj is IIdentified identified) - { - return this.Equals(identified); - } - else if (obj is string str) - { - return this.Equals(str); - } - - return false; - } - - public override int GetHashCode() - { - return this.Id.GetHashCode(); - } - - public override string ToString() - { - return this.ExecutorType switch - { - Type.Unbound => $"'{this.Id}':", - Type.Executor => $"'{this.Id}':{this._executorValue!.GetType()}", - //Type.Function => $"ExecutorIsh for Function with ID '{this.Id}'", - //Type.Agent => $"ExecutorIsh for Agent with ID '{this.Id}'", - //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", - _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") - }; - } -} - -internal record DirectEdgeData( - ExecutorIsh Source, - ExecutorIsh Sink, - Func? Condition) -{ - public static implicit operator FlowEdgeEx(DirectEdgeData data) - { - return new FlowEdgeEx(data); - } -} - -internal record FanOutEdgeData( - ExecutorIsh Source, - IEnumerable Sinks, - Func>? Partitioner) // TODO: Should this be IList (to imply an ordering?)? -{ - public static implicit operator FlowEdgeEx(FanOutEdgeData data) - { - return new FlowEdgeEx(data); - } -} - -internal enum FanInTrigger -{ - WhenAll, - WhenAny -} - -internal record FanInEdgeData( - IEnumerable Sources, - ExecutorIsh Sink, - FanInTrigger Trigger = FanInTrigger.WhenAll) -{ - public static implicit operator FlowEdgeEx(FanInEdgeData data) - { - return new FlowEdgeEx(data); - } -} - -internal class FlowEdgeEx -{ - public enum Type - { - Direct, - FanOut, - FanIn - } - - public Type EdgeType { get; init; } - public object Data { get; init; } - - public FlowEdgeEx(DirectEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.Direct; - } - - public FlowEdgeEx(FanOutEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.FanOut; - } - - public FlowEdgeEx(FanInEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.FanIn; - } - - public DirectEdgeData? DirectEdgeData => this.Data as DirectEdgeData; - public FanOutEdgeData? FanOutEdgeData => this.Data as FanOutEdgeData; - public FanInEdgeData? FanInEdgeData => this.Data as FanInEdgeData; -} - -internal class FlowEdge(ExecutorIsh source, ExecutorIsh sink, ConditionalT? conditional) : IEquatable -{ - public ExecutorIsh Source { get; init; } = source ?? throw new ArgumentNullException(nameof(source)); - public ExecutorIsh Sink { get; } = sink ?? throw new ArgumentNullException(nameof(sink)); - public Func? Condition { get; } = conditional; - - public bool Equals(FlowEdge? other) - { - return other is null - ? false - : this.Source.Equals(other.Source) && this.Sink.Equals(other.Sink); - } - - public override bool Equals(object? obj) => obj is FlowEdge other && this.Equals(other); - public override int GetHashCode() => HashCode.Combine(this.Source.GetHashCode(), this.Sink.GetHashCode()); -} - -internal class Workflow -{ - public Dictionary> Executors { get; internal init; } = new(); - public Dictionary> Edges { get; internal init; } = new(); - -#if NET9_0_OR_GREATER - required -#endif - public string StartExecutorId - { get; init; } - -#if NET9_0_OR_GREATER - required -#endif - public Type InputType - { get; init; } = typeof(object); - - public Workflow(string startExecutorId, Type type) - { - this.StartExecutorId = startExecutorId ?? throw new ArgumentNullException(nameof(startExecutorId)); - this.InputType = type ?? throw new ArgumentNullException(nameof(type)); - - // TODO: How do we (1) ensure the types are happy, and (2) work under AOT? - } - -#if NET9_0_OR_GREATER - public Workflow() - { } -#endif -} - -// Just a decorator for the purposes of keeping type type where we can -internal class Workflow : Workflow -{ - public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) - { - } - -#if NET9_0_OR_GREATER - public Workflow() - { - this.InputType = typeof(T); - } -#endif -} - internal class WorkflowBuilder { private readonly Dictionary> _executors = new(); - private readonly Dictionary> _edges = new(); + private readonly Dictionary> _edges = new(); private readonly HashSet _unboundExecutors = new(); private readonly string _startExecutorId; @@ -371,13 +70,13 @@ public WorkflowBuilder BindExecutor(Executor executor) return this; } - private HashSet EnsureEdgesFor(string sourceId) + private HashSet EnsureEdgesFor(string sourceId) { // Ensure that there is a set of edges for the given source ID. // If it does not exist, create a new one. - if (!this._edges.TryGetValue(sourceId, out HashSet? edges)) + if (!this._edges.TryGetValue(sourceId, out HashSet? edges)) { - this._edges[sourceId] = edges = new HashSet(); + this._edges[sourceId] = edges = new HashSet(); } return edges; @@ -392,20 +91,22 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func>? partitioner = null, params ExecutorIsh[] targets) + // output int strictly element-of [0, count) + + public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func>? partitioner = null, params ExecutorIsh[] targets) { Throw.IfNull(source); Throw.IfNullOrEmpty(targets); this.EnsureEdgesFor(source.Id) .Add(new FanOutEdgeData( - this.Track(source), - targets.Select(target => this.Track(target)), + this.Track(source).Id, + targets.Select(target => this.Track(target).Id).ToList(), partitioner)); return this; @@ -416,11 +117,15 @@ public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = d Throw.IfNull(target); Throw.IfNullOrEmpty(sources); - this.EnsureEdgesFor(target.Id) - .Add(new FanInEdgeData( - sources.Select(source => this.Track(source)), - this.Track(target), - trigger)); + FanInEdgeData edgeData = new( + sources.Select(source => this.Track(source).Id).ToList(), + this.Track(target).Id, + trigger); + + foreach (string sourceId in edgeData.SourceIds) + { + this.EnsureEdgesFor(sourceId).Add(edgeData); + } return this; } @@ -451,7 +156,7 @@ public Workflow Build() return new Workflow(this._startExecutorId) // Why does it not see the default ctor? { - Executors = this._executors, + ExecutorProviders = this._executors, Edges = this._edges, StartExecutorId = this._startExecutorId, InputType = typeof(T) diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index 1ce2649e2e..188ecb2d14 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; @@ -33,4 +34,39 @@ public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh return builder; } + + public static Workflow BuildWithOutput(this WorkflowBuilder builder, ExecutorIsh outputSource, StreamingAggregator aggregator) + => builder.BuildWithOutput(outputSource, aggregator); + + public static Workflow BuildWithOutput(this WorkflowBuilder builder, ExecutorIsh outputSource, StreamingAggregator aggregator) + { + Throw.IfNull(outputSource); + Throw.IfNull(aggregator); + + OutputCollectorExecutor outputSink = new(aggregator); + + // TODO: Check taht the outputSource has a TResult output? + builder.AddEdge(outputSource, outputSink); + + Workflow workflow = builder.Build(); + return workflow.Promote(outputSink); + } + + //public static WorkflowBuilder AddMapReduce } + +//class T +//{ +// async Task A() +// { +// WorkflowBuilder b; + +// Workflow> wf = +// WorkflowBuilderExtensions.BuildWithOutput>(b, "my_last_node", StreamingAggregators.Union()); + +// LocalRunner> runner = new(wf); + +// await runner.RunAsync(42).ConfigureAwait(false); +// var result = runner.RunningOutput; +// } +//} diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs index d9af8f9f3a..33004f8170 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs @@ -25,7 +25,7 @@ public static ValueTask RunAsync() internal sealed class UppercaseExecutor : Executor, IMessageHandler { - public ValueTask HandleAsync(string message, IExecutionContext context) + public ValueTask HandleAsync(string message, IWorkflowContext context) { return CompletedValueTaskSource.FromResult(message.ToUpperInvariant()); } @@ -33,7 +33,7 @@ public ValueTask HandleAsync(string message, IExecutionContext context) internal sealed class ReverseTextExecutor : Executor, IMessageHandler { - public ValueTask HandleAsync(string message, IExecutionContext context) + public ValueTask HandleAsync(string message, IWorkflowContext context) { char[] charArray = message.ToCharArray(); System.Array.Reverse(charArray); diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs index 42f23a7dd4..cb5d950b6d 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs @@ -38,7 +38,7 @@ public DetectSpamExecutor(params string[] spamKeywords) this.SpamKeywords = spamKeywords; } - public ValueTask HandleAsync(string message, IExecutionContext context) + public ValueTask HandleAsync(string message, IWorkflowContext context) { #if NET5_0_OR_GREATER bool isSpam = this.SpamKeywords.Any(keyword => message.Contains(keyword, StringComparison.OrdinalIgnoreCase)); @@ -52,7 +52,7 @@ public ValueTask HandleAsync(string message, IExecutionContext context) internal sealed class RespondToMessageExecutor : Executor, IMessageHandler { - public async ValueTask HandleAsync(bool message, IExecutionContext context) + public async ValueTask HandleAsync(bool message, IWorkflowContext context) { if (message) { @@ -69,7 +69,7 @@ await context.AddEventAsync(new WorkflowCompletedEvent { Data = "Message process internal sealed class RemoveSpamExecutor : Executor, IMessageHandler { - public async ValueTask HandleAsync(bool message, IExecutionContext context) + public async ValueTask HandleAsync(bool message, IWorkflowContext context) { if (!message) { diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs index c235352725..6ab2b232e5 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs @@ -45,7 +45,7 @@ public GuessNumberExecutor(int lowerBound, int upperBound) private int NextGuess => (this.LowerBound + this.UpperBound) / 2; private int _currGuess = -1; - public async ValueTask HandleAsync(NumberSignal message, IExecutionContext context) + public async ValueTask HandleAsync(NumberSignal message, IWorkflowContext context) { switch (message) { @@ -75,7 +75,7 @@ public JudgeExecutor(int targetNumber) this._targetNumber = targetNumber; } - public ValueTask HandleAsync(int message, IExecutionContext context) + public ValueTask HandleAsync(int message, IWorkflowContext context) { if (message == this._targetNumber) { From 6628438f8d89924f9289049dcd6ae4656d3226e3 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 4 Aug 2025 14:02:48 -0400 Subject: [PATCH 007/232] refactor: Assembly name .Workflow => .Workflows --- dotnet/agent-framework-dotnet.slnx | 2 +- ...Agents.Workflow.csproj => Microsoft.Agents.Workflows.csproj} | 2 -- .../Microsoft.Agents.Workflow.UnitTests.csproj | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) rename dotnet/src/Microsoft.Agents.Workflow/{Microsoft.Agents.Workflow.csproj => Microsoft.Agents.Workflows.csproj} (91%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 91cbf5db1f..e79922db8d 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -116,7 +116,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj similarity index 91% rename from dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj rename to dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj index df4590d832..851213dbe7 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj +++ b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj @@ -3,7 +3,6 @@ $(ProjectsTargetFrameworks) $(ProjectsDebugTargetFrameworks) - Microsoft.Agents.Workflow alpha @@ -18,7 +17,6 @@ Microsoft Agent Workflow Framework Contains the Microsoft Agent Workflow Framework. - Microsoft.Agents.Workflows diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj index f4e4b48d84..d384557d8f 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj @@ -6,7 +6,7 @@ - + From 27d322d80c2732c66611ff491ea549b0b84c0552 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 12:49:32 -0400 Subject: [PATCH 008/232] feat: Enable Default Message Handling * also lifts Bind in MessageHandlerInfo to better be able to direclty invoke handlers (for AOT, later) --- .../Core/MessageHandler.cs | 17 ++++++ .../Core/MessageRouting.cs | 60 ++++++++++++------- 2 files changed, 57 insertions(+), 20 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs index 1da548d9ba..a607736f40 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs @@ -4,6 +4,23 @@ namespace Microsoft.Agents.Workflows.Core; +/// +/// A default message handler interface for handling messages that do not have a specific handler registered. +/// +public interface IDefaultMessageHandler +{ + /// + /// Handles the incoming message asynchronously. + /// + /// + /// This is used as a fallback handler for messages that do not have a specific handler registered. + /// + /// The message to handle. + /// The execution context. + /// A task that represents the asynchronous operation. + ValueTask HandleAsync(object message, IWorkflowContext context); +} + /// /// A message handler interface for handling messages of type . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs index f7335c99e9..8d899bff94 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs @@ -164,22 +164,17 @@ public MessageHandlerInfo(MethodInfo handlerInfo) } } - public Func> Bind(Executor executor, bool checkType = false) + public static Func> Bind(Func handlerAsync, bool checkType, Type? resultType = null, Func>? unwrapper = null) { - Type? resultType = this.OutType; - MethodInfo handlerMethod = this.HandlerInfo; - Func>? unwrapper = this.Unwrapper; - return InvokeHandlerAsync; - // Create a delegate that binds the handler to the executor. async ValueTask InvokeHandlerAsync(object message, IWorkflowContext workflowContext) { bool expectingVoid = resultType == null || resultType == typeof(void); try { - object? maybeValueTask = handlerMethod.Invoke(executor, new object[] { message, workflowContext }); + object? maybeValueTask = handlerAsync(message, workflowContext); if (expectingVoid) { @@ -190,7 +185,7 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext } throw new InvalidOperationException( - $"Handler method {handlerMethod.Name} is expected to return ValueTask or ValueTask, but returned " + + "Handler method is expected to return ValueTask or ValueTask, but returned " + $"{maybeValueTask?.GetType().Name ?? "null"}."); } @@ -198,13 +193,13 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext if (unwrapper == null) { throw new InvalidOperationException( - $"Handler method {handlerMethod.Name} is expected to return ValueTask<{resultType!.Name}>, but no unwrapper is available."); + $"Handler method is expected to return ValueTask<{resultType!.Name}>, but no unwrapper is available."); } if (maybeValueTask == null) { throw new InvalidOperationException( - $"Handler method {handlerMethod.Name} returned null, but a ValueTask<{resultType!.Name}> was expected."); + $"Handler method returned null, but a ValueTask<{resultType!.Name}> was expected."); } object? result = await unwrapper(maybeValueTask).ConfigureAwait(false); @@ -212,7 +207,7 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext if (checkType && result != null && !resultType.IsInstanceOfType(result)) { throw new InvalidOperationException( - $"Handler method {handlerMethod.Name} returned an incompatible type: expected {resultType.Name}, got {result.GetType().Name}."); + $"Handler method returned an incompatible type: expected {resultType.Name}, got {result.GetType().Name}."); } return CallResult.ReturnResult(result); @@ -224,6 +219,17 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext } } } + + public Func> Bind(Executor executor, bool checkType = false) + { + MethodInfo handlerMethod = this.HandlerInfo; + return MessageHandlerInfo.Bind(InvokeHandler, checkType, this.OutType, this.Unwrapper); + + object? InvokeHandler(object message, IWorkflowContext workflowContext) + { + return handlerMethod.Invoke(executor, new object[] { message, workflowContext }); + } + } } internal class MessageRouter @@ -232,6 +238,7 @@ internal class MessageRouter internal static readonly Dictionary> s_routerFactoryCache = new(); private Dictionary>> BoundHandlers { get; init; } = new(); + private IDefaultMessageHandler? DefaultHandler { get; init; } = null; [SuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + @@ -315,12 +322,13 @@ internal static MessageRouter BindMessageHandlers(Executor executor, bool checkT boundHandlers.Add(inType, boundHandler); // TODO: Turn the error here into something more actionable. } - return new MessageRouter(boundHandlers); + return new MessageRouter(boundHandlers, executor as IDefaultMessageHandler); } - internal MessageRouter(Dictionary>> handlers) + internal MessageRouter(Dictionary>> handlers, IDefaultMessageHandler? defaultHandler = null) { this.BoundHandlers = handlers; + this.DefaultHandler = defaultHandler; } /// @@ -339,12 +347,24 @@ internal MessageRouter(Dictionary>? handler)) { result = await handler(message, context).ConfigureAwait(false); } + else if (this.DefaultHandler != null) + { + try + { + await this.DefaultHandler.HandleAsync(message, context).ConfigureAwait(false); + result = CallResult.ReturnVoid(); + } + catch (Exception e) + { + result = CallResult.RaisedException(wasVoid: true, e); + } + } return result; } @@ -353,14 +373,14 @@ internal MessageRouter(Dictionary IncomingTypes => [.. this.BoundHandlers.Keys]; + public HashSet IncomingTypes + => this.DefaultHandler != null + ? [.. this.BoundHandlers.Keys, typeof(object)] + : [.. this.BoundHandlers.Keys]; } From 6801ea17c2dc87893ae005f3616e4ac8b4cfe92c Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 12:55:35 -0400 Subject: [PATCH 009/232] feat: Implement StreamingHandle APIs This allows the user to respond to WorkflowEvents with external messages, enabling HIL. --- .../Execution/ExecutionHandle.cs | 170 ++++++++++++++++++ .../Execution/LocalRunner.cs | 95 +++++++--- .../Execution/LocalRunnerContext.cs | 2 +- .../ExecutionResult.cs | 10 -- .../Microsoft.Agents.Workflows.csproj | 1 - .../WorkflowBuilder.cs | 2 + .../Sample/02_Simple_Workflow_Sequential.cs | 9 +- .../Sample/02a_Simple_Workflow_Condition.cs | 9 +- .../Sample/02b_Simple_Workflow_Loop.cs | 10 +- 9 files changed, 254 insertions(+), 54 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs new file mode 100644 index 0000000000..6bab2c0aea --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Execution; + +internal interface ISuperStepRunner +{ + ValueTask EnqueueMessageAsync(object message); + + event EventHandler? WorkflowEvent; + + ValueTask RunSuperStepAsync(CancellationToken cancellation); +} + +internal interface IRunnerWithResult +{ + ISuperStepRunner StepRunner { get; } + + ValueTask GetResultAsync(CancellationToken cancellation = default); +} + +/// +/// . +/// +public class StreamingExecutionHandle +{ + private readonly ISuperStepRunner _stepRunner; + + internal StreamingExecutionHandle(ISuperStepRunner stepRunner) + { + this._stepRunner = Throw.IfNull(stepRunner); + } + + /// + /// . + /// + /// + /// + /// + public ValueTask SendResponseAsync(object response) + { + return this._stepRunner.EnqueueMessageAsync(response); + } + + /// + /// . + /// + /// + /// + /// + public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancellation] CancellationToken cancellation) + { + List eventSink = new(); + + this._stepRunner.WorkflowEvent += OnWorkflowEvent; + + try + { + while (await this._stepRunner.RunSuperStepAsync(cancellation).ConfigureAwait(false)) + { + List outputEvents = Interlocked.Exchange(ref eventSink, new()); + foreach (WorkflowEvent raisedEvent in outputEvents) + { + yield return raisedEvent; + } + } + } + finally + { + this._stepRunner.WorkflowEvent -= OnWorkflowEvent; + } + + void OnWorkflowEvent(object? sender, WorkflowEvent e) + { + eventSink.Add(e); + } + } +} + +/// +/// . +/// +/// +public class StreamingExecutionHandle : StreamingExecutionHandle +{ + private readonly IRunnerWithResult _resultSource; + + internal StreamingExecutionHandle(IRunnerWithResult runner) + : base(Throw.IfNull(runner.StepRunner)) + { + this._resultSource = runner; + } + + /// + /// . + /// + /// + /// + /// + public ValueTask GetResultAsync(CancellationToken cancellation = default) + { + return this._resultSource.GetResultAsync(cancellation); + } +} + +/// +/// . +/// +public static class ExecutionHandleExtensions +{ + /// + /// Processes all events from the workflow execution stream until completion. + /// + /// This method continuously monitors the workflow execution stream provided by and invokes the for each event. If the callback returns a + /// non- response, the response is sent back to the workflow using the handle. + /// The representing the workflow execution stream to monitor. + /// An optional callback function invoked for each received from the stream. The + /// callback can return a response object to be sent back to the workflow, or if no response + /// is required. + /// A to observe while waiting for events. Defaults to . + /// A that represents the asynchronous operation. The task completes when the workflow + /// execution stream is fully processed. + public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) + { + Throw.IfNull(handle); + + await foreach (WorkflowEvent @event in handle.WatchStreamAsync(cancellation).ConfigureAwait(false)) + { + object? maybeResponse = eventCallback?.Invoke(@event); + if (maybeResponse != null) + { + await handle.SendResponseAsync(maybeResponse).ConfigureAwait(false); + } + } + } + + /// + /// Executes the workflow associated with the specified until it + /// completes and returns the final result. + /// + /// This method ensures that the workflow runs to completion before returning the result. If an + /// is provided, it will be invoked for each event emitted during the workflow's + /// execution, allowing for custom event handling. + /// The type of the result produced by the workflow. + /// The representing the workflow to execute. This parameter cannot + /// be . + /// An optional callback function that is invoked for each emitted during the workflow + /// execution. The callback can process the event and return an object, or if no processing + /// is required. + /// A that can be used to cancel the workflow execution. The default value is . + /// A that represents the asynchronous operation. The task's result is the final + /// result of the workflow execution. + public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) + { + Throw.IfNull(handle); + + await handle.RunToCompletionAsync(eventCallback, cancellation).ConfigureAwait(false); + return await handle.GetResultAsync(cancellation).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index 18e252c274..c0533a1087 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; @@ -91,7 +92,7 @@ public EdgeMap(IRunnerContext runContext, Dictionary> } } -internal class LocalRunner +internal class LocalRunner : ISuperStepRunner where TInput : notnull { public LocalRunner(Workflow workflow) { @@ -103,6 +104,11 @@ public LocalRunner(Workflow workflow) this.EdgeMap = new EdgeMap(this.RunContext, this.Workflow.Edges, this.Workflow.StartExecutorId); } + ValueTask ISuperStepRunner.EnqueueMessageAsync(object message) + { + return this.RunContext.AddExternalMessageAsync(message); + } + protected Workflow Workflow { get; init; } protected LocalRunnerContext RunContext { get; init; } protected EdgeMap EdgeMap { get; init; } @@ -131,47 +137,68 @@ private bool IsResponse(object message) : this.EdgeMap.InvokeInputAsync(message); } - public async Task RunAsync(TInput input) + public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) { await this.RunContext.AddExternalMessageAsync(input).ConfigureAwait(false); - // Kick everything off by sending the first message to the start executor. - Executor startExecutor = await this.RunContext.EnsureExecutorAsync(this.Workflow.StartExecutorId) - .ConfigureAwait(false); + return new StreamingExecutionHandle(this); + } + + private StepContext? _currentStep = null; + public async ValueTask RunSuperStepAsync(CancellationToken cancellation) + { + cancellation.ThrowIfCancellationRequested(); - for (StepContext currentStep = this.RunContext.Advance(); currentStep.HasMessages; currentStep = this.RunContext.Advance()) + if (this._currentStep == null) { - // Deliver the messages and queue the next step - List>> edgeTasks = new(); - foreach (Identity sender in currentStep.QueuedMessages.Keys) + this._currentStep = this.RunContext.Advance(); + } + + if (this._currentStep.HasMessages) + { + await this.RunSuperstepAsync(this._currentStep).ConfigureAwait(false); + return true; + } + + return false; + } + + private async ValueTask RunSuperstepAsync(StepContext currentStep) + { + // Deliver the messages and queue the next step + List>> edgeTasks = new(); + foreach (Identity sender in currentStep.QueuedMessages.Keys) + { + IEnumerable senderMessages = currentStep.QueuedMessages[sender]; + if (sender.Id is null) { - IEnumerable senderMessages = currentStep.QueuedMessages[sender]; - if (sender.Id is null) - { - edgeTasks.AddRange(senderMessages.Select(message => this.RouteExternalMessageAsync(message).AsTask())); - } - else + edgeTasks.AddRange(senderMessages.Select(message => this.RouteExternalMessageAsync(message).AsTask())); + } + else + { + HashSet outgoingEdges = this.Workflow.Edges[sender.Id!]; // Id is not null when Identity is not .None + foreach (FlowEdge outgoingEdge in outgoingEdges) { - HashSet outgoingEdges = this.Workflow.Edges[sender.Id!]; // Id is not null when Identity is not .None - foreach (FlowEdge outgoingEdge in outgoingEdges) - { - edgeTasks.AddRange(senderMessages.Select(message => this.EdgeMap.InvokeEdgeAsync(outgoingEdge, sender.Id, message).AsTask())); - } + edgeTasks.AddRange(senderMessages.Select(message => this.EdgeMap.InvokeEdgeAsync(outgoingEdge, sender.Id, message).AsTask())); } } + } - IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); + // TODO: Should we let the user specify that they want strictly turn-based execution of the edges, vs. concurrent? + // (Simply substitute a strategy that replaces Task.WhenAll with a loop with an await in the middle. Difficulty is + // that we would need to avoid firing the tasks when we call InvokeEdgeAsync, or RouteExternalMessageAsync. + IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); - // After the message handler invocations, we may have some events to deliver - foreach (WorkflowEvent @event in this.RunContext.QueuedEvents) - { - // TODO - } + // TODO: Commit the state updates (so they are visible to the next step) + // After the message handler invocations, we may have some events to deliver + foreach (WorkflowEvent @event in this.RunContext.QueuedEvents) + { + this.RaiseWorkflowEvent(@event); } } } -internal class LocalRunner +internal class LocalRunner : IRunnerWithResult where TInput : notnull { private readonly Workflow _workflow; private readonly LocalRunner _innerRunner; @@ -182,10 +209,20 @@ public LocalRunner(Workflow workflow) this._innerRunner = new LocalRunner(workflow); } - public async Task RunAsync(TInput input) + public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) { - await this._innerRunner.RunAsync(input).ConfigureAwait(false); + await this.StepRunner.EnqueueMessageAsync(input).ConfigureAwait(false); + + return new StreamingExecutionHandle(this._innerRunner); + } + + public ValueTask GetResultAsync(CancellationToken cancellation = default) + { + // TODO: Block on finishing consuming StreamAsync()? + return CompletedValueTaskSource.FromResult(this.RunningOutput!); } public TResult? RunningOutput => this._workflow.RunningOutput; + + public ISuperStepRunner StepRunner => this._innerRunner; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index 450ae763e1..0275b32f30 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -39,7 +39,7 @@ public async ValueTask EnsureExecutorAsync(string executorId) return executor; } - public ValueTask AddExternalMessageAsync([NotNull] TExternalInput message) + public ValueTask AddExternalMessageAsync([NotNull] object message) { Throw.IfNull(message); diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs deleted file mode 100644 index a73bef38f1..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.Workflows; - -/// -/// . -/// -public class ExecutionResult -{ -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj index 851213dbe7..a9023a799c 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj @@ -30,7 +30,6 @@ - diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index d9932b0bca..ada872384e 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -152,6 +152,8 @@ public Workflow Build() { // We have no handlers for the input type T, which means the built workflow will not be able to // process messages of the desired type + throw new InvalidOperationException( + $"Workflow cannot be built because the starting executor {this._startExecutorId} does not contain a handler for the desired input type {typeof(T).Name}"); } return new Workflow(this._startExecutorId) // Why does it not see the default ctor? diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs index 33004f8170..af50478e26 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs @@ -2,12 +2,13 @@ using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; namespace Microsoft.Agents.Workflows.Sample; internal static class Step2EntryPoint { - public static ValueTask RunAsync() + public static async ValueTask RunAsync() { UppercaseExecutor uppercase = new(); ReverseTextExecutor reverse = new(); @@ -16,10 +17,10 @@ public static ValueTask RunAsync() builder.AddEdge(uppercase, reverse); Workflow workflow = builder.Build(); - // async foreach (var event in workflow.RunAsync("hello world")) - // await Console.Out.WriteLineAsync(event); + LocalRunner runner = new(workflow); - return CompletedValueTaskSource.Completed; + var handle = await runner.StreamAsync("Hello, World!").ConfigureAwait(false); + await handle.RunToCompletionAsync().ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs index cb5d950b6d..d782783404 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs @@ -4,12 +4,13 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; namespace Microsoft.Agents.Workflows.Sample; internal static class Step2aEntryPoint { - public static ValueTask RunAsync() + public static async ValueTask RunAsync() { string[] spamKeywords = { "spam", "advertisement", "offer" }; @@ -22,10 +23,10 @@ public static ValueTask RunAsync() .AddEdge(detectSpam, removeSpam, isSpam => isSpam is false) // If spam, remove .Build(); - // async foreach (var event in workflow.RunAsync("This is a spam message.")) - // await Console.Out.WriteLineAsync(event); + LocalRunner runner = new(workflow); - return CompletedValueTaskSource.Completed; + StreamingExecutionHandle handle = await runner.StreamAsync("This is a spam message.").ConfigureAwait(false); + await handle.RunToCompletionAsync().ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs index 6ab2b232e5..3ce581bf8b 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs @@ -2,12 +2,13 @@ using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; namespace Microsoft.Agents.Workflows.Sample; internal static class Step2bEntryPoint { - public static ValueTask RunAsync() + public static async ValueTask RunAsync() { GuessNumberExecutor guessNumber = new(1, 100); JudgeExecutor judge = new(42); // Let's say the target number is 42 @@ -16,10 +17,9 @@ public static ValueTask RunAsync() .AddLoop(guessNumber, judge) .Build(); - // async foreach (var event in workflow.RunAsync(NumberSignal.Init)) - // await Console.Out.WriteLineAsync(event); - - return CompletedValueTaskSource.Completed; + LocalRunner runner = new(workflow); + StreamingExecutionHandle handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); + await handle.RunToCompletionAsync(); } } From 601b89da4745735e15f8cfddcca1e0a077ab7e39 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 13:10:30 -0400 Subject: [PATCH 010/232] feat: Add checks for duplicate edges and chain cycles --- .../Microsoft.Agents.Workflows.csproj | 10 ++++++---- .../Microsoft.Agents.Workflow/WorkflowBuilder.cs | 14 ++++++++++++++ .../WorkflowBuilderExtensions.cs | 12 ++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj index a9023a799c..478396f484 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj @@ -19,6 +19,12 @@ Contains the Microsoft Agent Workflow Framework. + + + + + + @@ -29,8 +35,4 @@ - - - - \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index ada872384e..28416db074 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -18,11 +18,17 @@ namespace Microsoft.Agents.Workflows; internal delegate TExecutor ExecutorProvider() where TExecutor : Executor; +internal record struct EdgeId(string SourceId, string TargetId) +{ + public override string ToString() => $"{this.SourceId} -> {this.TargetId}"; +} + internal class WorkflowBuilder { private readonly Dictionary> _executors = new(); private readonly Dictionary> _edges = new(); private readonly HashSet _unboundExecutors = new(); + private readonly HashSet _conditionlessEdges = new(); private readonly string _startExecutorId; @@ -90,6 +96,14 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func seenExecutors = new(); + seenExecutors.Add(source.Id); + for (int i = 0; i < executors.Length; i++) { Throw.IfNull(executors[i], nameof(executors) + $"[{i}]"); + + if (seenExecutors.Contains(executors[i].Id)) + { + throw new ArgumentException($"Executor '{executors[i].Id}' is already in the chain.", nameof(executors)); + } + seenExecutors.Add(executors[i].Id); + builder.AddEdge(source, executors[i]); source = executors[i]; } From f1c8d6da5bb5526e3e0d75e13c917d0f61758ac7 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 13:48:33 -0400 Subject: [PATCH 011/232] feat: Add built-in WorkflowEvents --- .../Microsoft.Agents.Workflow/Core/Events.cs | 38 +++---------------- .../Core/Executor.cs | 4 ++ .../Execution/LocalRunner.cs | 6 +++ .../Execution/LocalRunnerContext.cs | 2 + 4 files changed, 18 insertions(+), 32 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs index 041944ea36..6ce2a05702 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -using System; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; @@ -27,27 +27,15 @@ public record ExecutorEvent : WorkflowEvent /// /// The identifier of the executor that generated this event. /// -#if NET9_0_OR_GREATER - required -#endif - public string ExecutorId - { get; init; } + public string ExecutorId { get; } /// /// . /// public ExecutorEvent(string executorId, object? data = null) : base(data) { - this.ExecutorId = executorId ?? throw new ArgumentNullException(nameof(executorId), "Executor ID cannot be null."); + this.ExecutorId = Throw.IfNull(executorId); } - -#if NET9_0_OR_GREATER - /// - /// . - /// - public ExecutorEvent() - { } -#endif } /// @@ -58,15 +46,9 @@ public record ExecutorInvokeEvent : ExecutorEvent /// /// . /// - public ExecutorInvokeEvent(string executorId, object? data = null) : base(executorId, data) { } - -#if NET9_0_OR_GREATER - /// - /// . - /// - public ExecutorInvokeEvent() - { } -#endif + public ExecutorInvokeEvent(string executorId, object? data = null) : base(executorId, data) + { + } } /// @@ -78,14 +60,6 @@ public record ExecutorCompleteEvent : ExecutorEvent /// . /// public ExecutorCompleteEvent(string executorId, object? data = null) : base(executorId, data) { } - -#if NET9_0_OR_GREATER - /// - /// . - /// - public ExecutorCompleteEvent() - { } -#endif } // TODO: This is a placeholder for streaming chat message content. diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 1767c0cbaa..451be5135b 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -126,9 +126,13 @@ protected Executor(string? id = null, string? name = null) /// public async ValueTask ExecuteAsync(object message, IWorkflowContext context) { + await context.AddEventAsync(new ExecutorInvokeEvent(this.Id)).ConfigureAwait(false); + CallResult? result = await this.MessageRouter.RouteMessageAsync(message, context, requireRoute: true) .ConfigureAwait(false); + await context.AddEventAsync(new ExecutorCompleteEvent(this.Id)).ConfigureAwait(false); + if (result == null) { throw new NotSupportedException( diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index c0533a1087..1605c77c5a 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -109,6 +109,7 @@ ValueTask ISuperStepRunner.EnqueueMessageAsync(object message) return this.RunContext.AddExternalMessageAsync(message); } + protected Dictionary PendingCalls { get; } = new(); protected Workflow Workflow { get; init; } protected LocalRunnerContext RunContext { get; init; } protected EdgeMap EdgeMap { get; init; } @@ -151,12 +152,16 @@ public async ValueTask RunSuperStepAsync(CancellationToken cancellation) if (this._currentStep == null) { + // TODO: Python-side does not raise this event. + // await this.RunContext.AddEventAsync(this.Workflow.StartExecutorId, new WorkflowStartedEvent()).ConfigureAwait(false); this._currentStep = this.RunContext.Advance(); } if (this._currentStep.HasMessages) { await this.RunSuperstepAsync(this._currentStep).ConfigureAwait(false); + this._currentStep = this.RunContext.Advance(); + return true; } @@ -190,6 +195,7 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); // TODO: Commit the state updates (so they are visible to the next step) + // After the message handler invocations, we may have some events to deliver foreach (WorkflowEvent @event in this.RunContext.QueuedEvents) { diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index 0275b32f30..c58a891e7f 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -47,6 +47,8 @@ public ValueTask AddExternalMessageAsync([NotNull] object message) return CompletedValueTaskSource.Completed; } + public bool NextStepHasActions => this._nextStep.HasMessages; + public StepContext Advance() { return Interlocked.Exchange(ref this._nextStep, new StepContext()); From 5d709fc0f8f22bf9dc22d588bf6061c8bba24353 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 13:55:59 -0400 Subject: [PATCH 012/232] refactor: Pull classes into own files --- .../Core/CallResult.cs | 78 ++++ .../Core/Executor.cs | 74 ---- .../Core/ExecutorCapabilities.cs | 69 ++++ .../Core/IDefaultMessageHandler.cs | 22 + .../Core/IIdentified.cs | 14 + .../{MessageHandler.cs => IMessageHandler.cs} | 17 - .../Core/MessageHandlerInfo.cs | 131 ++++++ .../Core/MessageRouter.cs | 169 ++++++++ .../Core/MessageRouting.cs | 386 ------------------ .../Core/StreamsMessageAttribute.cs | 26 ++ ...TypeErasure.cs => ValueTaskTypeErasure.cs} | 0 .../Execution/EdgeMap.cs | 91 +++++ .../{Identity.cs => ExecutorIdentity.cs} | 14 +- .../Execution/IRunnerWithResult.cs | 13 + .../Execution/ISuperStepRunner.cs | 17 + .../Execution/LocalRunner.cs | 84 +--- .../Execution/LocalRunnerContext.cs | 2 +- .../Execution/StepContext.cs | 2 +- ...nHandle.cs => StreamingExecutionHandle.cs} | 16 - 19 files changed, 640 insertions(+), 585 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/IIdentified.cs rename dotnet/src/Microsoft.Agents.Workflow/Core/{MessageHandler.cs => IMessageHandler.cs} (68%) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandlerInfo.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs rename dotnet/src/Microsoft.Agents.Workflow/Core/{TypeErasure.cs => ValueTaskTypeErasure.cs} (100%) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs rename dotnet/src/Microsoft.Agents.Workflow/Execution/{Identity.cs => ExecutorIdentity.cs} (70%) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs rename dotnet/src/Microsoft.Agents.Workflow/Execution/{ExecutionHandle.cs => StreamingExecutionHandle.cs} (94%) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs new file mode 100644 index 0000000000..9b484610b0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// This class represents the result of a call to a +/// or . +/// +public sealed class CallResult +{ + /// + /// Indicates whether the call was void (i.e., no result expected). This only applies to + /// calls to handlers. + /// + public bool IsVoid { get; init; } + + /// + /// If the call was successful, this property contains the result of the call. For calls to + /// void handlers, this will be null. + /// + public object? Result { get; init; } = null; + + /// + /// If the call failed, this property contains the exception that was raised during the call. + /// + public Exception? Exception { get; init; } = null; + + /// + /// Indicates whether the call was successful. A call is considered successful if it returned + /// without throwing an exception. + /// + public bool IsSuccess => this.Exception == null; + + private CallResult(bool isVoid = false) + { + // Private constructor to enforce use of static methods. + this.IsVoid = isVoid; + } + + /// + /// Create a indicating a successful that returned a result (non-void). + /// + /// The result to return. + /// A indicating the result of the call. + public static CallResult ReturnResult(object? result = null) + { + return new() { Result = result }; + } + + /// + /// Create a indicating a successful call that returned no result (void). + /// + /// A indicating the result of the call. + public static CallResult ReturnVoid() + { + return new(isVoid: true); + } + + /// + /// Create a indicating that an exception was raised during the call. + /// + /// A boolean specifying whether the call was void (was not expected to return + /// a value). + /// The exception that was raised during the call. + /// A indicating the result of the call. + /// Thrown when is null. + public static CallResult RaisedException(bool wasVoid, Exception exception) + { + if (exception == null) + { + throw new ArgumentNullException(nameof(exception), "Exception cannot be null."); + } + + return new(wasVoid) { Exception = exception }; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 451be5135b..1f82cb0b5d 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -10,80 +10,6 @@ namespace Microsoft.Agents.Workflows.Core; -/// -/// A tag interface for objects that have a unique identifier within an appropriate namespace. -/// -public interface IIdentified -{ - /// - /// The unique identifier. - /// - string Id { get; } -} - -/// -/// . -/// -public record ExecutorCapabilities -{ - /// - /// . - /// - public string Id { get; init; } - /// - /// . - /// - public string Name { get; init; } - /// - /// . - /// - public Type ExecutorType { get; init; } - /// - /// . - /// - public ISet HandledMessageTypes { get; init; } - /// - /// . - /// - public bool IsInitialized { get; init; } - /// - /// . - /// - public ISet StateKeys { get; init; } - - /// - /// . - /// - public ExecutorCapabilities() - { - this.Id = string.Empty; - this.Name = string.Empty; - this.ExecutorType = typeof(Executor); - this.HandledMessageTypes = new HashSet(); - this.IsInitialized = false; - this.StateKeys = new HashSet(); - } - - /// - /// . - /// - /// - /// - /// - /// - /// - /// - public ExecutorCapabilities(string id, string name, Type executorType, ISet handledMessageTypes, bool isInitialized, ISet stateKeys) - { - this.Id = id; - this.Name = name; - this.ExecutorType = executorType; - this.HandledMessageTypes = handledMessageTypes; - this.IsInitialized = isInitialized; - this.StateKeys = stateKeys; - } -} - /// /// . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs new file mode 100644 index 0000000000..7f6ab5aebd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +public record ExecutorCapabilities +{ + /// + /// . + /// + public string Id { get; init; } + /// + /// . + /// + public string Name { get; init; } + /// + /// . + /// + public Type ExecutorType { get; init; } + /// + /// . + /// + public ISet HandledMessageTypes { get; init; } + /// + /// . + /// + public bool IsInitialized { get; init; } + /// + /// . + /// + public ISet StateKeys { get; init; } + + /// + /// . + /// + public ExecutorCapabilities() + { + this.Id = string.Empty; + this.Name = string.Empty; + this.ExecutorType = typeof(Executor); + this.HandledMessageTypes = new HashSet(); + this.IsInitialized = false; + this.StateKeys = new HashSet(); + } + + /// + /// . + /// + /// + /// + /// + /// + /// + /// + public ExecutorCapabilities(string id, string name, Type executorType, ISet handledMessageTypes, bool isInitialized, ISet stateKeys) + { + this.Id = id; + this.Name = name; + this.ExecutorType = executorType; + this.HandledMessageTypes = handledMessageTypes; + this.IsInitialized = isInitialized; + this.StateKeys = stateKeys; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs new file mode 100644 index 0000000000..bd8de4e48b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// A default message handler interface for handling messages that do not have a specific handler registered. +/// +public interface IDefaultMessageHandler +{ + /// + /// Handles the incoming message asynchronously. + /// + /// + /// This is used as a fallback handler for messages that do not have a specific handler registered. + /// + /// The message to handle. + /// The execution context. + /// A task that represents the asynchronous operation. + ValueTask HandleAsync(object message, IWorkflowContext context); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IIdentified.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IIdentified.cs new file mode 100644 index 0000000000..3b58e89665 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/IIdentified.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// A tag interface for objects that have a unique identifier within an appropriate namespace. +/// +public interface IIdentified +{ + /// + /// The unique identifier. + /// + string Id { get; } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageHandler.cs similarity index 68% rename from dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/IMessageHandler.cs index a607736f40..1da548d9ba 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageHandler.cs @@ -4,23 +4,6 @@ namespace Microsoft.Agents.Workflows.Core; -/// -/// A default message handler interface for handling messages that do not have a specific handler registered. -/// -public interface IDefaultMessageHandler -{ - /// - /// Handles the incoming message asynchronously. - /// - /// - /// This is used as a fallback handler for messages that do not have a specific handler registered. - /// - /// The message to handle. - /// The execution context. - /// A task that represents the asynchronous operation. - ValueTask HandleAsync(object message, IWorkflowContext context); -} - /// /// A message handler interface for handling messages of type . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandlerInfo.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandlerInfo.cs new file mode 100644 index 0000000000..da55f649c2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandlerInfo.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows.Core; + +internal struct MessageHandlerInfo +{ + public Type InType { get; init; } + public Type? OutType { get; init; } = null; + + public MethodInfo HandlerInfo { get; init; } + public Func>? Unwrapper { get; init; } = null; + + [SuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality " + + "when AOT compiling.", Justification = "")] + public MessageHandlerInfo(MethodInfo handlerInfo) + { + // The method is one of the following: + // - ValueTask HandleAsync(TMessage message, IExecutionContext context) + // - ValueTask HandleAsync(TMessage message, IExecutionContext context) + this.HandlerInfo = handlerInfo; + + ParameterInfo[] parameters = handlerInfo.GetParameters(); + if (parameters.Length != 2) + { + throw new ArgumentException("Handler method must have exactly two parameters: TMessage and IExecutionContext.", nameof(handlerInfo)); + } + + if (parameters[1].ParameterType != typeof(IWorkflowContext)) + { + throw new ArgumentException("Handler method's second parameter must be of type IExecutionContext.", nameof(handlerInfo)); + } + + this.InType = parameters[0].ParameterType; + + Type decoratedReturnType = handlerInfo.ReturnType; + if (decoratedReturnType.IsGenericType && decoratedReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) + { + // If the return type is ValueTask, extract TResult. + Type[] returnRawTypes = decoratedReturnType.GetGenericArguments(); + Debug.Assert( + returnRawTypes.Length == 1, + "ValueTask should have exactly one generic argument."); + + this.OutType = returnRawTypes.Single(); + this.Unwrapper = ValueTaskTypeErasure.UnwrapperFor(this.OutType); + } + else if (decoratedReturnType == typeof(ValueTask)) + { + // If the return type is ValueTask, there is no output type. + this.OutType = null; + } + else + { + throw new ArgumentException("Handler method must return ValueTask or ValueTask.", nameof(handlerInfo)); + } + } + + public static Func> Bind(Func handlerAsync, bool checkType, Type? resultType = null, Func>? unwrapper = null) + { + return InvokeHandlerAsync; + + async ValueTask InvokeHandlerAsync(object message, IWorkflowContext workflowContext) + { + bool expectingVoid = resultType == null || resultType == typeof(void); + + try + { + object? maybeValueTask = handlerAsync(message, workflowContext); + + if (expectingVoid) + { + if (maybeValueTask is ValueTask vt) + { + await vt.ConfigureAwait(false); + return CallResult.ReturnVoid(); + } + + throw new InvalidOperationException( + "Handler method is expected to return ValueTask or ValueTask, but returned " + + $"{maybeValueTask?.GetType().Name ?? "null"}."); + } + + Debug.Assert(resultType != null, "Expected resultType to be non-null when not expecting void."); + if (unwrapper == null) + { + throw new InvalidOperationException( + $"Handler method is expected to return ValueTask<{resultType!.Name}>, but no unwrapper is available."); + } + + if (maybeValueTask == null) + { + throw new InvalidOperationException( + $"Handler method returned null, but a ValueTask<{resultType!.Name}> was expected."); + } + + object? result = await unwrapper(maybeValueTask).ConfigureAwait(false); + + if (checkType && result != null && !resultType.IsInstanceOfType(result)) + { + throw new InvalidOperationException( + $"Handler method returned an incompatible type: expected {resultType.Name}, got {result.GetType().Name}."); + } + + return CallResult.ReturnResult(result); + } + catch (Exception ex) + { + // If the handler throws an exception, return it in the CallResult. + return CallResult.RaisedException(wasVoid: expectingVoid, exception: ex); + } + } + } + + public Func> Bind(Executor executor, bool checkType = false) + { + MethodInfo handlerMethod = this.HandlerInfo; + return MessageHandlerInfo.Bind(InvokeHandler, checkType, this.OutType, this.Unwrapper); + + object? InvokeHandler(object message, IWorkflowContext workflowContext) + { + return handlerMethod.Invoke(executor, new object[] { message, workflowContext }); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs new file mode 100644 index 0000000000..43e4ef4d74 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +using HandlerInfosT = + System.Collections.Generic.Dictionary< + System.Type, + Microsoft.Agents.Workflows.Core.MessageHandlerInfo + >; + +namespace Microsoft.Agents.Workflows.Core; + +internal class MessageRouter +{ + // TODO: The goal of the cache is to allow SourceGenerators to do the reflection to bind the handlers in the router. + internal static readonly Dictionary> s_routerFactoryCache = new(); + + private Dictionary>> BoundHandlers { get; init; } = new(); + private IDefaultMessageHandler? DefaultHandler { get; init; } = null; + + [SuppressMessage("Trimming", + "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + + "of the source method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + [SuppressMessage("Trimming", + "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of " + + "method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + private static HandlerInfosT ReflectHandlers(Type executorType) + { + // This method reflects over the methods of the executor type to find message handlers. + HandlerInfosT handlers = new(); + + // Get all implementations of IMessageHandler or IMessageHandler + // and create a MessageHandlerInfo for each. + if (!typeof(Executor).IsAssignableFrom(executorType)) + { + throw new ArgumentException($"Type {executorType.FullName} is not a valid Executor type.", nameof(executorType)); + } + + if (executorType.IsAbstract || executorType.IsInterface) + { + throw new ArgumentException($"Type {executorType.FullName} cannot be abstract or an interface.", nameof(executorType)); + } + + // Iterate all interfaces implemented by the executor type. + foreach (Type interfaceType in executorType.GetInterfaces()) + { + // Check if the interface is a message handler. + if (!interfaceType.IsGenericType) + { + continue; + } + + if (interfaceType.IsGenericType && (interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || + interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>))) + { + // Get the generic arguments of the interface. + Type[] genericArguments = interfaceType.GetGenericArguments(); + if (genericArguments.Length < 1 || genericArguments.Length > 2) + { + continue; // Invalid handler signature. + } + Type inType = genericArguments[0]; + Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; + MethodInfo? method = interfaceType.GetMethod("HandleAsync"); + if (method != null) + { + MessageHandlerInfo info = new(method) { InType = inType, OutType = outType }; + handlers[inType] = info; + } + } + } + + return handlers; + } + + [SuppressMessage("Trimming", + "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + + "of the source method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + internal static HandlerInfosT ReflectHandlers() where TExecutor : Executor + => ReflectHandlers(typeof(TExecutor)); + + internal static MessageRouter BindMessageHandlers(Executor executor, bool checkType) + { + if (s_routerFactoryCache.TryGetValue(executor.GetType(), out var factory)) + { + return factory(); + } + + // If no factory is found, reflect over the handlers + HandlerInfosT handlers = ReflectHandlers(executor.GetType()); + + Dictionary>> boundHandlers = new(); + foreach (Type inType in handlers.Keys) + { + MessageHandlerInfo handlerInfo = handlers[inType]; + Func> boundHandler = handlerInfo.Bind(executor, checkType); + boundHandlers.Add(inType, boundHandler); // TODO: Turn the error here into something more actionable. + } + + return new MessageRouter(boundHandlers, executor as IDefaultMessageHandler); + } + + internal MessageRouter(Dictionary>> handlers, IDefaultMessageHandler? defaultHandler = null) + { + this.BoundHandlers = handlers; + this.DefaultHandler = defaultHandler; + } + + /// + /// . + /// + /// + /// + /// + /// + /// + /// + public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message), "Message cannot be null."); + } + + // TODO: Implement base type delegation? + CallResult? result = null; + if (this.BoundHandlers.TryGetValue(message.GetType(), out Func>? handler)) + { + result = await handler(message, context).ConfigureAwait(false); + } + else if (this.DefaultHandler != null) + { + try + { + await this.DefaultHandler.HandleAsync(message, context).ConfigureAwait(false); + result = CallResult.ReturnVoid(); + } + catch (Exception e) + { + result = CallResult.RaisedException(wasVoid: true, e); + } + } + + return result; + } + + public bool CanHandle(object message) => this.CanHandle(Throw.IfNull(message).GetType()); + + public bool CanHandle(Type candidateType) + { + Throw.IfNull(candidateType); + + // Check if the router can handle the candidate type. + return this.DefaultHandler != null || this.BoundHandlers.ContainsKey(candidateType); + } + + public HashSet IncomingTypes + => this.DefaultHandler != null + ? [.. this.BoundHandlers.Keys, typeof(object)] + : [.. this.BoundHandlers.Keys]; +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs deleted file mode 100644 index 8d899bff94..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs +++ /dev/null @@ -1,386 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Reflection; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -using HandlerInfosT = - System.Collections.Generic.Dictionary< - System.Type, - Microsoft.Agents.Workflows.Core.MessageHandlerInfo - >; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// This attribute indicates that a message handler streams messages during its execution. -/// -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] -public sealed class StreamsMessageAttribute : Attribute -{ - /// - /// The type of the message that the handler yields. - /// - public Type Type { get; } - - /// - /// Indicates that the message handler yields streaming messages during the course of execution. - /// - public StreamsMessageAttribute(Type type) - { - // This attribute is used to mark executors that yield messages. - this.Type = type ?? throw new ArgumentNullException(nameof(type), "Type cannot be null."); - } -} - -/// -/// This class represents the result of a call to a -/// or . -/// -public sealed class CallResult -{ - /// - /// Indicates whether the call was void (i.e., no result expected). This only applies to - /// calls to handlers. - /// - public bool IsVoid { get; init; } - - /// - /// If the call was successful, this property contains the result of the call. For calls to - /// void handlers, this will be null. - /// - public object? Result { get; init; } = null; - - /// - /// If the call failed, this property contains the exception that was raised during the call. - /// - public Exception? Exception { get; init; } = null; - - /// - /// Indicates whether the call was successful. A call is considered successful if it returned - /// without throwing an exception. - /// - public bool IsSuccess => this.Exception == null; - - private CallResult(bool isVoid = false) - { - // Private constructor to enforce use of static methods. - this.IsVoid = isVoid; - } - - /// - /// Create a indicating a successful that returned a result (non-void). - /// - /// The result to return. - /// A indicating the result of the call. - public static CallResult ReturnResult(object? result = null) - { - return new() { Result = result }; - } - - /// - /// Create a indicating a successful call that returned no result (void). - /// - /// A indicating the result of the call. - public static CallResult ReturnVoid() - { - return new(isVoid: true); - } - - /// - /// Create a indicating that an exception was raised during the call. - /// - /// A boolean specifying whether the call was void (was not expected to return - /// a value). - /// The exception that was raised during the call. - /// A indicating the result of the call. - /// Thrown when is null. - public static CallResult RaisedException(bool wasVoid, Exception exception) - { - if (exception == null) - { - throw new ArgumentNullException(nameof(exception), "Exception cannot be null."); - } - - return new(wasVoid) { Exception = exception }; - } -} - -internal struct MessageHandlerInfo -{ - public Type InType { get; init; } - public Type? OutType { get; init; } = null; - - public MethodInfo HandlerInfo { get; init; } - public Func>? Unwrapper { get; init; } = null; - - [SuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality " + - "when AOT compiling.", Justification = "")] - public MessageHandlerInfo(MethodInfo handlerInfo) - { - // The method is one of the following: - // - ValueTask HandleAsync(TMessage message, IExecutionContext context) - // - ValueTask HandleAsync(TMessage message, IExecutionContext context) - this.HandlerInfo = handlerInfo; - - ParameterInfo[] parameters = handlerInfo.GetParameters(); - if (parameters.Length != 2) - { - throw new ArgumentException("Handler method must have exactly two parameters: TMessage and IExecutionContext.", nameof(handlerInfo)); - } - - if (parameters[1].ParameterType != typeof(IWorkflowContext)) - { - throw new ArgumentException("Handler method's second parameter must be of type IExecutionContext.", nameof(handlerInfo)); - } - - this.InType = parameters[0].ParameterType; - - Type decoratedReturnType = handlerInfo.ReturnType; - if (decoratedReturnType.IsGenericType && decoratedReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) - { - // If the return type is ValueTask, extract TResult. - Type[] returnRawTypes = decoratedReturnType.GetGenericArguments(); - Debug.Assert( - returnRawTypes.Length == 1, - "ValueTask should have exactly one generic argument."); - - this.OutType = returnRawTypes.Single(); - this.Unwrapper = ValueTaskTypeErasure.UnwrapperFor(this.OutType); - } - else if (decoratedReturnType == typeof(ValueTask)) - { - // If the return type is ValueTask, there is no output type. - this.OutType = null; - } - else - { - throw new ArgumentException("Handler method must return ValueTask or ValueTask.", nameof(handlerInfo)); - } - } - - public static Func> Bind(Func handlerAsync, bool checkType, Type? resultType = null, Func>? unwrapper = null) - { - return InvokeHandlerAsync; - - async ValueTask InvokeHandlerAsync(object message, IWorkflowContext workflowContext) - { - bool expectingVoid = resultType == null || resultType == typeof(void); - - try - { - object? maybeValueTask = handlerAsync(message, workflowContext); - - if (expectingVoid) - { - if (maybeValueTask is ValueTask vt) - { - await vt.ConfigureAwait(false); - return CallResult.ReturnVoid(); - } - - throw new InvalidOperationException( - "Handler method is expected to return ValueTask or ValueTask, but returned " + - $"{maybeValueTask?.GetType().Name ?? "null"}."); - } - - Debug.Assert(resultType != null, "Expected resultType to be non-null when not expecting void."); - if (unwrapper == null) - { - throw new InvalidOperationException( - $"Handler method is expected to return ValueTask<{resultType!.Name}>, but no unwrapper is available."); - } - - if (maybeValueTask == null) - { - throw new InvalidOperationException( - $"Handler method returned null, but a ValueTask<{resultType!.Name}> was expected."); - } - - object? result = await unwrapper(maybeValueTask).ConfigureAwait(false); - - if (checkType && result != null && !resultType.IsInstanceOfType(result)) - { - throw new InvalidOperationException( - $"Handler method returned an incompatible type: expected {resultType.Name}, got {result.GetType().Name}."); - } - - return CallResult.ReturnResult(result); - } - catch (Exception ex) - { - // If the handler throws an exception, return it in the CallResult. - return CallResult.RaisedException(wasVoid: expectingVoid, exception: ex); - } - } - } - - public Func> Bind(Executor executor, bool checkType = false) - { - MethodInfo handlerMethod = this.HandlerInfo; - return MessageHandlerInfo.Bind(InvokeHandler, checkType, this.OutType, this.Unwrapper); - - object? InvokeHandler(object message, IWorkflowContext workflowContext) - { - return handlerMethod.Invoke(executor, new object[] { message, workflowContext }); - } - } -} - -internal class MessageRouter -{ - // TODO: The goal of the cache is to allow SourceGenerators to do the reflection to bind the handlers in the router. - internal static readonly Dictionary> s_routerFactoryCache = new(); - - private Dictionary>> BoundHandlers { get; init; } = new(); - private IDefaultMessageHandler? DefaultHandler { get; init; } = null; - - [SuppressMessage("Trimming", - "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + - "of the source method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - [SuppressMessage("Trimming", - "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of " + - "method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - private static HandlerInfosT ReflectHandlers(Type executorType) - { - // This method reflects over the methods of the executor type to find message handlers. - HandlerInfosT handlers = new(); - - // Get all implementations of IMessageHandler or IMessageHandler - // and create a MessageHandlerInfo for each. - if (!typeof(Executor).IsAssignableFrom(executorType)) - { - throw new ArgumentException($"Type {executorType.FullName} is not a valid Executor type.", nameof(executorType)); - } - - if (executorType.IsAbstract || executorType.IsInterface) - { - throw new ArgumentException($"Type {executorType.FullName} cannot be abstract or an interface.", nameof(executorType)); - } - - // Iterate all interfaces implemented by the executor type. - foreach (Type interfaceType in executorType.GetInterfaces()) - { - // Check if the interface is a message handler. - if (!interfaceType.IsGenericType) - { - continue; - } - - if (interfaceType.IsGenericType && (interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || - interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>))) - { - // Get the generic arguments of the interface. - Type[] genericArguments = interfaceType.GetGenericArguments(); - if (genericArguments.Length < 1 || genericArguments.Length > 2) - { - continue; // Invalid handler signature. - } - Type inType = genericArguments[0]; - Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; - MethodInfo? method = interfaceType.GetMethod("HandleAsync"); - if (method != null) - { - MessageHandlerInfo info = new(method) { InType = inType, OutType = outType }; - handlers[inType] = info; - } - } - } - - return handlers; - } - - [SuppressMessage("Trimming", - "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + - "of the source method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - internal static HandlerInfosT ReflectHandlers() where TExecutor : Executor - => ReflectHandlers(typeof(TExecutor)); - - internal static MessageRouter BindMessageHandlers(Executor executor, bool checkType) - { - if (s_routerFactoryCache.TryGetValue(executor.GetType(), out var factory)) - { - return factory(); - } - - // If no factory is found, reflect over the handlers - HandlerInfosT handlers = ReflectHandlers(executor.GetType()); - - Dictionary>> boundHandlers = new(); - foreach (Type inType in handlers.Keys) - { - MessageHandlerInfo handlerInfo = handlers[inType]; - Func> boundHandler = handlerInfo.Bind(executor, checkType); - boundHandlers.Add(inType, boundHandler); // TODO: Turn the error here into something more actionable. - } - - return new MessageRouter(boundHandlers, executor as IDefaultMessageHandler); - } - - internal MessageRouter(Dictionary>> handlers, IDefaultMessageHandler? defaultHandler = null) - { - this.BoundHandlers = handlers; - this.DefaultHandler = defaultHandler; - } - - /// - /// . - /// - /// - /// - /// - /// - /// - /// - public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) - { - if (message == null) - { - throw new ArgumentNullException(nameof(message), "Message cannot be null."); - } - - // TODO: Implement base type delegation? - CallResult? result = null; - if (this.BoundHandlers.TryGetValue(message.GetType(), out Func>? handler)) - { - result = await handler(message, context).ConfigureAwait(false); - } - else if (this.DefaultHandler != null) - { - try - { - await this.DefaultHandler.HandleAsync(message, context).ConfigureAwait(false); - result = CallResult.ReturnVoid(); - } - catch (Exception e) - { - result = CallResult.RaisedException(wasVoid: true, e); - } - } - - return result; - } - - public bool CanHandle(object message) => this.CanHandle(Throw.IfNull(message).GetType()); - - public bool CanHandle(Type candidateType) - { - Throw.IfNull(candidateType); - - // Check if the router can handle the candidate type. - return this.DefaultHandler != null || this.BoundHandlers.ContainsKey(candidateType); - } - - public HashSet IncomingTypes - => this.DefaultHandler != null - ? [.. this.BoundHandlers.Keys, typeof(object)] - : [.. this.BoundHandlers.Keys]; -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs new file mode 100644 index 0000000000..52e1afb457 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// This attribute indicates that a message handler streams messages during its execution. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public sealed class StreamsMessageAttribute : Attribute +{ + /// + /// The type of the message that the handler yields. + /// + public Type Type { get; } + + /// + /// Indicates that the message handler yields streaming messages during the course of execution. + /// + public StreamsMessageAttribute(Type type) + { + // This attribute is used to mark executors that yield messages. + this.Type = type ?? throw new ArgumentNullException(nameof(type), "Type cannot be null."); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/TypeErasure.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ValueTaskTypeErasure.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/TypeErasure.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/ValueTaskTypeErasure.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs new file mode 100644 index 0000000000..0b84cba03a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class EdgeMap +{ + private readonly Dictionary _edgeRunners = new(); + private readonly Dictionary _fanInState = new(); + private readonly InputEdgeRuner _inputRunner; + + public EdgeMap(IRunnerContext runContext, Dictionary> workflowEdges, string startExecutorId) + { + foreach (FlowEdge edge in workflowEdges.Values.SelectMany(e => e)) + { + object edgeRunner = edge.EdgeType switch + { + FlowEdge.Type.Direct => new DirectEdgeRunner(runContext, edge.DirectEdgeData!), + FlowEdge.Type.FanOut => new FanOutEdgeRunner(runContext, edge.FanOutEdgeData!), + FlowEdge.Type.FanIn => new FanInEdgeRunner(runContext, edge.FanInEdgeData!), + _ => throw new NotSupportedException($"Unsupported edge type: {edge.EdgeType}") + }; + + this._edgeRunners[edge] = edgeRunner; + } + + this._inputRunner = new InputEdgeRuner(runContext, startExecutorId); + } + + public async ValueTask> InvokeEdgeAsync(FlowEdge edge, string sourceId, object message) + { + if (!this._edgeRunners.TryGetValue(edge, out object? edgeRunner)) + { + throw new InvalidOperationException($"Edge {edge} not found in the edge map."); + } + + IEnumerable edgeResults; + switch (edge.EdgeType) + { + // We know the corresponding EdgeRunner type given the FlowEdge EdgeType, as + // established in the EdgeMap() ctor; this avoid doing an as-cast inside of + // the depths of the message delivery loop for every edges (multiplicity N, + // in FanIn/Out cases) + // TODO: Once we have a fixed interface, if it is reasonably generalizable + // between the Runners, we can normalize it behind an IFace. + case FlowEdge.Type.Direct: + { + DirectEdgeRunner runner = (DirectEdgeRunner)this._edgeRunners[edge]; + edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); + break; + } + + case FlowEdge.Type.FanOut: + { + FanOutEdgeRunner runner = (FanOutEdgeRunner)this._edgeRunners[edge]; + edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); + break; + } + + case FlowEdge.Type.FanIn: + { + FanInEdgeState state = this._fanInState[edge]; + FanInEdgeRunner runner = (FanInEdgeRunner)this._edgeRunners[edge]; + edgeResults = [await runner.ChaseAsync(sourceId, message, state).ConfigureAwait(false)]; + break; + } + + default: + throw new InvalidOperationException("Unknown edge type"); + + } + + return edgeResults; + } + + // TODO: Should we promote Input to a true "FlowEdge" type? + public async ValueTask> InvokeInputAsync(object inputMessage) + { + return [await this._inputRunner.ChaseAsync(inputMessage).ConfigureAwait(false)]; + } + + public ValueTask> InvokeResponseAsync(object externalResponse) + { + throw new NotImplementedException(); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutorIdentity.cs similarity index 70% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs rename to dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutorIdentity.cs index 4c99a29cea..b612a735bb 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutorIdentity.cs @@ -5,13 +5,13 @@ namespace Microsoft.Agents.Workflows.Execution; -internal readonly struct Identity : IEquatable +internal readonly struct ExecutorIdentity : IEquatable { - public static Identity None { get; } = new Identity(); + public static ExecutorIdentity None { get; } = new ExecutorIdentity(); public string? Id { get; init; } - public bool Equals(Identity other) + public bool Equals(ExecutorIdentity other) { return this.Id == null ? other.Id == null @@ -30,7 +30,7 @@ public override bool Equals([NotNullWhen(true)] object? obj) return false; } - if (obj is Identity id) + if (obj is ExecutorIdentity id) { return id.Equals(this); } @@ -48,12 +48,12 @@ public override int GetHashCode() return this.Id == null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(this.Id); } - public static implicit operator Identity(string? id) + public static implicit operator ExecutorIdentity(string? id) { - return new Identity { Id = id }; + return new ExecutorIdentity { Id = id }; } - public static implicit operator string?(Identity identity) + public static implicit operator string?(ExecutorIdentity identity) { return identity.Id; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs new file mode 100644 index 0000000000..0d8a8ff422 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows.Execution; + +internal interface IRunnerWithResult +{ + ISuperStepRunner StepRunner { get; } + + ValueTask GetResultAsync(CancellationToken cancellation = default); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs new file mode 100644 index 0000000000..f2c6b5f929 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal interface ISuperStepRunner +{ + ValueTask EnqueueMessageAsync(object message); + + event EventHandler? WorkflowEvent; + + ValueTask RunSuperStepAsync(CancellationToken cancellation); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index 1605c77c5a..89e18037cf 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -10,88 +10,6 @@ namespace Microsoft.Agents.Workflows.Execution; -internal class EdgeMap -{ - private readonly Dictionary _edgeRunners = new(); - private readonly Dictionary _fanInState = new(); - private readonly InputEdgeRuner _inputRunner; - - public EdgeMap(IRunnerContext runContext, Dictionary> workflowEdges, string startExecutorId) - { - foreach (FlowEdge edge in workflowEdges.Values.SelectMany(e => e)) - { - object edgeRunner = edge.EdgeType switch - { - FlowEdge.Type.Direct => new DirectEdgeRunner(runContext, edge.DirectEdgeData!), - FlowEdge.Type.FanOut => new FanOutEdgeRunner(runContext, edge.FanOutEdgeData!), - FlowEdge.Type.FanIn => new FanInEdgeRunner(runContext, edge.FanInEdgeData!), - _ => throw new NotSupportedException($"Unsupported edge type: {edge.EdgeType}") - }; - - this._edgeRunners[edge] = edgeRunner; - } - - this._inputRunner = new InputEdgeRuner(runContext, startExecutorId); - } - - public async ValueTask> InvokeEdgeAsync(FlowEdge edge, string sourceId, object message) - { - if (!this._edgeRunners.TryGetValue(edge, out object? edgeRunner)) - { - throw new InvalidOperationException($"Edge {edge} not found in the edge map."); - } - - IEnumerable edgeResults; - switch (edge.EdgeType) - { - // We know the corresponding EdgeRunner type given the FlowEdge EdgeType, as - // established in the EdgeMap() ctor; this avoid doing an as-cast inside of - // the depths of the message delivery loop for every edges (multiplicity N, - // in FanIn/Out cases) - // TODO: Once we have a fixed interface, if it is reasonably generalizable - // between the Runners, we can normalize it behind an IFace. - case FlowEdge.Type.Direct: - { - DirectEdgeRunner runner = (DirectEdgeRunner)this._edgeRunners[edge]; - edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); - break; - } - - case FlowEdge.Type.FanOut: - { - FanOutEdgeRunner runner = (FanOutEdgeRunner)this._edgeRunners[edge]; - edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); - break; - } - - case FlowEdge.Type.FanIn: - { - FanInEdgeState state = this._fanInState[edge]; - FanInEdgeRunner runner = (FanInEdgeRunner)this._edgeRunners[edge]; - edgeResults = [await runner.ChaseAsync(sourceId, message, state).ConfigureAwait(false)]; - break; - } - - default: - throw new InvalidOperationException("Unknown edge type"); - - } - - return edgeResults; - } - - // TODO: Should we promote Input to a true "FlowEdge" type? - public async ValueTask> InvokeInputAsync(object inputMessage) - { - return [await this._inputRunner.ChaseAsync(inputMessage).ConfigureAwait(false)]; - } - - public ValueTask> InvokeResponseAsync(object externalResponse) - { - throw new NotImplementedException(); - } -} - internal class LocalRunner : ISuperStepRunner where TInput : notnull { public LocalRunner(Workflow workflow) @@ -172,7 +90,7 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) { // Deliver the messages and queue the next step List>> edgeTasks = new(); - foreach (Identity sender in currentStep.QueuedMessages.Keys) + foreach (ExecutorIdentity sender in currentStep.QueuedMessages.Keys) { IEnumerable senderMessages = currentStep.QueuedMessages[sender]; if (sender.Id is null) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index c58a891e7f..e915d16157 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -43,7 +43,7 @@ public ValueTask AddExternalMessageAsync([NotNull] object message) { Throw.IfNull(message); - this._nextStep.MessagesFor(Identity.None).Add(message); + this._nextStep.MessagesFor(ExecutorIdentity.None).Add(message); return CompletedValueTaskSource.Completed; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs index ba305a4b46..07d30267ed 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.Workflows.Execution; internal class StepContext { - public Dictionary> QueuedMessages { get; } = new(); + public Dictionary> QueuedMessages { get; } = new(); public bool HasMessages => this.QueuedMessages.Values.Any(messageList => messageList.Count > 0); diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs similarity index 94% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs rename to dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs index 6bab2c0aea..ca915e24e7 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs @@ -10,22 +10,6 @@ namespace Microsoft.Agents.Workflows.Execution; -internal interface ISuperStepRunner -{ - ValueTask EnqueueMessageAsync(object message); - - event EventHandler? WorkflowEvent; - - ValueTask RunSuperStepAsync(CancellationToken cancellation); -} - -internal interface IRunnerWithResult -{ - ISuperStepRunner StepRunner { get; } - - ValueTask GetResultAsync(CancellationToken cancellation = default); -} - /// /// . /// From 347b7081405641ede677ee889a04e30a603e82c7 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 13:59:16 -0400 Subject: [PATCH 013/232] refactor: Simplify Disposal pattern in Executor --- .../Core/DisposableObject.cs | 59 ------------------- .../Core/Executor.cs | 13 ++-- 2 files changed, 9 insertions(+), 63 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs deleted file mode 100644 index a754870660..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Diagnostics; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// Provides a base class implementing the interface using -/// the virtual Dispose pattern. -/// -public class DisposableObject : IAsyncDisposable -{ - /// - /// Implements invocation of the DisposeAsync method when the object is finalized to - /// dispose unmanaged resources properly. - /// - ~DisposableObject() - { - // Finalizer calls DisposeAsync to ensure resources are released. - // This is a safety net in case DisposeAsync was not called. -#pragma warning disable CA2012 // Use ValueTasks correctly: Uses OnCompleted to properly handle the ValueTask return. - ValueTask disposeTask = this.DisposeAsync(false); -#pragma warning restore CA2012 // Use ValueTasks correctly - - if (!disposeTask.IsCompleted) - { - using (ManualResetEvent barrier = new(false)) - { - disposeTask.GetAwaiter().OnCompleted(() => barrier.Set()); - - // Wait for the DisposeAsync to complete. - barrier.WaitOne(); // TODO: Timeout? - } - } - - Debug.Assert( - disposeTask.IsCompleted, - "DisposeAsync should have completed in order to pass to this line."); -#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits - disposeTask.GetAwaiter().GetResult(); -#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits - } - - /// - protected virtual ValueTask DisposeAsync(bool disposing) - { - return CompletedValueTaskSource.Completed; - } - - /// - public async ValueTask DisposeAsync() - { - await this.DisposeAsync(true).ConfigureAwait(false); - GC.SuppressFinalize(this); - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 1f82cb0b5d..1a95663f57 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -14,7 +14,7 @@ namespace Microsoft.Agents.Workflows.Core; /// . /// [DebuggerDisplay("{GetType().Name}{Id}({Name})")] -public abstract class Executor : DisposableObject, IIdentified +public abstract class Executor : IIdentified, IAsyncDisposable { /// /// . @@ -192,14 +192,19 @@ private async ValueTask FlushReduceRemainingAsync() /// /// . /// - /// /// - protected override async ValueTask DisposeAsync(bool disposing = false) + protected virtual async ValueTask DisposeAsync() { this._initialized = false; await this.FlushReduceRemainingAsync().ConfigureAwait(false); + } + + ValueTask IAsyncDisposable.DisposeAsync() + { + GC.SuppressFinalize(this); // Should we be suppressing the finalizer here? CodeAnalysis seems to want it (CA1816) - await base.DisposeAsync(disposing).ConfigureAwait(false); + // Chain to the virtual call to DisposeAsync. + return this.DisposeAsync(); } } From 999727334002efa144ae0ed806f4086a1f9032b3 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 14:01:06 -0400 Subject: [PATCH 014/232] refactor: Break EdgeRunner file into per-type files --- .../Execution/DirectEdgeRunner.cs | 38 +++++ .../Execution/EdgeRunner.cs | 159 ------------------ .../Execution/FanInEdgeRunner.cs | 36 ++++ .../Execution/FanInEdgeState.cs | 38 +++++ .../Execution/FanOutEdgeRunner.cs | 43 +++++ .../Execution/InputEdgeRuner.cs | 34 ++++ 6 files changed, 189 insertions(+), 159 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeState.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs new file mode 100644 index 0000000000..29b1b2dc83 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData edgeData) : + EdgeRunner(runContext, edgeData) +{ + public IWorkflowContext WorkflowContext { get; } = runContext.Bind(edgeData.SinkId); + + private async ValueTask FindRouterAsync() + { + Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); + + return sink.MessageRouter; + } + + public async ValueTask> ChaseAsync(object message) + { + if (this.EdgeData.Condition != null && !this.EdgeData.Condition(message)) + { + return []; + } + + MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); + if (router.CanHandle(message)) + { + return [await router.RouteMessageAsync(message, this.WorkflowContext) + .ConfigureAwait(false)]; + } + + return []; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs index 05f6f6f887..8871f9d8bb 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs @@ -1,9 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Execution; @@ -14,158 +10,3 @@ internal abstract class EdgeRunner( protected IRunnerContext RunContext { get; } = Throw.IfNull(runContext); protected TEdgeData EdgeData { get; } = Throw.IfNull(edgeData); } - -internal class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData edgeData) : - EdgeRunner(runContext, edgeData) -{ - public IWorkflowContext WorkflowContext { get; } = runContext.Bind(edgeData.SinkId); - - private async ValueTask FindRouterAsync() - { - Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) - .ConfigureAwait(false); - - return sink.MessageRouter; - } - - public async ValueTask> ChaseAsync(object message) - { - if (this.EdgeData.Condition != null && !this.EdgeData.Condition(message)) - { - return []; - } - - MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); - if (router.CanHandle(message)) - { - return [await router.RouteMessageAsync(message, this.WorkflowContext) - .ConfigureAwait(false)]; - } - - return []; - } -} - -internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeData) : - EdgeRunner(runContext, edgeData) -{ - private Dictionary BoundContexts { get; } - = edgeData.SinkIds.ToDictionary( - sinkId => sinkId, - sinkId => runContext.Bind(sinkId)); - - public async ValueTask> ChaseAsync(object message) - { - List targets = - this.EdgeData.Partitioner == null - ? this.EdgeData.SinkIds - : this.EdgeData.Partitioner(message, this.BoundContexts.Count).Select(i => this.EdgeData.SinkIds[i]).ToList(); - - CallResult?[] result = await Task.WhenAll(targets.Select(ProcessTargetAsync)).ConfigureAwait(false); - return result.Where(r => r is not null); - - async Task ProcessTargetAsync(string targetId) - { - Executor executor = await this.RunContext.EnsureExecutorAsync(targetId) - .ConfigureAwait(false); - - MessageRouter router = executor.MessageRouter; - if (router.CanHandle(message)) - { - return await router.RouteMessageAsync(message, this.BoundContexts[targetId]) - .ConfigureAwait(false); - } - - return null; - } - } -} - -internal record FanInEdgeState(FanInEdgeData EdgeData) -{ - private List? _pendingMessages - = EdgeData.Trigger == FanInTrigger.WhenAll ? [] : null; - - private HashSet? _unseen - = EdgeData.Trigger == FanInTrigger.WhenAll ? new(EdgeData.SourceIds) : null; - - public IEnumerable? ProcessMessage(string sourceId, object message) - { - if (this.EdgeData.Trigger == FanInTrigger.WhenAll) - { - this._pendingMessages!.Add(message); - this._unseen!.Remove(sourceId); - - if (this._unseen.Count == 0) - { - List result = this._pendingMessages; - - this._pendingMessages = []; - this._unseen = new(this.EdgeData.SourceIds); - - return result; - } - - return null; - } - - return [message]; - } -} - -internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData) : - EdgeRunner(runContext, edgeData) -{ - private IWorkflowContext BoundContext { get; } = runContext.Bind(edgeData.SinkId); - - public FanInEdgeState CreateState() => new(this.EdgeData); - - public async ValueTask ChaseAsync(string sourceId, object message, FanInEdgeState state) - { - IEnumerable? releasedMessages = state.ProcessMessage(sourceId, message); - if (releasedMessages is null) - { - // Not ready to process yet. - return null; - } - - Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) - .ConfigureAwait(false); - - MessageRouter router = sink.MessageRouter; - if (router.CanHandle(message)) - { - return await router.RouteMessageAsync(message, this.BoundContext) - .ConfigureAwait(false); - } - return null; - } -} - -internal class InputEdgeRuner(IRunnerContext runContext, string sinkId) - : EdgeRunner(runContext, sinkId) -{ - public IWorkflowContext WorkflowContext { get; } = runContext.Bind(sinkId); - - private async ValueTask FindRouterAsync() - { - Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData) - .ConfigureAwait(false); - - return sink.MessageRouter; - } - - public async ValueTask ChaseAsync(object message) - { - MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); - if (router.CanHandle(message)) - { - return await router.RouteMessageAsync(message, this.WorkflowContext) - .ConfigureAwait(false); - } - - // TODO: Throw instead? - - return null; - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs new file mode 100644 index 0000000000..585f7e1833 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData) : + EdgeRunner(runContext, edgeData) +{ + private IWorkflowContext BoundContext { get; } = runContext.Bind(edgeData.SinkId); + + public FanInEdgeState CreateState() => new(this.EdgeData); + + public async ValueTask ChaseAsync(string sourceId, object message, FanInEdgeState state) + { + IEnumerable? releasedMessages = state.ProcessMessage(sourceId, message); + if (releasedMessages is null) + { + // Not ready to process yet. + return null; + } + + Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); + + MessageRouter router = sink.MessageRouter; + if (router.CanHandle(message)) + { + return await router.RouteMessageAsync(message, this.BoundContext) + .ConfigureAwait(false); + } + return null; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeState.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeState.cs new file mode 100644 index 0000000000..2747969d91 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeState.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal record FanInEdgeState(FanInEdgeData EdgeData) +{ + private List? _pendingMessages + = EdgeData.Trigger == FanInTrigger.WhenAll ? [] : null; + + private HashSet? _unseen + = EdgeData.Trigger == FanInTrigger.WhenAll ? new(EdgeData.SourceIds) : null; + + public IEnumerable? ProcessMessage(string sourceId, object message) + { + if (this.EdgeData.Trigger == FanInTrigger.WhenAll) + { + this._pendingMessages!.Add(message); + this._unseen!.Remove(sourceId); + + if (this._unseen.Count == 0) + { + List result = this._pendingMessages; + + this._pendingMessages = []; + this._unseen = new(this.EdgeData.SourceIds); + + return result; + } + + return null; + } + + return [message]; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs new file mode 100644 index 0000000000..7ff3e9c171 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeData) : + EdgeRunner(runContext, edgeData) +{ + private Dictionary BoundContexts { get; } + = edgeData.SinkIds.ToDictionary( + sinkId => sinkId, + sinkId => runContext.Bind(sinkId)); + + public async ValueTask> ChaseAsync(object message) + { + List targets = + this.EdgeData.Partitioner == null + ? this.EdgeData.SinkIds + : this.EdgeData.Partitioner(message, this.BoundContexts.Count).Select(i => this.EdgeData.SinkIds[i]).ToList(); + + CallResult?[] result = await Task.WhenAll(targets.Select(ProcessTargetAsync)).ConfigureAwait(false); + return result.Where(r => r is not null); + + async Task ProcessTargetAsync(string targetId) + { + Executor executor = await this.RunContext.EnsureExecutorAsync(targetId) + .ConfigureAwait(false); + + MessageRouter router = executor.MessageRouter; + if (router.CanHandle(message)) + { + return await router.RouteMessageAsync(message, this.BoundContexts[targetId]) + .ConfigureAwait(false); + } + + return null; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs new file mode 100644 index 0000000000..2ba64f1ee8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class InputEdgeRuner(IRunnerContext runContext, string sinkId) + : EdgeRunner(runContext, sinkId) +{ + public IWorkflowContext WorkflowContext { get; } = runContext.Bind(sinkId); + + private async ValueTask FindRouterAsync() + { + Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData) + .ConfigureAwait(false); + + return sink.MessageRouter; + } + + public async ValueTask ChaseAsync(object message) + { + MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); + if (router.CanHandle(message)) + { + return await router.RouteMessageAsync(message, this.WorkflowContext) + .ConfigureAwait(false); + } + + // TODO: Throw instead? + + return null; + } +} From df40fdca0e3168a97bb1450571e14d0eca79d42a Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 14:06:47 -0400 Subject: [PATCH 015/232] refactor: Use Throw.IfNull() --- dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs | 6 ++---- dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs | 6 ++---- dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs | 6 +++--- dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs | 5 +---- .../Core/StreamsMessageAttribute.cs | 3 ++- dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs | 4 ++-- .../src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs | 2 +- dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs | 7 ++++--- .../Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs | 1 - 9 files changed, 17 insertions(+), 23 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs index 9b484610b0..934c03d43c 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; @@ -68,10 +69,7 @@ public static CallResult ReturnVoid() /// Thrown when is null. public static CallResult RaisedException(bool wasVoid, Exception exception) { - if (exception == null) - { - throw new ArgumentNullException(nameof(exception), "Exception cannot be null."); - } + Throw.IfNull(exception); return new(wasVoid) { Exception = exception }; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 1a95663f57..c617bb52e6 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -7,6 +7,7 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; @@ -142,10 +143,7 @@ public ExecutorCapabilities Capabilities /// public void RestoreState(IDictionary state) { - if (state == null) - { - throw new ArgumentNullException(nameof(state), "State cannot be null."); - } + Throw.IfNull(state); this.State.Clear(); diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs index c3c6bd2bbe..e94fad7848 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; - +using Microsoft.Shared.Diagnostics; using ExecutorId = string; // TODO: Unclear whether this should be forcibly a serializable type. using MetadataValueT = object; @@ -93,8 +93,8 @@ public record Message /// public Message(TContent content, MessageMetadata metadata) { - this.Content = content ?? throw new ArgumentNullException(nameof(content)); - this.Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); + this.Content = Throw.IfNull(content); + this.Metadata = Throw.IfNull(metadata); } /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs index 43e4ef4d74..0e7fce5082 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs @@ -125,10 +125,7 @@ internal MessageRouter(Dictionary public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) { - if (message == null) - { - throw new ArgumentNullException(nameof(message), "Message cannot be null."); - } + Throw.IfNull(message); // TODO: Implement base type delegation? CallResult? result = null; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs index 52e1afb457..c79d8fb8ab 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; @@ -21,6 +22,6 @@ public sealed class StreamsMessageAttribute : Attribute public StreamsMessageAttribute(Type type) { // This attribute is used to mark executors that yield messages. - this.Type = type ?? throw new ArgumentNullException(nameof(type), "Type cannot be null."); + this.Type = Throw.IfNull(type); } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs index bb92518d7b..e246635e89 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs @@ -25,8 +25,8 @@ public Type InputType public Workflow(string startExecutorId, Type type) { - this.StartExecutorId = startExecutorId ?? throw new ArgumentNullException(nameof(startExecutorId)); - this.InputType = type ?? throw new ArgumentNullException(nameof(type)); + this.StartExecutorId = Throw.IfNull(startExecutorId); + this.InputType = Throw.IfNull(type); // TODO: How do we (1) ensure the types are happy, and (2) work under AOT? } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index 89e18037cf..0646a25b49 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -14,7 +14,7 @@ internal class LocalRunner : ISuperStepRunner where TInput : notnull { public LocalRunner(Workflow workflow) { - this.Workflow = workflow ?? throw new ArgumentNullException(nameof(workflow)); + this.Workflow = Throw.IfNull(workflow); this.RunContext = new LocalRunnerContext(workflow); // Initialize the runners for each of the edges, along with the state for edges that diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs index f40fbf606f..a1fa16dd58 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -2,6 +2,7 @@ using System; using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; @@ -29,13 +30,13 @@ public enum Type public ExecutorIsh(Executor executor) { this.ExecutorType = Type.Executor; - this._executorValue = executor ?? throw new ArgumentNullException(nameof(executor)); + this._executorValue = Throw.IfNull(executor); } public ExecutorIsh(string id) { this.ExecutorType = Type.Unbound; - this._idValue = id ?? throw new ArgumentNullException(nameof(id)); + this._idValue = Throw.IfNull(id); } public bool IsUnbound => this.ExecutorType == Type.Unbound; @@ -63,7 +64,7 @@ public ExecutorIsh(string id) //public ExecutorIsh(Func function) //{ // this.ExecutorType = Type.Function; - // this._functionValue = function ?? throw new ArgumentNullException(nameof(function)); + // this._functionValue = Throw.IfNull(function); //} // Implicit conversions into ExecutorIsh diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index e3dae65cf3..3a0f3a2fbd 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.Linq; using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; From 0d2807183483acf9abe91346a944d0c75fc4f400 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 14:17:14 -0400 Subject: [PATCH 016/232] refactor: Remove AddLoop() Per https://github.com/microsoft/agent-framework/pull/272#discussion_r2241739079 we decided this was not very useful. --- .../WorkflowBuilderExtensions.cs | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index 3a0f3a2fbd..7b2836952b 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -9,18 +9,6 @@ namespace Microsoft.Agents.Workflows; internal static class WorkflowBuilderExtensions { - public static WorkflowBuilder AddLoop(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh loopBody, Func? condition = null) - { - Throw.IfNull(builder); - Throw.IfNull(source); - Throw.IfNull(loopBody); - - builder.AddEdge(source, loopBody, condition); - builder.AddEdge(loopBody, source); - - return builder; - } - public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors) { Throw.IfNull(builder); @@ -62,22 +50,4 @@ public static Workflow BuildWithOutput workflow = builder.Build(); return workflow.Promote(outputSink); } - - //public static WorkflowBuilder AddMapReduce } - -//class T -//{ -// async Task A() -// { -// WorkflowBuilder b; - -// Workflow> wf = -// WorkflowBuilderExtensions.BuildWithOutput>(b, "my_last_node", StreamingAggregators.Union()); - -// LocalRunner> runner = new(wf); - -// await runner.RunAsync(42).ConfigureAwait(false); -// var result = runner.RunningOutput; -// } -//} From 3ffa3f5bc41098e5bd873e378683f3d8a3332524 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 14:20:25 -0400 Subject: [PATCH 017/232] refactor: Normalize use of ValueTask --- .../Core/CompletedValueTaskSource.cs | 23 ------------------- .../Core/Executor.cs | 6 ++--- .../Execution/LocalRunner.cs | 2 +- .../Execution/LocalRunnerContext.cs | 6 ++--- .../OutputCollectorExecutor.cs | 2 +- .../Sample/02_Simple_Workflow_Sequential.cs | 4 ++-- .../Sample/02a_Simple_Workflow_Condition.cs | 2 +- .../Sample/02b_Simple_Workflow_Loop.cs | 6 ++--- 8 files changed, 14 insertions(+), 37 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs deleted file mode 100644 index 2c8cec1e81..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// Helper class to work around lack of proper ValueTask support in .NET Framework. -/// -internal static class CompletedValueTaskSource -{ - internal static ValueTask Completed => -#if NET5_0_OR_GREATER - ValueTask.CompletedTask; -#else - new(Task.CompletedTask); -#endif - - internal static ValueTask FromResult(T result) - { - return new ValueTask(result); - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index c617bb52e6..6ba34b4457 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -159,7 +159,7 @@ public void RestoreState(IDictionary state) /// protected virtual ValueTask PrepareForCheckpointAsync() { - return CompletedValueTaskSource.Completed; + return default; } /// @@ -168,7 +168,7 @@ protected virtual ValueTask PrepareForCheckpointAsync() /// protected virtual ValueTask AfterCheckpointRestoreAsync() { - return CompletedValueTaskSource.Completed; + return default; } /// @@ -179,7 +179,7 @@ protected virtual ValueTask AfterCheckpointRestoreAsync() protected virtual ValueTask InitializeOverride(IWorkflowContext context) { // Default implementation does nothing. - return CompletedValueTaskSource.Completed; + return default; } private async ValueTask FlushReduceRemainingAsync() diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index 0646a25b49..a155dfe2c2 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -143,7 +143,7 @@ public async ValueTask StreamAsync(TInput input, Cance public ValueTask GetResultAsync(CancellationToken cancellation = default) { // TODO: Block on finishing consuming StreamAsync()? - return CompletedValueTaskSource.FromResult(this.RunningOutput!); + return new ValueTask(this.RunningOutput!); } public TResult? RunningOutput => this._workflow.RunningOutput; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index e915d16157..2a4edcbcf4 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -44,7 +44,7 @@ public ValueTask AddExternalMessageAsync([NotNull] object message) Throw.IfNull(message); this._nextStep.MessagesFor(ExecutorIdentity.None).Add(message); - return CompletedValueTaskSource.Completed; + return default; } public bool NextStepHasActions => this._nextStep.HasMessages; @@ -57,13 +57,13 @@ public StepContext Advance() public ValueTask AddEventAsync(string executorId, WorkflowEvent workflowEvent) { this.QueuedEvents.Add(workflowEvent); - return CompletedValueTaskSource.Completed; + return default; } public ValueTask SendMessageAsync(string executorId, object message) { this._nextStep.MessagesFor(message.GetType().Name).Add(message); - return CompletedValueTaskSource.Completed; + return default; } public IWorkflowContext Bind(string executorId) diff --git a/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs index 3417b36161..3380293cfa 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs @@ -25,6 +25,6 @@ public OutputCollectorExecutor(StreamingAggregator aggregator, public ValueTask HandleAsync(TInput message, IWorkflowContext context) { this.Result = this._aggregator(message); - return CompletedValueTaskSource.Completed; + return default; } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs index af50478e26..c731a2c3f1 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs @@ -28,7 +28,7 @@ internal sealed class UppercaseExecutor : Executor, IMessageHandler HandleAsync(string message, IWorkflowContext context) { - return CompletedValueTaskSource.FromResult(message.ToUpperInvariant()); + return new ValueTask(message.ToUpperInvariant()); } } @@ -38,6 +38,6 @@ public ValueTask HandleAsync(string message, IWorkflowContext context) { char[] charArray = message.ToCharArray(); System.Array.Reverse(charArray); - return CompletedValueTaskSource.FromResult(new string(charArray)); + return new ValueTask(new string(charArray)); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs index d782783404..4040452540 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs @@ -47,7 +47,7 @@ public ValueTask HandleAsync(string message, IWorkflowContext context) bool isSpam = this.SpamKeywords.Any(keyword => message.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0); #endif - return CompletedValueTaskSource.FromResult(isSpam); + return new ValueTask(isSpam); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs index 3ce581bf8b..9b09b23306 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs @@ -79,15 +79,15 @@ public ValueTask HandleAsync(int message, IWorkflowContext context { if (message == this._targetNumber) { - return CompletedValueTaskSource.FromResult(NumberSignal.Matched); + return new ValueTask(NumberSignal.Matched); } else if (message < this._targetNumber) { - return CompletedValueTaskSource.FromResult(NumberSignal.Below); + return new ValueTask(NumberSignal.Below); } else { - return CompletedValueTaskSource.FromResult(NumberSignal.Above); + return new ValueTask(NumberSignal.Above); } } } From 2d2d518e5817349ec59c9847004f6a755832c844 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 10:06:46 -0400 Subject: [PATCH 018/232] fix: Build Break from removing .AddLoop --- .../Sample/02b_Simple_Workflow_Loop.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs index 9b09b23306..df69c47167 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs @@ -14,7 +14,8 @@ public static async ValueTask RunAsync() JudgeExecutor judge = new(42); // Let's say the target number is 42 Workflow workflow = new WorkflowBuilder(guessNumber) - .AddLoop(guessNumber, judge) + .AddEdge(guessNumber, judge) + .AddEdge(judge, guessNumber) .Build(); LocalRunner runner = new(workflow); From be09377067a07a9ad66002f6c08f96c4b3a0d006 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 10:08:37 -0400 Subject: [PATCH 019/232] refactor: Explicit routing and RouteBuilder Split out reflection from MessageRouter implemention into build phase, enabling AOT compilation to drive RouteBuilding without reflection. --- .../Core/Executor.cs | 71 +++++--- .../Core/IMessageRouter.cs | 16 ++ .../Core/MessageRouter.cs | 156 +++--------------- .../Core/RouteBuilder.cs | 92 +++++++++++ .../Core/RouteBuilderExtensions.cs | 78 +++++++++ .../Execution/DirectEdgeRunner.cs | 2 +- .../Execution/FanInEdgeRunner.cs | 2 +- .../Execution/FanOutEdgeRunner.cs | 2 +- .../Execution/InputEdgeRuner.cs | 2 +- 9 files changed, 260 insertions(+), 161 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/IMessageRouter.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 6ba34b4457..ebaea05391 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -27,7 +27,6 @@ public abstract class Executor : IIdentified, IAsyncDisposable /// public string Name { get; } - internal MessageRouter MessageRouter { get; init; } private Dictionary State { get; } = new(); /// @@ -39,8 +38,32 @@ protected Executor(string? id = null, string? name = null) { this.Name = name ?? this.GetType().Name; this.Id = id ?? $"{this.Name}{Guid.NewGuid():N}"; + } + + /// + /// Override this method to register handlers for the executor. The deafult implementation uses reflection to + /// look for implementations of and . + /// + /// + /// + protected virtual RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + return routeBuilder.ReflectHandlers(this); + } - this.MessageRouter = MessageRouter.BindMessageHandlers(this, checkType: true); + private MessageRouter? _router = null; + internal MessageRouter Router + { + get + { + if (this._router == null) + { + RouteBuilder routeBuilder = this.ConfigureRoutes(new RouteBuilder()); + this._router = routeBuilder.Build(); + } + + return this._router; + } } /// @@ -55,7 +78,7 @@ protected Executor(string? id = null, string? name = null) { await context.AddEventAsync(new ExecutorInvokeEvent(this.Id)).ConfigureAwait(false); - CallResult? result = await this.MessageRouter.RouteMessageAsync(message, context, requireRoute: true) + CallResult? result = await this.Router.RouteMessageAsync(message, context, requireRoute: true) .ConfigureAwait(false); await context.AddEventAsync(new ExecutorCompleteEvent(this.Id)).ConfigureAwait(false); @@ -81,10 +104,25 @@ protected Executor(string? id = null, string? name = null) private bool _initialized = false; + /// + /// Ensures that the executor has been initialized before performing operations. + /// + /// This method checks the internal state of the executor and throws an exception if it has not + /// been initialized. Call InitializeAsync before invoking any operations that require + /// initialization. + /// Thrown if the executor has not been initialized by calling InitializeAsync. + protected void CheckInitialized() + { + if (!this._initialized) + { + throw new InvalidOperationException($"Executor {this.GetType().Name} is not initialized. Call InitializeAsync first."); + } + } + /// /// . /// - public ISet InputTypes => this.MessageRouter.IncomingTypes; + public ISet InputTypes => this.Router.IncomingTypes; /// /// . @@ -97,7 +135,7 @@ protected Executor(string? id = null, string? name = null) /// /// /// - public bool CanHandle(Type messageType) => this.MessageRouter.CanHandle(messageType); + public bool CanHandle(Type messageType) => this.Router.CanHandle(messageType); /// /// . @@ -157,35 +195,20 @@ public void RestoreState(IDictionary state) /// . /// /// - protected virtual ValueTask PrepareForCheckpointAsync() - { - return default; - } + protected virtual ValueTask PrepareForCheckpointAsync() => default; /// /// . /// /// - protected virtual ValueTask AfterCheckpointRestoreAsync() - { - return default; - } + protected virtual ValueTask AfterCheckpointRestoreAsync() => default; /// /// . /// /// /// - protected virtual ValueTask InitializeOverride(IWorkflowContext context) - { - // Default implementation does nothing. - return default; - } - - private async ValueTask FlushReduceRemainingAsync() - { - return; - } + protected virtual ValueTask InitializeOverride(IWorkflowContext context) => default; /// /// . @@ -194,8 +217,6 @@ private async ValueTask FlushReduceRemainingAsync() protected virtual async ValueTask DisposeAsync() { this._initialized = false; - - await this.FlushReduceRemainingAsync().ConfigureAwait(false); } ValueTask IAsyncDisposable.DisposeAsync() diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageRouter.cs new file mode 100644 index 0000000000..8876220a8e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageRouter.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows.Core; + +internal interface IMessageRouter +{ + HashSet IncomingTypes { get; } + + bool CanHandle(object message); + bool CanHandle(Type candidateType); + ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs index 0e7fce5082..29281c63bc 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs @@ -2,165 +2,57 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -using HandlerInfosT = - System.Collections.Generic.Dictionary< - System.Type, - Microsoft.Agents.Workflows.Core.MessageHandlerInfo +using MessageHandlerF = + System.Func< + object, // message + Microsoft.Agents.Workflows.Core.IWorkflowContext, // context + System.Threading.Tasks.ValueTask >; namespace Microsoft.Agents.Workflows.Core; -internal class MessageRouter +internal class MessageRouter : IMessageRouter { - // TODO: The goal of the cache is to allow SourceGenerators to do the reflection to bind the handlers in the router. - internal static readonly Dictionary> s_routerFactoryCache = new(); + private readonly Dictionary _typedHandlers; + private readonly bool _hasCatchall; - private Dictionary>> BoundHandlers { get; init; } = new(); - private IDefaultMessageHandler? DefaultHandler { get; init; } = null; - - [SuppressMessage("Trimming", - "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + - "of the source method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - [SuppressMessage("Trimming", - "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of " + - "method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - private static HandlerInfosT ReflectHandlers(Type executorType) + internal MessageRouter(Dictionary handlers) { - // This method reflects over the methods of the executor type to find message handlers. - HandlerInfosT handlers = new(); - - // Get all implementations of IMessageHandler or IMessageHandler - // and create a MessageHandlerInfo for each. - if (!typeof(Executor).IsAssignableFrom(executorType)) - { - throw new ArgumentException($"Type {executorType.FullName} is not a valid Executor type.", nameof(executorType)); - } - - if (executorType.IsAbstract || executorType.IsInterface) - { - throw new ArgumentException($"Type {executorType.FullName} cannot be abstract or an interface.", nameof(executorType)); - } - - // Iterate all interfaces implemented by the executor type. - foreach (Type interfaceType in executorType.GetInterfaces()) - { - // Check if the interface is a message handler. - if (!interfaceType.IsGenericType) - { - continue; - } - - if (interfaceType.IsGenericType && (interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || - interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>))) - { - // Get the generic arguments of the interface. - Type[] genericArguments = interfaceType.GetGenericArguments(); - if (genericArguments.Length < 1 || genericArguments.Length > 2) - { - continue; // Invalid handler signature. - } - Type inType = genericArguments[0]; - Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; - MethodInfo? method = interfaceType.GetMethod("HandleAsync"); - if (method != null) - { - MessageHandlerInfo info = new(method) { InType = inType, OutType = outType }; - handlers[inType] = info; - } - } - } - - return handlers; + this._typedHandlers = Throw.IfNull(handlers); + this._hasCatchall = this._typedHandlers.ContainsKey(typeof(object)); } - [SuppressMessage("Trimming", - "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + - "of the source method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - internal static HandlerInfosT ReflectHandlers() where TExecutor : Executor - => ReflectHandlers(typeof(TExecutor)); - - internal static MessageRouter BindMessageHandlers(Executor executor, bool checkType) - { - if (s_routerFactoryCache.TryGetValue(executor.GetType(), out var factory)) - { - return factory(); - } - - // If no factory is found, reflect over the handlers - HandlerInfosT handlers = ReflectHandlers(executor.GetType()); + public HashSet IncomingTypes => [.. this._typedHandlers.Keys]; - Dictionary>> boundHandlers = new(); - foreach (Type inType in handlers.Keys) - { - MessageHandlerInfo handlerInfo = handlers[inType]; - Func> boundHandler = handlerInfo.Bind(executor, checkType); - boundHandlers.Add(inType, boundHandler); // TODO: Turn the error here into something more actionable. - } - - return new MessageRouter(boundHandlers, executor as IDefaultMessageHandler); - } + public bool CanHandle(object message) => this.CanHandle(Throw.IfNull(message).GetType()); - internal MessageRouter(Dictionary>> handlers, IDefaultMessageHandler? defaultHandler = null) + public bool CanHandle(Type candidateType) { - this.BoundHandlers = handlers; - this.DefaultHandler = defaultHandler; + // For now we only support routing to handlers registered on the exact type (no base type delegation). + return this._hasCatchall || this._typedHandlers.ContainsKey(candidateType); } - /// - /// . - /// - /// - /// - /// - /// - /// - /// public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) { Throw.IfNull(message); - // TODO: Implement base type delegation? CallResult? result = null; - if (this.BoundHandlers.TryGetValue(message.GetType(), out Func>? handler)) - { - result = await handler(message, context).ConfigureAwait(false); - } - else if (this.DefaultHandler != null) + + try { - try - { - await this.DefaultHandler.HandleAsync(message, context).ConfigureAwait(false); - result = CallResult.ReturnVoid(); - } - catch (Exception e) + if (this._typedHandlers.TryGetValue(message.GetType(), out MessageHandlerF? handler)) { - result = CallResult.RaisedException(wasVoid: true, e); + result = await handler(message, context).ConfigureAwait(false); } } + catch (Exception e) + { + result = CallResult.RaisedException(wasVoid: true, e); + } return result; } - - public bool CanHandle(object message) => this.CanHandle(Throw.IfNull(message).GetType()); - - public bool CanHandle(Type candidateType) - { - Throw.IfNull(candidateType); - - // Check if the router can handle the candidate type. - return this.DefaultHandler != null || this.BoundHandlers.ContainsKey(candidateType); - } - - public HashSet IncomingTypes - => this.DefaultHandler != null - ? [.. this.BoundHandlers.Keys, typeof(object)] - : [.. this.BoundHandlers.Keys]; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs new file mode 100644 index 0000000000..e480aeb97d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +using MessageHandlerF = + System.Func< + object, // message + Microsoft.Agents.Workflows.Core.IWorkflowContext, // context + System.Threading.Tasks.ValueTask + >; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +public class RouteBuilder +{ + private readonly Dictionary _typedHandlers = new(); + + internal RouteBuilder AddHandler(Type messageType, MessageHandlerF handler, bool overwrite = false) + { + Throw.IfNull(messageType); + Throw.IfNull(handler); + + // Overwrite must be false if the type is not registered. Overwrite must be true if the type is registered. + if (this._typedHandlers.ContainsKey(messageType) == overwrite) + { + this._typedHandlers[messageType] = handler; + } + else if (overwrite) + { + // overwrite is true, but the type is not registered. + throw new ArgumentException($"A handler for message type {messageType.FullName} has not yet been registered (overwrite = true)."); + } + else if (!overwrite) + { + throw new ArgumentException($"A handler for message type {messageType.FullName} is already registered (overwrite = false)."); + } + + return this; + } + + /// + /// . + /// + /// + /// + /// + /// + public RouteBuilder AddHandler(Func handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddHandler(typeof(TInput), WrappedHandlerAsync, overwrite); + + async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) + { + await handler.Invoke((TInput)msg, ctx).ConfigureAwait(false); + return CallResult.ReturnVoid(); + } + } + + /// + /// . + /// + /// + /// + /// + /// + /// + public RouteBuilder AddHandler(Func> handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddHandler(typeof(TInput), WrappedHandlerAsync, overwrite); + + async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) + { + TResult result = await handler.Invoke((TInput)msg, ctx).ConfigureAwait(false); + return CallResult.ReturnResult(result); + } + } + + internal MessageRouter Build() + { + return new MessageRouter(this._typedHandlers); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs new file mode 100644 index 0000000000..7d7aa7a7a3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Core; + +internal static class RouteBuilderExtensions +{ + [SuppressMessage("Trimming", + "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + + "of the source method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + [SuppressMessage("Trimming", + "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of " + + "method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + private static IEnumerable GetHandlerInfos(this Type executorType) + { + // Handlers are defined by implementations of IMessageHandler or IMessageHandler + Debug.Assert(typeof(Executor).IsAssignableFrom(executorType), "executorType must be an Executor type."); + + foreach (Type interfaceType in executorType.GetInterfaces()) + { + // Check if the interface is a message handler. + if (!interfaceType.IsGenericType) + { + continue; + } + + if (interfaceType.IsGenericType && (interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || + interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>))) + { + // Get the generic arguments of the interface. + Type[] genericArguments = interfaceType.GetGenericArguments(); + if (genericArguments.Length < 1 || genericArguments.Length > 2) + { + continue; // Invalid handler signature. + } + Type inType = genericArguments[0]; + Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; + MethodInfo? method = interfaceType.GetMethod("HandleAsync"); + + if (method != null) + { + yield return new MessageHandlerInfo(method) { InType = inType, OutType = outType }; + } + } + } + } + + public static RouteBuilder ReflectHandlers(this RouteBuilder builder, Type executorType, Executor executor) + { + Throw.IfNull(builder); + Throw.IfNull(executorType); + + Debug.Assert(typeof(Executor).IsAssignableFrom(executorType), "executorType must be an Executor type."); + + foreach (MessageHandlerInfo handlerInfo in executorType.GetHandlerInfos()) + { + builder = builder.AddHandler(handlerInfo.InType, handlerInfo.Bind(executor, checkType: true)); + } + + if (executor is IDefaultMessageHandler defaultHandler) + { + builder = builder.AddHandler(defaultHandler.HandleAsync); + } + + return builder; + } + + public static RouteBuilder ReflectHandlers(this RouteBuilder builder, TExecutor executor) where TExecutor : Executor + => builder.ReflectHandlers(typeof(TExecutor), executor); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs index 29b1b2dc83..8908a6e3e6 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs @@ -16,7 +16,7 @@ private async ValueTask FindRouterAsync() Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) .ConfigureAwait(false); - return sink.MessageRouter; + return sink.Router; } public async ValueTask> ChaseAsync(object message) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs index 585f7e1833..3d8db74eca 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs @@ -25,7 +25,7 @@ internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) .ConfigureAwait(false); - MessageRouter router = sink.MessageRouter; + MessageRouter router = sink.Router; if (router.CanHandle(message)) { return await router.RouteMessageAsync(message, this.BoundContext) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs index 7ff3e9c171..59afcd1ced 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs @@ -30,7 +30,7 @@ internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeDa Executor executor = await this.RunContext.EnsureExecutorAsync(targetId) .ConfigureAwait(false); - MessageRouter router = executor.MessageRouter; + MessageRouter router = executor.Router; if (router.CanHandle(message)) { return await router.RouteMessageAsync(message, this.BoundContexts[targetId]) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs index 2ba64f1ee8..24c5622b80 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs @@ -15,7 +15,7 @@ private async ValueTask FindRouterAsync() Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData) .ConfigureAwait(false); - return sink.MessageRouter; + return sink.Router; } public async ValueTask ChaseAsync(object message) From e7494596f50239cf2fa9633eca98c2e7da602be9 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 10:43:31 -0400 Subject: [PATCH 020/232] test: Add Reflection/Invocation tests --- .../Core/RouteBuilderExtensions.cs | 2 +- .../ReflectionSmokeTest.cs | 136 ++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs index 7d7aa7a7a3..601e5de199 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs @@ -74,5 +74,5 @@ public static RouteBuilder ReflectHandlers(this RouteBuilder builder, Type execu } public static RouteBuilder ReflectHandlers(this RouteBuilder builder, TExecutor executor) where TExecutor : Executor - => builder.ReflectHandlers(typeof(TExecutor), executor); + => builder.ReflectHandlers(executor.GetType(), executor); } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs new file mode 100644 index 0000000000..2a3734f7b4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Moq; + +namespace Microsoft.Agents.Orchestration.UnitTest; + +public class BaseTestExecutor : Executor +{ + protected void OnInvokedHandler() + { + this.InvokedHandler = true; + } + + public bool InvokedHandler + { + get; + private set; + } = false; +} + +public class DefaultHandler : BaseTestExecutor, IDefaultMessageHandler +{ + public ValueTask HandleAsync(object message, IWorkflowContext context) + { + this.OnInvokedHandler(); + return this.Handler(message, context); + } + + public Func Handler + { + get; + set; + } = (message, context) => default; +} + +public class TypedHandler : BaseTestExecutor, IMessageHandler +{ + public ValueTask HandleAsync(TInput message, IWorkflowContext context) + { + this.OnInvokedHandler(); + return this.Handler(message, context); + } + + public Func Handler + { + get; + set; + } = (message, context) => default; +} + +public class TypedHandlerWithOutput : BaseTestExecutor, IMessageHandler +{ + public ValueTask HandleAsync(TInput message, IWorkflowContext context) + { + this.OnInvokedHandler(); + return this.Handler(message, context); + } + public Func> Handler + { + get; + set; + } = (message, context) => default; +} + +public class RoutingReflectionTests +{ + private async ValueTask RunTestReflectAndRouteMessageAsync(BaseTestExecutor executor, TInput? input = default) where TInput : new() + { + MessageRouter router = executor.Router; + + Assert.NotNull(router); + input ??= new(); + Assert.True(router.CanHandle(input.GetType())); + Assert.True(router.CanHandle(input)); + + CallResult? result = await router.RouteMessageAsync(input, Mock.Of()); + + Assert.True(executor.InvokedHandler); + + return result; + } + + [Fact] + public async Task Test_ReflectAndExecute_DefaultHandlerAsync() + { + DefaultHandler executor = new(); + + CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor); + + Assert.NotNull(result); + Assert.True(result.IsSuccess); + Assert.True(result.IsVoid); + + await ((IAsyncDisposable)executor).DisposeAsync(); + } + + [Fact] + public async Task Test_ReflectAndExecute_HandlerReturnsVoidAsync() + { + TypedHandler executor = new(); + + CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor, 3); + + Assert.NotNull(result); + Assert.True(result.IsSuccess); + Assert.True(result.IsVoid); + + await ((IAsyncDisposable)executor).DisposeAsync(); + } + + [Fact] + public async Task Test_ReflectAndExecute_HandlerReturnsValueAsync() + { + TypedHandlerWithOutput executor = new() + { + Handler = (message, context) => + { + return new ValueTask($"{message}"); + } + }; + + const string Expected = "3"; + CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor, int.Parse(Expected)); + + Assert.NotNull(result); + Assert.True(result.IsSuccess); + Assert.False(result.IsVoid); + + Assert.Equal(Expected, result.Result); + + await ((IAsyncDisposable)executor).DisposeAsync(); + } +} From e7f964bec25efe11e4588c5de835f5178c0b4250 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 10:06:32 -0400 Subject: [PATCH 021/232] fix: Terminate on Completion event --- .../Execution/StreamingExecutionHandle.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs index ca915e24e7..048b6fa5ad 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs @@ -49,10 +49,23 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell { while (await this._stepRunner.RunSuperStepAsync(cancellation).ConfigureAwait(false)) { + bool hadCompletionEvent = false; List outputEvents = Interlocked.Exchange(ref eventSink, new()); foreach (WorkflowEvent raisedEvent in outputEvents) { yield return raisedEvent; + + // TODO: Do we actually want to interpret this as a termination request? + if (raisedEvent is WorkflowCompletedEvent) + { + hadCompletionEvent = true; + } + } + + if (hadCompletionEvent) + { + // If we had a completion event, we are done. + yield break; } } } From 377cacc15b7ba4d1a4193568b1012465f882743e Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 11:15:25 -0400 Subject: [PATCH 022/232] refactor: Update public API surface --- .../Core/CallResult.cs | 2 +- .../Microsoft.Agents.Workflow/Core/Edge.cs | 150 ++++++++++++++++++ .../Microsoft.Agents.Workflow/Core/Edges.cs | 89 ----------- .../Core/Executor.cs | 4 +- .../Core/IDefaultMessageHandler.cs | 22 --- .../Core/IWorkflowContext.cs | 13 +- .../Core/RouteBuilderExtensions.cs | 5 - .../Core/Workflow.cs | 68 ++++---- .../Execution/EdgeMap.cs | 22 +-- .../Execution/LocalRunner.cs | 69 ++++++-- .../Microsoft.Agents.Workflow/ExecutorIsh.cs | 52 +++++- .../StreamingAggregators.cs | 56 ++++++- .../WorkflowBuilder.cs | 72 +++++++-- .../WorkflowBuilderExtensions.cs | 32 +++- .../ReflectionSmokeTest.cs | 2 +- 15 files changed, 453 insertions(+), 205 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs index 934c03d43c..482a228e1a 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.Workflows.Core; /// This class represents the result of a call to a /// or . /// -public sealed class CallResult +internal sealed class CallResult { /// /// Indicates whether the call was void (i.e., no result expected). This only applies to diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs new file mode 100644 index 0000000000..de2dc0ed44 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +using PredicateT = System.Func; +using PartitionerT = System.Func>; +using System; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +/// +/// +/// +public record DirectEdgeData( + string SourceId, + string SinkId, + PredicateT? Condition = null) +{ + /// + /// . + /// + /// + public static implicit operator Edge(DirectEdgeData data) + { + return new Edge(data); + } +} + +/// +/// . +/// +/// +/// +/// +public record FanOutEdgeData( + string SourceId, + List SinkIds, + PartitionerT? Partitioner = null) +{ + /// + /// . + /// + /// + public static implicit operator Edge(FanOutEdgeData data) + { + return new Edge(data); + } +} + +/// +/// . +/// +public enum FanInTrigger +{ + /// + /// . + /// + WhenAll, + /// + /// . + /// + WhenAny +} + +/// +/// . +/// +/// +/// +/// +public record FanInEdgeData( + IEnumerable SourceIds, + string SinkId, + FanInTrigger Trigger = FanInTrigger.WhenAll) +{ + internal Guid UniqueKey { get; } = Guid.NewGuid(); + + /// + /// . + /// + /// + public static implicit operator Edge(FanInEdgeData data) + { + return new Edge(data); + } +} + +/// +/// . +/// +public class Edge +{ + /// + /// . + /// + public enum Type + { + /// + /// . + /// + Direct, + /// + /// . + /// + FanOut, + /// + /// . + /// + FanIn + } + + /// + /// . + /// + public Type EdgeType { get; init; } + + /// + /// . + /// + public object Data { get; init; } + + internal Edge(DirectEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.Direct; + } + + internal Edge(FanOutEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.FanOut; + } + + internal Edge(FanInEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.FanIn; + } + + internal DirectEdgeData? DirectEdgeData => this.Data as DirectEdgeData; + internal FanOutEdgeData? FanOutEdgeData => this.Data as FanOutEdgeData; + internal FanInEdgeData? FanInEdgeData => this.Data as FanInEdgeData; +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs deleted file mode 100644 index b17d8ebb54..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using Microsoft.Shared.Diagnostics; - -using PredicateT = System.Func; -using PartitionerT = System.Func>; -using System; - -namespace Microsoft.Agents.Workflows.Core; - -internal record DirectEdgeData( - string SourceId, - string SinkId, - PredicateT? Condition = null) -{ - public static implicit operator FlowEdge(DirectEdgeData data) - { - return new FlowEdge(data); - } -} - -internal record FanOutEdgeData( - string SourceId, - List SinkIds, - PartitionerT? Partitioner = null) -{ - public static implicit operator FlowEdge(FanOutEdgeData data) - { - return new FlowEdge(data); - } -} - -internal enum FanInTrigger -{ - WhenAll, - WhenAny -} - -internal record FanInEdgeData( - IEnumerable SourceIds, - string SinkId, - FanInTrigger Trigger = FanInTrigger.WhenAll) -{ - internal Guid UniqueKey { get; } = Guid.NewGuid(); - - public static implicit operator FlowEdge(FanInEdgeData data) - { - return new FlowEdge(data); - } -} - -internal class FlowEdge -{ - public enum Type - { - Direct, - FanOut, - FanIn - } - - public Type EdgeType { get; init; } - public object Data { get; init; } - - public FlowEdge(DirectEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.Direct; - } - - public FlowEdge(FanOutEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.FanOut; - } - - public FlowEdge(FanInEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.FanIn; - } - - public DirectEdgeData? DirectEdgeData => this.Data as DirectEdgeData; - public FanOutEdgeData? FanOutEdgeData => this.Data as FanOutEdgeData; - public FanInEdgeData? FanInEdgeData => this.Data as FanInEdgeData; -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index ebaea05391..5e43a5c98b 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -127,8 +126,7 @@ protected void CheckInitialized() /// /// . /// - [SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "")] - public ISet OutputTypes => throw new NotImplementedException(); + public virtual ISet OutputTypes => new HashSet(); /// /// . diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs deleted file mode 100644 index bd8de4e48b..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// A default message handler interface for handling messages that do not have a specific handler registered. -/// -public interface IDefaultMessageHandler -{ - /// - /// Handles the incoming message asynchronously. - /// - /// - /// This is used as a fallback handler for messages that do not have a specific handler registered. - /// - /// The message to handle. - /// The execution context. - /// A task that represents the asynchronous operation. - ValueTask HandleAsync(object message, IWorkflowContext context); -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs index decf8ce8d4..bf5528db9c 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs @@ -10,17 +10,18 @@ namespace Microsoft.Agents.Workflows.Core; public interface IWorkflowContext { /// - /// . + /// Adds an event to the workflow's output queue. These events will be raised to the caller of the workflow at the + /// end of the current SuperStep. /// - /// - /// + /// The event to be raised. + /// A representing the asynchronous operation. ValueTask AddEventAsync(WorkflowEvent workflowEvent); /// - /// . + /// Queues a message to be sent to connected executors. The message will be sent during the next SuperStep. /// - /// - /// + /// The message to be sent. + /// A representing the asynchronous operation. ValueTask SendMessageAsync(object message); // TODO: State management diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs index 601e5de199..2beadc3ec5 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs @@ -65,11 +65,6 @@ public static RouteBuilder ReflectHandlers(this RouteBuilder builder, Type execu builder = builder.AddHandler(handlerInfo.InType, handlerInfo.Bind(executor, checkType: true)); } - if (executor is IDefaultMessageHandler defaultHandler) - { - builder = builder.AddHandler(defaultHandler.HandleAsync); - } - return builder; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs index e246635e89..c49afff737 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs @@ -6,65 +6,72 @@ namespace Microsoft.Agents.Workflows.Core; -internal class Workflow +/// +/// . +/// +public class Workflow { + /// + /// . + /// public Dictionary> ExecutorProviders { get; internal init; } = new(); - public Dictionary> Edges { get; internal init; } = new(); -#if NET9_0_OR_GREATER - required -#endif - public string StartExecutorId - { get; init; } + /// + /// . + /// + public Dictionary> Edges { get; internal init; } = new(); -#if NET9_0_OR_GREATER - required -#endif - public Type InputType - { get; init; } = typeof(object); + /// + /// . + /// + public string StartExecutorId { get; } - public Workflow(string startExecutorId, Type type) + /// + /// . + /// + public Type InputType { get; } + + internal Workflow(string startExecutorId, Type type) { this.StartExecutorId = Throw.IfNull(startExecutorId); this.InputType = Throw.IfNull(type); // TODO: How do we (1) ensure the types are happy, and (2) work under AOT? } - -#if NET9_0_OR_GREATER - public Workflow() - { } -#endif } -internal class Workflow : Workflow +/// +/// . +/// +/// +public class Workflow : Workflow { + /// + /// . + /// + /// public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) { } -#if NET9_0_OR_GREATER - public Workflow() - { - this.InputType = typeof(T); - } -#endif - internal Workflow Promote(OutputSink outputSource) { Throw.IfNull(outputSource); return new Workflow(this.StartExecutorId, outputSource) { - StartExecutorId = this.StartExecutorId, ExecutorProviders = this.ExecutorProviders, Edges = this.Edges, - InputType = this.InputType, }; } } -internal class Workflow : Workflow +/// +/// . +/// +/// +/// +public class Workflow : Workflow { private readonly OutputSink _output; @@ -74,5 +81,8 @@ internal Workflow(string startExecutorId, OutputSink outputSource) this._output = Throw.IfNull(outputSource); } + /// + /// . + /// public TResult? RunningOutput => this._output.Result; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs index 0b84cba03a..373b443ec2 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs @@ -10,19 +10,19 @@ namespace Microsoft.Agents.Workflows.Execution; internal class EdgeMap { - private readonly Dictionary _edgeRunners = new(); - private readonly Dictionary _fanInState = new(); + private readonly Dictionary _edgeRunners = new(); + private readonly Dictionary _fanInState = new(); private readonly InputEdgeRuner _inputRunner; - public EdgeMap(IRunnerContext runContext, Dictionary> workflowEdges, string startExecutorId) + public EdgeMap(IRunnerContext runContext, Dictionary> workflowEdges, string startExecutorId) { - foreach (FlowEdge edge in workflowEdges.Values.SelectMany(e => e)) + foreach (Edge edge in workflowEdges.Values.SelectMany(e => e)) { object edgeRunner = edge.EdgeType switch { - FlowEdge.Type.Direct => new DirectEdgeRunner(runContext, edge.DirectEdgeData!), - FlowEdge.Type.FanOut => new FanOutEdgeRunner(runContext, edge.FanOutEdgeData!), - FlowEdge.Type.FanIn => new FanInEdgeRunner(runContext, edge.FanInEdgeData!), + Edge.Type.Direct => new DirectEdgeRunner(runContext, edge.DirectEdgeData!), + Edge.Type.FanOut => new FanOutEdgeRunner(runContext, edge.FanOutEdgeData!), + Edge.Type.FanIn => new FanInEdgeRunner(runContext, edge.FanInEdgeData!), _ => throw new NotSupportedException($"Unsupported edge type: {edge.EdgeType}") }; @@ -32,7 +32,7 @@ public EdgeMap(IRunnerContext runContext, Dictionary> this._inputRunner = new InputEdgeRuner(runContext, startExecutorId); } - public async ValueTask> InvokeEdgeAsync(FlowEdge edge, string sourceId, object message) + public async ValueTask> InvokeEdgeAsync(Edge edge, string sourceId, object message) { if (!this._edgeRunners.TryGetValue(edge, out object? edgeRunner)) { @@ -48,21 +48,21 @@ public EdgeMap(IRunnerContext runContext, Dictionary> // in FanIn/Out cases) // TODO: Once we have a fixed interface, if it is reasonably generalizable // between the Runners, we can normalize it behind an IFace. - case FlowEdge.Type.Direct: + case Edge.Type.Direct: { DirectEdgeRunner runner = (DirectEdgeRunner)this._edgeRunners[edge]; edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); break; } - case FlowEdge.Type.FanOut: + case Edge.Type.FanOut: { FanOutEdgeRunner runner = (FanOutEdgeRunner)this._edgeRunners[edge]; edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); break; } - case FlowEdge.Type.FanIn: + case Edge.Type.FanIn: { FanInEdgeState state = this._fanInState[edge]; FanInEdgeRunner runner = (FanInEdgeRunner)this._edgeRunners[edge]; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index a155dfe2c2..e5bc824348 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -10,8 +10,16 @@ namespace Microsoft.Agents.Workflows.Execution; -internal class LocalRunner : ISuperStepRunner where TInput : notnull +/// +/// . +/// +/// +public class LocalRunner : ISuperStepRunner where TInput : notnull { + /// + /// . + /// + /// public LocalRunner(Workflow workflow) { this.Workflow = Throw.IfNull(workflow); @@ -27,13 +35,19 @@ ValueTask ISuperStepRunner.EnqueueMessageAsync(object message) return this.RunContext.AddExternalMessageAsync(message); } - protected Dictionary PendingCalls { get; } = new(); - protected Workflow Workflow { get; init; } - protected LocalRunnerContext RunContext { get; init; } - protected EdgeMap EdgeMap { get; init; } + private Dictionary PendingCalls { get; } = new(); + private Workflow Workflow { get; init; } + private LocalRunnerContext RunContext { get; init; } + private EdgeMap EdgeMap { get; init; } // TODO: Better signature? - public event EventHandler? WorkflowEvent; + event EventHandler? ISuperStepRunner.WorkflowEvent + { + add => this.WorkflowEvent += value; + remove => this.WorkflowEvent -= value; + } + + private event EventHandler? WorkflowEvent; private void RaiseWorkflowEvent(WorkflowEvent workflowEvent) { @@ -56,6 +70,12 @@ private bool IsResponse(object message) : this.EdgeMap.InvokeInputAsync(message); } + /// + /// . + /// + /// + /// + /// public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) { await this.RunContext.AddExternalMessageAsync(input).ConfigureAwait(false); @@ -64,7 +84,7 @@ public async ValueTask StreamAsync(TInput input, Cance } private StepContext? _currentStep = null; - public async ValueTask RunSuperStepAsync(CancellationToken cancellation) + async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cancellation) { cancellation.ThrowIfCancellationRequested(); @@ -99,8 +119,8 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) } else { - HashSet outgoingEdges = this.Workflow.Edges[sender.Id!]; // Id is not null when Identity is not .None - foreach (FlowEdge outgoingEdge in outgoingEdges) + HashSet outgoingEdges = this.Workflow.Edges[sender.Id!]; // Id is not null when Identity is not .None + foreach (Edge outgoingEdge in outgoingEdges) { edgeTasks.AddRange(senderMessages.Select(message => this.EdgeMap.InvokeEdgeAsync(outgoingEdge, sender.Id, message).AsTask())); } @@ -122,31 +142,54 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) } } -internal class LocalRunner : IRunnerWithResult where TInput : notnull +/// +/// . +/// +/// +/// +public class LocalRunner : IRunnerWithResult where TInput : notnull { private readonly Workflow _workflow; - private readonly LocalRunner _innerRunner; + private readonly ISuperStepRunner _innerRunner; + /// + /// . + /// + /// public LocalRunner(Workflow workflow) { this._workflow = Throw.IfNull(workflow); this._innerRunner = new LocalRunner(workflow); } + /// + /// . + /// + /// + /// + /// public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) { - await this.StepRunner.EnqueueMessageAsync(input).ConfigureAwait(false); + await this._innerRunner.EnqueueMessageAsync(input).ConfigureAwait(false); return new StreamingExecutionHandle(this._innerRunner); } + /// + /// . + /// + /// + /// public ValueTask GetResultAsync(CancellationToken cancellation = default) { // TODO: Block on finishing consuming StreamAsync()? return new ValueTask(this.RunningOutput!); } + /// + /// . + /// public TResult? RunningOutput => this._workflow.RunningOutput; - public ISuperStepRunner StepRunner => this._innerRunner; + ISuperStepRunner IRunnerWithResult.StepRunner => this._innerRunner; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs index a1fa16dd58..6200dc5d2e 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -6,41 +6,65 @@ namespace Microsoft.Agents.Workflows; -internal sealed class ExecutorIsh : +/// +/// . +/// +public sealed class ExecutorIsh : IIdentified, IEquatable, IEquatable, IEquatable { + /// + /// . + /// public enum Type { + /// + /// . + /// Unbound, + /// + /// . + /// Executor, //Function, //Agent, //ProcessStep } + /// + /// . + /// public Type ExecutorType { get; init; } private readonly string? _idValue; private readonly Executor? _executorValue; //private readonly Func? _functionValue; + /// + /// . + /// + /// public ExecutorIsh(Executor executor) { this.ExecutorType = Type.Executor; this._executorValue = Throw.IfNull(executor); } + /// + /// . + /// + /// public ExecutorIsh(string id) { this.ExecutorType = Type.Unbound; this._idValue = Throw.IfNull(id); } - public bool IsUnbound => this.ExecutorType == Type.Unbound; + internal bool IsUnbound => this.ExecutorType == Type.Unbound; + /// public string Id => this.ExecutorType switch { Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), @@ -48,9 +72,12 @@ public ExecutorIsh(string id) //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), - _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") }; + /// + /// . + /// public ExecutorProvider ExecutorProvider => this.ExecutorType switch { Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), @@ -58,7 +85,7 @@ public ExecutorIsh(string id) //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), - _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") }; //public ExecutorIsh(Func function) @@ -67,7 +94,10 @@ public ExecutorIsh(string id) // this._functionValue = Throw.IfNull(function); //} - // Implicit conversions into ExecutorIsh + /// + /// . + /// + /// public static implicit operator ExecutorIsh(Executor executor) { return new ExecutorIsh(executor); @@ -79,29 +109,37 @@ public static implicit operator ExecutorIsh(Executor executor) // return new ExecutorIsh(function); //} + /// + /// . + /// + /// public static implicit operator ExecutorIsh(string id) { return new ExecutorIsh(id); } + /// public bool Equals(ExecutorIsh? other) { return other is not null && other.Id == this.Id; } + /// public bool Equals(IIdentified? other) { return other is not null && other.Id == this.Id; } + /// public bool Equals(string? other) { return other is not null && other == this.Id; } + /// public override bool Equals(object? obj) { if (obj is null) @@ -125,11 +163,13 @@ public override bool Equals(object? obj) return false; } + /// public override int GetHashCode() { return this.Id.GetHashCode(); } + /// public override string ToString() { return this.ExecutorType switch @@ -139,7 +179,7 @@ public override string ToString() //Type.Function => $"ExecutorIsh for Function with ID '{this.Id}'", //Type.Agent => $"ExecutorIsh for Agent with ID '{this.Id}'", //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", - _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + _ => $"'{this.Id}':(TInput input); - -internal static class StreamingAggregators +/// +/// . +/// +/// +/// +/// +/// +public delegate TResult? StreamingAggregator(TInput input); + +/// +/// . +/// +public static class StreamingAggregators { + /// + /// . + /// + /// + /// + /// + /// + /// public static StreamingAggregator First(Func conversion, TResult? defaultValue = default) { bool hasRun = false; @@ -27,9 +45,23 @@ public static StreamingAggregator First(Func + /// . + /// + /// + /// + /// public static StreamingAggregator First(TInput? defaultValue = default) => First(input => input, defaultValue); + /// + /// . + /// + /// + /// + /// + /// + /// public static StreamingAggregator Last(Func conversion, TResult? defaultValue = default) { TResult? local = defaultValue; @@ -43,9 +75,22 @@ public static StreamingAggregator Last(Func + /// . + /// + /// + /// + /// public static StreamingAggregator Last(TInput? defaultValue = default) => Last(input => input, defaultValue); + /// + /// . + /// + /// + /// + /// + /// public static StreamingAggregator> Union(Func conversion) { List results = new(); @@ -59,6 +104,11 @@ IEnumerable Aggregate(TInput input) } } + /// + /// . + /// + /// + /// public static StreamingAggregator> Union() => Union(input => input); } diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index 28416db074..d92bef1c8a 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -15,23 +15,35 @@ namespace Microsoft.Agents.Workflows; -internal delegate TExecutor ExecutorProvider() +/// +/// A factory method that produces an executor instance. +/// +/// The executor type. +/// A new instance. +public delegate TExecutor ExecutorProvider() where TExecutor : Executor; -internal record struct EdgeId(string SourceId, string TargetId) +/// +/// . +/// +public class WorkflowBuilder { - public override string ToString() => $"{this.SourceId} -> {this.TargetId}"; -} + private record struct EdgeId(string SourceId, string TargetId) + { + public override string ToString() => $"{this.SourceId} -> {this.TargetId}"; + } -internal class WorkflowBuilder -{ private readonly Dictionary> _executors = new(); - private readonly Dictionary> _edges = new(); + private readonly Dictionary> _edges = new(); private readonly HashSet _unboundExecutors = new(); private readonly HashSet _conditionlessEdges = new(); private readonly string _startExecutorId; + /// + /// . + /// + /// public WorkflowBuilder(ExecutorIsh start) { this._startExecutorId = this.Track(start).Id; @@ -63,6 +75,12 @@ private void UpdateExecutor(string id, ExecutorProvider provider) this._executors[id] = provider; } + /// + /// . + /// + /// + /// + /// public WorkflowBuilder BindExecutor(Executor executor) { if (!this._unboundExecutors.Contains(executor.Id)) @@ -76,18 +94,26 @@ public WorkflowBuilder BindExecutor(Executor executor) return this; } - private HashSet EnsureEdgesFor(string sourceId) + private HashSet EnsureEdgesFor(string sourceId) { // Ensure that there is a set of edges for the given source ID. // If it does not exist, create a new one. - if (!this._edges.TryGetValue(sourceId, out HashSet? edges)) + if (!this._edges.TryGetValue(sourceId, out HashSet? edges)) { - this._edges[sourceId] = edges = new HashSet(); + this._edges[sourceId] = edges = new HashSet(); } return edges; } + /// + /// . + /// + /// + /// + /// + /// + /// public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func? condition = null) { // Add an edge from source to target with an optional condition. @@ -110,8 +136,13 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func + /// . + /// + /// + /// + /// + /// public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func>? partitioner = null, params ExecutorIsh[] targets) { Throw.IfNull(source); @@ -126,6 +157,13 @@ public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func + /// . + /// + /// + /// + /// + /// public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = default, params ExecutorIsh[] sources) { Throw.IfNull(target); @@ -144,6 +182,12 @@ public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = d return this; } + /// + /// . + /// + /// + /// + /// public Workflow Build() { if (this._unboundExecutors.Count > 0) @@ -173,9 +217,7 @@ public Workflow Build() return new Workflow(this._startExecutorId) // Why does it not see the default ctor? { ExecutorProviders = this._executors, - Edges = this._edges, - StartExecutorId = this._startExecutorId, - InputType = typeof(T) + Edges = this._edges }; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index 7b2836952b..aa8fe8c8ce 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -7,8 +7,19 @@ namespace Microsoft.Agents.Workflows; -internal static class WorkflowBuilderExtensions +/// +/// . +/// +public static class WorkflowBuilderExtensions { + /// + /// . + /// + /// + /// + /// + /// + /// public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors) { Throw.IfNull(builder); @@ -34,9 +45,28 @@ public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh return builder; } + /// + /// . + /// + /// + /// + /// + /// + /// + /// public static Workflow BuildWithOutput(this WorkflowBuilder builder, ExecutorIsh outputSource, StreamingAggregator aggregator) => builder.BuildWithOutput(outputSource, aggregator); + /// + /// . + /// + /// + /// + /// + /// + /// + /// + /// public static Workflow BuildWithOutput(this WorkflowBuilder builder, ExecutorIsh outputSource, StreamingAggregator aggregator) { Throw.IfNull(outputSource); diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs index 2a3734f7b4..ae6cb303a1 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs @@ -21,7 +21,7 @@ public bool InvokedHandler } = false; } -public class DefaultHandler : BaseTestExecutor, IDefaultMessageHandler +public class DefaultHandler : BaseTestExecutor, IMessageHandler { public ValueTask HandleAsync(object message, IWorkflowContext context) { From 61201389b62e9710d9b40696eb8d4b694f2cf3c2 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 18:23:48 -0400 Subject: [PATCH 023/232] feat: Add support for external requests --- .../Microsoft.Agents.Workflow/Core/Events.cs | 5 ++ .../Core/ExternalRequest.cs | 73 +++++++++++++++++++ .../Core/ExternalResponse.cs | 16 ++++ .../Core/InputPort.cs | 14 ++++ .../Core/Workflow.cs | 6 ++ .../Execution/EdgeMap.cs | 24 ++++-- .../Execution/IExternalRequestSink.cs | 11 +++ .../Execution/IRunnerContext.cs | 4 +- .../{InputEdgeRuner.cs => InputEdgeRunner.cs} | 13 +++- .../Execution/LocalRunner.cs | 18 ++++- .../Execution/LocalRunnerContext.cs | 20 ++++- .../Execution/StreamingExecutionHandle.cs | 10 +-- .../Microsoft.Agents.Workflow/ExecutorIsh.cs | 42 ++++++++--- .../Microsoft.Agents.Workflows.csproj | 8 +- .../OutputCollectorExecutor.cs | 2 +- .../Specialized/RequestInputExecutor.cs | 46 ++++++++++++ .../WorkflowBuilderExtensions.cs | 21 ++++++ 17 files changed, 295 insertions(+), 38 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/IExternalRequestSink.cs rename dotnet/src/Microsoft.Agents.Workflow/Execution/{InputEdgeRuner.cs => InputEdgeRunner.cs} (67%) rename dotnet/src/Microsoft.Agents.Workflow/{ => Specialized}/OutputCollectorExecutor.cs (94%) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs index 6ce2a05702..0a06cf1bb0 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs @@ -19,6 +19,11 @@ public record WorkflowStartedEvent : WorkflowEvent; /// public record WorkflowCompletedEvent : WorkflowEvent; +/// +/// . +/// +public record RequestInputEvent(ExternalRequest request) : WorkflowEvent; + /// /// . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs new file mode 100644 index 0000000000..c7ab3b2d5e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +/// +/// +/// +public record ExternalRequest(InputPort Port, string RequestId, object Data) +{ + /// + /// . + /// + /// + /// + /// + /// + /// + public static ExternalRequest Create(InputPort port, [NotNull] object data, string? requestId = null) + { + if (!port.Request.IsAssignableFrom(Throw.IfNull(data).GetType())) + { + throw new InvalidOperationException( + $"Message type {data.GetType().Name} is not assignable to the request type {port.Request.Name} of input port {port.Id}."); + } + + requestId ??= Guid.NewGuid().ToString("N"); + + return new ExternalRequest(port, requestId, data); + } + + /// + /// . + /// + /// + /// + /// + /// + /// + public static ExternalRequest Create(InputPort port, T data, string? requestId = null) => Create(port, (object)Throw.IfNull(data), requestId); + + /// + /// . + /// + /// + /// + /// + public ExternalResponse CreateResponse(object data) + { + if (!Throw.IfNull(this.Port).Response.IsAssignableFrom(Throw.IfNull(data).GetType())) + { + throw new InvalidOperationException( + $"Message type {data.GetType().Name} is not assignable to the response type {this.Port.Response.Name} of input port {this.Port.Id}."); + } + + return new ExternalResponse(this.Port, this.RequestId, data); + } + + /// + /// . + /// + /// + /// + /// + /// + public ExternalResponse CreateResponse(InputPort port, T data) => this.CreateResponse(port, (object)Throw.IfNull(data)); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs new file mode 100644 index 0000000000..2c6bd22782 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + + +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +/// +/// +/// +public record ExternalResponse(InputPort Port, string RequestId, object Data) +{ +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs new file mode 100644 index 0000000000..cfa4e27f8d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +/// +/// +/// +public record InputPort(string Id, Type Request, Type Response) +{ }; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs index c49afff737..b12ff25113 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using Microsoft.Agents.Workflows.Specialized; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; @@ -21,6 +22,11 @@ public class Workflow /// public Dictionary> Edges { get; internal init; } = new(); + /// + /// . + /// + public Dictionary Ports { get; } = new(); + /// /// . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs index 373b443ec2..f9ba08af00 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs @@ -12,9 +12,13 @@ internal class EdgeMap { private readonly Dictionary _edgeRunners = new(); private readonly Dictionary _fanInState = new(); - private readonly InputEdgeRuner _inputRunner; + private readonly Dictionary _portEdgeRunners; + private readonly InputEdgeRunner _inputRunner; - public EdgeMap(IRunnerContext runContext, Dictionary> workflowEdges, string startExecutorId) + public EdgeMap(IRunnerContext runContext, + Dictionary> workflowEdges, + IEnumerable workflowPorts, + string startExecutorId) { foreach (Edge edge in workflowEdges.Values.SelectMany(e => e)) { @@ -29,7 +33,12 @@ public EdgeMap(IRunnerContext runContext, Dictionary> work this._edgeRunners[edge] = edgeRunner; } - this._inputRunner = new InputEdgeRuner(runContext, startExecutorId); + this._portEdgeRunners = workflowPorts.ToDictionary( + port => port.Id, + port => InputEdgeRunner.ForPort(runContext, port) + ); + + this._inputRunner = new InputEdgeRunner(runContext, startExecutorId); } public async ValueTask> InvokeEdgeAsync(Edge edge, string sourceId, object message) @@ -84,8 +93,13 @@ public EdgeMap(IRunnerContext runContext, Dictionary> work return [await this._inputRunner.ChaseAsync(inputMessage).ConfigureAwait(false)]; } - public ValueTask> InvokeResponseAsync(object externalResponse) + public async ValueTask> InvokeResponseAsync(ExternalResponse response) { - throw new NotImplementedException(); + if (!this._portEdgeRunners.TryGetValue(response.Port.Id, out InputEdgeRunner? portRunner)) + { + throw new InvalidOperationException($"Port {response.Port.Id} not found in the edge map."); + } + + return [await portRunner.ChaseAsync(response.Data).ConfigureAwait(false)]; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IExternalRequestSink.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/IExternalRequestSink.cs new file mode 100644 index 0000000000..76301b2785 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/IExternalRequestSink.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal interface IExternalRequestSink +{ + ValueTask PostAsync(ExternalRequest request); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs index 78770036a9..692abdf3af 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs @@ -5,9 +5,9 @@ namespace Microsoft.Agents.Workflows.Execution; -internal interface IRunnerContext +internal interface IRunnerContext : IExternalRequestSink { - ValueTask AddEventAsync(string executorId, WorkflowEvent workflowEvent); + ValueTask AddEventAsync(WorkflowEvent workflowEvent); ValueTask SendMessageAsync(string executorId, object message); // TODO: State Management diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs similarity index 67% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs rename to dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs index 24c5622b80..b0c45ba0f4 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs @@ -2,14 +2,23 @@ using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Execution; -internal class InputEdgeRuner(IRunnerContext runContext, string sinkId) +internal class InputEdgeRunner(IRunnerContext runContext, string sinkId) : EdgeRunner(runContext, sinkId) { public IWorkflowContext WorkflowContext { get; } = runContext.Bind(sinkId); + public static InputEdgeRunner ForPort(IRunnerContext runContext, InputPort port) + { + Throw.IfNull(port); + + // The port is an input port, so we can use the port's ID as the sink ID. + return new InputEdgeRunner(runContext, port.Id); + } + private async ValueTask FindRouterAsync() { Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData) @@ -24,7 +33,7 @@ private async ValueTask FindRouterAsync() if (router.CanHandle(message)) { return await router.RouteMessageAsync(message, this.WorkflowContext) - .ConfigureAwait(false); + .ConfigureAwait(false); } // TODO: Throw instead? diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index e5bc824348..ed37852ccb 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -27,7 +27,7 @@ public LocalRunner(Workflow workflow) // 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.StartExecutorId); + this.EdgeMap = new EdgeMap(this.RunContext, this.Workflow.Edges, this.Workflow.Ports.Values, this.Workflow.StartExecutorId); } ValueTask ISuperStepRunner.EnqueueMessageAsync(object message) @@ -56,7 +56,7 @@ private void RaiseWorkflowEvent(WorkflowEvent workflowEvent) private bool IsResponse(object message) { - return false; + return message is ExternalResponse; } private ValueTask> RouteExternalMessageAsync(object message) @@ -65,11 +65,21 @@ private bool IsResponse(object message) bool isHil = false; #pragma warning restore CS0219 // Variable is assigned but its value is never used - return this.IsResponse(message) - ? this.EdgeMap.InvokeResponseAsync(message) + return message is ExternalResponse response + ? this.CompleteExternalResponseAsync(response) : this.EdgeMap.InvokeInputAsync(message); } + private ValueTask> CompleteExternalResponseAsync(ExternalResponse response) + { + if (!this.RunContext.CompleteRequest(response.RequestId)) + { + throw new InvalidOperationException($"No pending request with ID {response.RequestId} found in the workflow context."); + } + + return this.EdgeMap.InvokeResponseAsync(response); + } + /// /// . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index 2a4edcbcf4..fb14752d2d 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Specialized; using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; @@ -16,6 +17,7 @@ internal class LocalRunnerContext : IRunnerContext private StepContext _nextStep = new(); private readonly Dictionary> _executorProviders; private readonly Dictionary _executors = new(); + private readonly Dictionary _externalRequests = new(); public LocalRunnerContext(Workflow workflow, ILogger? logger = null) { @@ -34,6 +36,11 @@ public async ValueTask EnsureExecutorAsync(string executorId) this._executors[executorId] = executor = provider(); await executor.InitializeAsync(this.Bind(executor.Id)).ConfigureAwait(false); + + if (executor is RequestInputExecutor requestInputExecutor) + { + requestInputExecutor.AttachRequestSink(this); + } } return executor; @@ -48,13 +55,14 @@ public ValueTask AddExternalMessageAsync([NotNull] object message) } public bool NextStepHasActions => this._nextStep.HasMessages; + public bool HasUnservicedRequests => this._externalRequests.Count > 0; public StepContext Advance() { return Interlocked.Exchange(ref this._nextStep, new StepContext()); } - public ValueTask AddEventAsync(string executorId, WorkflowEvent workflowEvent) + public ValueTask AddEventAsync(WorkflowEvent workflowEvent) { this.QueuedEvents.Add(workflowEvent); return default; @@ -71,11 +79,19 @@ public IWorkflowContext Bind(string executorId) return new BoundContext(this, executorId); } + public ValueTask PostAsync(ExternalRequest request) + { + this._externalRequests.Add(request.RequestId, request); + return this.AddEventAsync(new RequestInputEvent(request)); + } + + public bool CompleteRequest(string requestId) => this._externalRequests.Remove(requestId); + public readonly List QueuedEvents = new(); private class BoundContext(LocalRunnerContext RunnerContext, string ExecutorId) : IWorkflowContext { - public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => RunnerContext.AddEventAsync(ExecutorId, workflowEvent); + public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => RunnerContext.AddEventAsync(workflowEvent); public ValueTask SendMessageAsync(object message) => RunnerContext.SendMessageAsync(ExecutorId, message); } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs index 048b6fa5ad..eba2f960a1 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs @@ -28,7 +28,7 @@ internal StreamingExecutionHandle(ISuperStepRunner stepRunner) /// /// /// - public ValueTask SendResponseAsync(object response) + public ValueTask SendResponseAsync(ExternalResponse response) { return this._stepRunner.EnqueueMessageAsync(response); } @@ -119,20 +119,20 @@ public static class ExecutionHandleExtensions /// name="handle"/> and invokes the for each event. If the callback returns a /// non- response, the response is sent back to the workflow using the handle. /// The representing the workflow execution stream to monitor. - /// An optional callback function invoked for each received from the stream. The - /// callback can return a response object to be sent back to the workflow, or if no response + /// An optional callback function invoked for each received from the stream. + /// The /// callback can return a response object to be sent back to the workflow, or if no response /// is required. /// A to observe while waiting for events. Defaults to . /// A that represents the asynchronous operation. The task completes when the workflow /// execution stream is fully processed. - public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) + public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) { Throw.IfNull(handle); await foreach (WorkflowEvent @event in handle.WatchStreamAsync(cancellation).ConfigureAwait(false)) { - object? maybeResponse = eventCallback?.Invoke(@event); + ExternalResponse? maybeResponse = eventCallback?.Invoke(@event); if (maybeResponse != null) { await handle.SendResponseAsync(maybeResponse).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs index 6200dc5d2e..30367ac709 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -2,6 +2,7 @@ using System; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Specialized; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; @@ -28,6 +29,10 @@ public enum Type /// . /// Executor, + /// + /// . + /// + InputPort, //Function, //Agent, //ProcessStep @@ -40,8 +45,19 @@ public enum Type private readonly string? _idValue; private readonly Executor? _executorValue; + private readonly InputPort? _inputPortValue; //private readonly Func? _functionValue; + /// + /// . + /// + /// + public ExecutorIsh(string id) + { + this.ExecutorType = Type.Unbound; + this._idValue = Throw.IfNull(id); + } + /// /// . /// @@ -55,11 +71,11 @@ public ExecutorIsh(Executor executor) /// /// . /// - /// - public ExecutorIsh(string id) + /// + public ExecutorIsh(InputPort port) { - this.ExecutorType = Type.Unbound; - this._idValue = Throw.IfNull(id); + this.ExecutorType = Type.InputPort; + this._inputPortValue = Throw.IfNull(port); } internal bool IsUnbound => this.ExecutorType == Type.Unbound; @@ -69,6 +85,7 @@ public ExecutorIsh(string id) { Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), Type.Executor => this._executorValue!.Id, + Type.InputPort => this._inputPortValue!.Id, //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), @@ -82,6 +99,7 @@ public ExecutorIsh(string id) { Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), Type.Executor => () => this._executorValue!, + Type.InputPort => () => new RequestInputExecutor(this._inputPortValue!), //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), @@ -98,10 +116,13 @@ public ExecutorIsh(string id) /// . /// /// - public static implicit operator ExecutorIsh(Executor executor) - { - return new ExecutorIsh(executor); - } + public static implicit operator ExecutorIsh(Executor executor) => new(executor); + + /// + /// . + /// + /// + public static implicit operator ExecutorIsh(InputPort inputPort) => new(inputPort); // How do we AoT compile this? //public static implicit operator ExecutorIsh(Func function) @@ -175,11 +196,12 @@ public override string ToString() return this.ExecutorType switch { Type.Unbound => $"'{this.Id}':", - Type.Executor => $"'{this.Id}':{this._executorValue!.GetType()}", + Type.Executor => $"'{this.Id}':{this._executorValue!.GetType().Name}", + Type.InputPort => $"'{this.Id}':Input({this._inputPortValue!.Request.Name}->{this._inputPortValue!.Response.Name})", //Type.Function => $"ExecutorIsh for Function with ID '{this.Id}'", //Type.Agent => $"ExecutorIsh for Agent with ID '{this.Id}'", //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", - _ => $"'{this.Id}': $"'{this.Id}':" }; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj index 478396f484..65f8a0dfab 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj @@ -20,14 +20,8 @@ - - - - - - + - diff --git a/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs similarity index 94% rename from dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs index 3380293cfa..f3658fb479 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs @@ -4,7 +4,7 @@ using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.Workflows; +namespace Microsoft.Agents.Workflows.Specialized; internal class OutputSink : Executor { diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs new file mode 100644 index 0000000000..be460d2955 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Specialized; + +internal class RequestInputExecutor : Executor, IMessageHandler, IMessageHandler +{ + private InputPort Port { get; } + private IExternalRequestSink? RequestSink { get; set; } + + public RequestInputExecutor(InputPort port) : base(port.Id) + { + this.Port = port; + } + + internal void AttachRequestSink(IExternalRequestSink requestSink) + { + this.RequestSink = Throw.IfNull(requestSink); + } + + public ValueTask HandleAsync(object message, IWorkflowContext context) + { + Throw.IfNull(message); + + return this.RequestSink!.PostAsync(ExternalRequest.Create(this.Port, message)); + } + + public ValueTask HandleAsync(ExternalResponse message, IWorkflowContext context) + { + Throw.IfNull(message); + Throw.IfNull(message.Data); + + if (!this.Port.Response.IsAssignableFrom(message.Data.GetType())) + { + throw new InvalidOperationException( + $"Message type {message.Data.GetType().Name} is not assignable to the response type {this.Port.Response.Name} of input port {this.Port.Id}."); + } + + return context.SendMessageAsync(message.Data); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index aa8fe8c8ce..7643d05078 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Specialized; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; @@ -45,6 +46,26 @@ public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh return builder; } + /// + /// . + /// + /// + /// + /// + /// + /// + /// + public static WorkflowBuilder AddExternalCall(this WorkflowBuilder builder, ExecutorIsh source, string portId) + { + Throw.IfNull(builder); + Throw.IfNull(source); + Throw.IfNull(portId); + + InputPort port = new(portId, typeof(TRequest), typeof(TResponse)); + return builder.AddEdge(source, port) + .AddEdge(port, source); + } + /// /// . /// From af38f4eacc32b86830ddb9853adb47159edacd6f Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 18:59:24 -0400 Subject: [PATCH 024/232] feat: Support hosting AIAgent instances in Workflows --- .../Microsoft.Agents.Workflow/ExecutorIsh.cs | 44 ++++++++++--------- .../Specialized/AIAgentHostExecutor.cs | 31 +++++++++++++ 2 files changed, 55 insertions(+), 20 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Specialized/AIAgentHostExecutor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs index 30367ac709..28384a2cf8 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -3,6 +3,7 @@ using System; using Microsoft.Agents.Workflows.Core; using Microsoft.Agents.Workflows.Specialized; +using Microsoft.Extensions.AI.Agents; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; @@ -33,8 +34,10 @@ public enum Type /// . /// InputPort, - //Function, - //Agent, + /// + /// . + /// + Agent, //ProcessStep } @@ -46,7 +49,7 @@ public enum Type private readonly string? _idValue; private readonly Executor? _executorValue; private readonly InputPort? _inputPortValue; - //private readonly Func? _functionValue; + private readonly AIAgent? _aiAgentValue; /// /// . @@ -78,6 +81,16 @@ public ExecutorIsh(InputPort port) this._inputPortValue = Throw.IfNull(port); } + /// + /// . + /// + /// + public ExecutorIsh(AIAgent aiAgent) + { + this.ExecutorType = Type.Agent; + this._aiAgentValue = Throw.IfNull(aiAgent); + } + internal bool IsUnbound => this.ExecutorType == Type.Unbound; /// @@ -86,8 +99,7 @@ public ExecutorIsh(InputPort port) Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), Type.Executor => this._executorValue!.Id, Type.InputPort => this._inputPortValue!.Id, - //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), - //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), + Type.Agent => this._aiAgentValue!.Id, //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") }; @@ -100,18 +112,11 @@ public ExecutorIsh(InputPort port) Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), Type.Executor => () => this._executorValue!, Type.InputPort => () => new RequestInputExecutor(this._inputPortValue!), - //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), - //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), + Type.Agent => () => new AIAgentHostExecutor(this._aiAgentValue!), //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") }; - //public ExecutorIsh(Func function) - //{ - // this.ExecutorType = Type.Function; - // this._functionValue = Throw.IfNull(function); - //} - /// /// . /// @@ -124,11 +129,11 @@ public ExecutorIsh(InputPort port) /// public static implicit operator ExecutorIsh(InputPort inputPort) => new(inputPort); - // How do we AoT compile this? - //public static implicit operator ExecutorIsh(Func function) - //{ - // return new ExecutorIsh(function); - //} + /// + /// . + /// + /// + public static implicit operator ExecutorIsh(AIAgent aiAgent) => new(aiAgent); /// /// . @@ -198,8 +203,7 @@ public override string ToString() Type.Unbound => $"'{this.Id}':", Type.Executor => $"'{this.Id}':{this._executorValue!.GetType().Name}", Type.InputPort => $"'{this.Id}':Input({this._inputPortValue!.Request.Name}->{this._inputPortValue!.Response.Name})", - //Type.Function => $"ExecutorIsh for Function with ID '{this.Id}'", - //Type.Agent => $"ExecutorIsh for Agent with ID '{this.Id}'", + Type.Agent => $"{this.Id}':AIAgent(@{this._aiAgentValue!.GetType().Name})", //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", _ => $"'{this.Id}':" }; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/Specialized/AIAgentHostExecutor.cs new file mode 100644 index 0000000000..2f156d9191 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Specialized/AIAgentHostExecutor.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; + +namespace Microsoft.Agents.Workflows.Specialized; + +internal class AIAgentHostExecutor : Executor, IMessageHandler> +{ + private AIAgent Agent { get; set; } + + public AIAgentHostExecutor(AIAgent agent) + { + this.Agent = agent; + } + + public async ValueTask HandleAsync(IList message, IWorkflowContext context) + { + IReadOnlyCollection messageList = (message as List ?? message.ToList()).AsReadOnly(); + + // TODO: Ideally we want to be able to split the Run across multiple super-steps so that we can stream out + // incremental updates from the chat model. + AgentRunResponse runResponse = await this.Agent.RunAsync(messageList).ConfigureAwait(false); + + await context.SendMessageAsync(runResponse).ConfigureAwait(false); + } +} From 292892b0ea2b1f7c50102694772bb75bd383d338 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 20:28:33 -0400 Subject: [PATCH 025/232] fix: Fix routing to go through Executor.ExecuteAsync --- .../Core/Executor.cs | 7 ++- .../Core/ExternalResponse.cs | 3 - .../Execution/DirectEdgeRunner.cs | 17 +++--- .../Execution/EdgeMap.cs | 8 +-- .../Execution/FanInEdgeRunner.cs | 13 ++--- .../Execution/FanOutEdgeRunner.cs | 13 ++--- .../Execution/InputEdgeRunner.cs | 15 ++--- .../Execution/LocalRunner.cs | 17 +++--- .../Execution/LocalRunnerContext.cs | 2 +- .../Execution/StreamingExecutionHandle.cs | 2 +- .../Sample/01_Simple_Workflow_Sequential.cs | 57 +++++++++++++++++++ .../Sample/02_Simple_Workflow_Sequential.cs | 43 -------------- .../SampleSmokeTest.cs | 30 ++++++++++ 13 files changed, 131 insertions(+), 96 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/01_Simple_Workflow_Sequential.cs delete mode 100644 dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 5e43a5c98b..e3f5af19d9 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -80,7 +80,12 @@ internal MessageRouter Router CallResult? result = await this.Router.RouteMessageAsync(message, context, requireRoute: true) .ConfigureAwait(false); - await context.AddEventAsync(new ExecutorCompleteEvent(this.Id)).ConfigureAwait(false); + ExecutorCompleteEvent completeEvent = new(this.Id) + { + Data = result == null ? null : result.IsSuccess ? result.Result : result.Exception + }; + + await context.AddEventAsync(completeEvent).ConfigureAwait(false); if (result == null) { diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs index 2c6bd22782..00dde3bcbd 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs @@ -1,8 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. - -// Copyright (c) Microsoft. All rights reserved. - namespace Microsoft.Agents.Workflows.Core; /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs index 8908a6e3e6..df70b2b620 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs @@ -11,26 +11,23 @@ internal class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData edgeDa { public IWorkflowContext WorkflowContext { get; } = runContext.Bind(edgeData.SinkId); - private async ValueTask FindRouterAsync() + private async ValueTask FindRouterAsync() { - Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) - .ConfigureAwait(false); - - return sink.Router; + return await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); } - public async ValueTask> ChaseAsync(object message) + public async ValueTask> ChaseAsync(object message) { if (this.EdgeData.Condition != null && !this.EdgeData.Condition(message)) { return []; } - MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); - if (router.CanHandle(message)) + Executor target = await this.FindRouterAsync().ConfigureAwait(false); + if (target.CanHandle(message.GetType())) { - return [await router.RouteMessageAsync(message, this.WorkflowContext) - .ConfigureAwait(false)]; + return [await target.ExecuteAsync(message, this.WorkflowContext).ConfigureAwait(false)]; } return []; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs index f9ba08af00..34e13c898d 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs @@ -41,14 +41,14 @@ public EdgeMap(IRunnerContext runContext, this._inputRunner = new InputEdgeRunner(runContext, startExecutorId); } - public async ValueTask> InvokeEdgeAsync(Edge edge, string sourceId, object message) + public async ValueTask> InvokeEdgeAsync(Edge edge, string sourceId, object message) { if (!this._edgeRunners.TryGetValue(edge, out object? edgeRunner)) { throw new InvalidOperationException($"Edge {edge} not found in the edge map."); } - IEnumerable edgeResults; + IEnumerable edgeResults; switch (edge.EdgeType) { // We know the corresponding EdgeRunner type given the FlowEdge EdgeType, as @@ -88,12 +88,12 @@ public EdgeMap(IRunnerContext runContext, } // TODO: Should we promote Input to a true "FlowEdge" type? - public async ValueTask> InvokeInputAsync(object inputMessage) + public async ValueTask> InvokeInputAsync(object inputMessage) { return [await this._inputRunner.ChaseAsync(inputMessage).ConfigureAwait(false)]; } - public async ValueTask> InvokeResponseAsync(ExternalResponse response) + public async ValueTask> InvokeResponseAsync(ExternalResponse response) { if (!this._portEdgeRunners.TryGetValue(response.Port.Id, out InputEdgeRunner? portRunner)) { diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs index 3d8db74eca..9b790bd0e6 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs @@ -13,7 +13,7 @@ internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData public FanInEdgeState CreateState() => new(this.EdgeData); - public async ValueTask ChaseAsync(string sourceId, object message, FanInEdgeState state) + public async ValueTask ChaseAsync(string sourceId, object message, FanInEdgeState state) { IEnumerable? releasedMessages = state.ProcessMessage(sourceId, message); if (releasedMessages is null) @@ -22,14 +22,13 @@ internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData return null; } - Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) - .ConfigureAwait(false); + Executor target = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); - MessageRouter router = sink.Router; - if (router.CanHandle(message)) + if (target.CanHandle(message.GetType())) { - return await router.RouteMessageAsync(message, this.BoundContext) - .ConfigureAwait(false); + return await target.ExecuteAsync(message, this.BoundContext) + .ConfigureAwait(false); } return null; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs index 59afcd1ced..7a21accf8b 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs @@ -15,26 +15,25 @@ internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeDa sinkId => sinkId, sinkId => runContext.Bind(sinkId)); - public async ValueTask> ChaseAsync(object message) + public async ValueTask> ChaseAsync(object message) { List targets = this.EdgeData.Partitioner == null ? this.EdgeData.SinkIds : this.EdgeData.Partitioner(message, this.BoundContexts.Count).Select(i => this.EdgeData.SinkIds[i]).ToList(); - CallResult?[] result = await Task.WhenAll(targets.Select(ProcessTargetAsync)).ConfigureAwait(false); + object?[] result = await Task.WhenAll(targets.Select(ProcessTargetAsync)).ConfigureAwait(false); return result.Where(r => r is not null); - async Task ProcessTargetAsync(string targetId) + async Task ProcessTargetAsync(string targetId) { Executor executor = await this.RunContext.EnsureExecutorAsync(targetId) .ConfigureAwait(false); - MessageRouter router = executor.Router; - if (router.CanHandle(message)) + if (executor.CanHandle(message.GetType())) { - return await router.RouteMessageAsync(message, this.BoundContexts[targetId]) - .ConfigureAwait(false); + return await executor.ExecuteAsync(message, this.BoundContexts[targetId]) + .ConfigureAwait(false); } return null; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs index b0c45ba0f4..bfe002b9bd 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs @@ -19,20 +19,17 @@ public static InputEdgeRunner ForPort(IRunnerContext runContext, InputPort port) return new InputEdgeRunner(runContext, port.Id); } - private async ValueTask FindRouterAsync() + private async ValueTask FindExecutorAsync() { - Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData) - .ConfigureAwait(false); - - return sink.Router; + return await this.RunContext.EnsureExecutorAsync(this.EdgeData).ConfigureAwait(false); } - public async ValueTask ChaseAsync(object message) + public async ValueTask ChaseAsync(object message) { - MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); - if (router.CanHandle(message)) + Executor target = await this.FindExecutorAsync().ConfigureAwait(false); + if (target.CanHandle(message.GetType())) { - return await router.RouteMessageAsync(message, this.WorkflowContext) + return await target.ExecuteAsync(message, this.WorkflowContext) .ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index ed37852ccb..0a089619e6 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -59,18 +59,14 @@ private bool IsResponse(object message) return message is ExternalResponse; } - private ValueTask> RouteExternalMessageAsync(object message) + private ValueTask> RouteExternalMessageAsync(object message) { -#pragma warning disable CS0219 // Variable is assigned but its value is never used - bool isHil = false; -#pragma warning restore CS0219 // Variable is assigned but its value is never used - return message is ExternalResponse response ? this.CompleteExternalResponseAsync(response) : this.EdgeMap.InvokeInputAsync(message); } - private ValueTask> CompleteExternalResponseAsync(ExternalResponse response) + private ValueTask> CompleteExternalResponseAsync(ExternalResponse response) { if (!this.RunContext.CompleteRequest(response.RequestId)) { @@ -119,7 +115,7 @@ async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cance private async ValueTask RunSuperstepAsync(StepContext currentStep) { // Deliver the messages and queue the next step - List>> edgeTasks = new(); + List>> edgeTasks = new(); foreach (ExecutorIdentity sender in currentStep.QueuedMessages.Keys) { IEnumerable senderMessages = currentStep.QueuedMessages[sender]; @@ -127,9 +123,8 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) { edgeTasks.AddRange(senderMessages.Select(message => this.RouteExternalMessageAsync(message).AsTask())); } - else + else if (this.Workflow.Edges.TryGetValue(sender.Id!, out HashSet? outgoingEdges)) { - HashSet outgoingEdges = this.Workflow.Edges[sender.Id!]; // Id is not null when Identity is not .None foreach (Edge outgoingEdge in outgoingEdges) { edgeTasks.AddRange(senderMessages.Select(message => this.EdgeMap.InvokeEdgeAsync(outgoingEdge, sender.Id, message).AsTask())); @@ -140,7 +135,7 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) // TODO: Should we let the user specify that they want strictly turn-based execution of the edges, vs. concurrent? // (Simply substitute a strategy that replaces Task.WhenAll with a loop with an await in the middle. Difficulty is // that we would need to avoid firing the tasks when we call InvokeEdgeAsync, or RouteExternalMessageAsync. - IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); + IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); // TODO: Commit the state updates (so they are visible to the next step) @@ -149,6 +144,8 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) { this.RaiseWorkflowEvent(@event); } + + this.RunContext.QueuedEvents.Clear(); } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index fb14752d2d..ad6fafe741 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -70,7 +70,7 @@ public ValueTask AddEventAsync(WorkflowEvent workflowEvent) public ValueTask SendMessageAsync(string executorId, object message) { - this._nextStep.MessagesFor(message.GetType().Name).Add(message); + this._nextStep.MessagesFor(executorId).Add(message); return default; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs index eba2f960a1..1fee2c7695 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs @@ -39,7 +39,7 @@ public ValueTask SendResponseAsync(ExternalResponse response) /// /// /// - public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancellation] CancellationToken cancellation) + public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancellation] CancellationToken cancellation = default) { List eventSink = new(); diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/01_Simple_Workflow_Sequential.cs new file mode 100644 index 0000000000..5af83b752a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/01_Simple_Workflow_Sequential.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.IO; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; + +namespace Microsoft.Agents.Workflows.Sample; + +internal static class Step1EntryPoint +{ + public static async ValueTask RunAsync(TextWriter writer) + { + UppercaseExecutor uppercase = new(); + ReverseTextExecutor reverse = new(); + + WorkflowBuilder builder = new(uppercase); + builder.AddEdge(uppercase, reverse); + + Workflow workflow = builder.Build(); + LocalRunner runner = new(workflow); + + var handle = await runner.StreamAsync("Hello, World!").ConfigureAwait(false); + + await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + { + if (evt is ExecutorCompleteEvent executorComplete) + { + writer.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}"); + } + } + } +} + +internal sealed class UppercaseExecutor() : Executor("UppercaseExecutor"), IMessageHandler +{ + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + string result = message.ToUpperInvariant(); + + await context.SendMessageAsync(result).ConfigureAwait(false); + return result; + } +} + +internal sealed class ReverseTextExecutor() : Executor("ReverseTextExecutor"), IMessageHandler +{ + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + char[] charArray = message.ToCharArray(); + System.Array.Reverse(charArray); + string result = new(charArray); + + await context.SendMessageAsync(result).ConfigureAwait(false); + return result; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs deleted file mode 100644 index c731a2c3f1..0000000000 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Core; -using Microsoft.Agents.Workflows.Execution; - -namespace Microsoft.Agents.Workflows.Sample; - -internal static class Step2EntryPoint -{ - public static async ValueTask RunAsync() - { - UppercaseExecutor uppercase = new(); - ReverseTextExecutor reverse = new(); - - WorkflowBuilder builder = new(uppercase); - builder.AddEdge(uppercase, reverse); - - Workflow workflow = builder.Build(); - LocalRunner runner = new(workflow); - - var handle = await runner.StreamAsync("Hello, World!").ConfigureAwait(false); - await handle.RunToCompletionAsync().ConfigureAwait(false); - } -} - -internal sealed class UppercaseExecutor : Executor, IMessageHandler -{ - public ValueTask HandleAsync(string message, IWorkflowContext context) - { - return new ValueTask(message.ToUpperInvariant()); - } -} - -internal sealed class ReverseTextExecutor : Executor, IMessageHandler -{ - public ValueTask HandleAsync(string message, IWorkflowContext context) - { - char[] charArray = message.ToCharArray(); - System.Array.Reverse(charArray); - return new ValueTask(new string(charArray)); - } -} diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs new file mode 100644 index 0000000000..7fdee9601c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Sample; + +namespace Microsoft.Agents.Workflow.UnitTests; + +public class SampleSmokeTest +{ + [Fact] + public async Task Test_RunSample_Step1Async() + { + using StringWriter writer = new(); + + await Step1EntryPoint.RunAsync(writer); + + string result = writer.ToString(); + string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); + + const string INPUT = "Hello, World!"; + + Assert.Collection(lines, + line => Assert.Contains($"UppercaseExecutor: {INPUT.ToUpperInvariant()}", line), + line => Assert.Contains($"ReverseTextExecutor: {new string(INPUT.ToUpperInvariant().Reverse().ToArray())}", line) + ); + } +} From 5973dec86824266401a03fa98d58cbf7cda90ae5 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 21:31:25 -0400 Subject: [PATCH 026/232] test: Update samples for "must SendMessage" semantics * Add invoking samples to unit tests to avoid future breaks --- ...ion.cs => 02_Simple_Workflow_Condition.cs} | 41 ++++++++++++++----- ...low_Loop.cs => 03_Simple_Workflow_Loop.cs} | 40 ++++++++++++++---- .../SampleSmokeTest.cs | 24 +++++++++++ 3 files changed, 87 insertions(+), 18 deletions(-) rename dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/{02a_Simple_Workflow_Condition.cs => 02_Simple_Workflow_Condition.cs} (63%) rename dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/{02b_Simple_Workflow_Loop.cs => 03_Simple_Workflow_Loop.cs} (61%) diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Condition.cs similarity index 63% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs rename to dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Condition.cs index 4040452540..c6f52d7166 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Condition.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.IO; using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; @@ -8,9 +9,9 @@ namespace Microsoft.Agents.Workflows.Sample; -internal static class Step2aEntryPoint +internal static class Step2EntryPoint { - public static async ValueTask RunAsync() + public static async ValueTask RunAsync(TextWriter writer, string input = "This is a spam message.") { string[] spamKeywords = { "spam", "advertisement", "offer" }; @@ -19,14 +20,29 @@ public static async ValueTask RunAsync() RemoveSpamExecutor removeSpam = new(); Workflow workflow = new WorkflowBuilder(detectSpam) - .AddEdge(detectSpam, respondToMessage, isSpam => isSpam is true) // If not spam, respond - .AddEdge(detectSpam, removeSpam, isSpam => isSpam is false) // If spam, remove + .AddEdge(detectSpam, respondToMessage, isSpam => isSpam is false) // If not spam, respond + .AddEdge(detectSpam, removeSpam, isSpam => isSpam is true) // If spam, remove .Build(); LocalRunner runner = new(workflow); - StreamingExecutionHandle handle = await runner.StreamAsync("This is a spam message.").ConfigureAwait(false); - await handle.RunToCompletionAsync().ConfigureAwait(false); + StreamingExecutionHandle handle = await runner.StreamAsync(input).ConfigureAwait(false); + await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + { + switch (evt) + { + case WorkflowCompletedEvent workflowCompleteEvt: + // The workflow has completed successfully, return the result + string workflowResult = workflowCompleteEvt.Data!.ToString()!; + writer.WriteLine($"Result: {workflowResult}"); + return workflowResult; + case ExecutorCompleteEvent executorCompleteEvt: + writer.WriteLine($"'{executorCompleteEvt.ExecutorId}: {executorCompleteEvt.Data}"); + break; + } + } + + throw new InvalidOperationException("Workflow failed to yield the completion event."); } } @@ -39,7 +55,7 @@ public DetectSpamExecutor(params string[] spamKeywords) this.SpamKeywords = spamKeywords; } - public ValueTask HandleAsync(string message, IWorkflowContext context) + public async ValueTask HandleAsync(string message, IWorkflowContext context) { #if NET5_0_OR_GREATER bool isSpam = this.SpamKeywords.Any(keyword => message.Contains(keyword, StringComparison.OrdinalIgnoreCase)); @@ -47,12 +63,15 @@ public ValueTask HandleAsync(string message, IWorkflowContext context) bool isSpam = this.SpamKeywords.Any(keyword => message.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0); #endif - return new ValueTask(isSpam); + await context.SendMessageAsync(isSpam).ConfigureAwait(false); + return isSpam; } } internal sealed class RespondToMessageExecutor : Executor, IMessageHandler { + public const string ActionResult = "Message processed successfully."; + public async ValueTask HandleAsync(bool message, IWorkflowContext context) { if (message) @@ -63,13 +82,15 @@ public async ValueTask HandleAsync(bool message, IWorkflowContext context) await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay - await context.AddEventAsync(new WorkflowCompletedEvent { Data = "Message processed successfully." }) + await context.AddEventAsync(new WorkflowCompletedEvent { Data = RespondToMessageExecutor.ActionResult }) .ConfigureAwait(false); } } internal sealed class RemoveSpamExecutor : Executor, IMessageHandler { + public const string ActionResult = "Spam message removed."; + public async ValueTask HandleAsync(bool message, IWorkflowContext context) { if (!message) @@ -80,7 +101,7 @@ public async ValueTask HandleAsync(bool message, IWorkflowContext context) await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay - await context.AddEventAsync(new WorkflowCompletedEvent { Data = "Spam message removed." }) + await context.AddEventAsync(new WorkflowCompletedEvent { Data = RemoveSpamExecutor.ActionResult }) .ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/03_Simple_Workflow_Loop.cs similarity index 61% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs rename to dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/03_Simple_Workflow_Loop.cs index df69c47167..ead24833e4 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/03_Simple_Workflow_Loop.cs @@ -1,14 +1,16 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.IO; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; using Microsoft.Agents.Workflows.Execution; namespace Microsoft.Agents.Workflows.Sample; -internal static class Step2bEntryPoint +internal static class Step3EntryPoint { - public static async ValueTask RunAsync() + public static async ValueTask RunAsync(TextWriter writer) { GuessNumberExecutor guessNumber = new(1, 100); JudgeExecutor judge = new(42); // Let's say the target number is 42 @@ -20,7 +22,23 @@ public static async ValueTask RunAsync() LocalRunner runner = new(workflow); StreamingExecutionHandle handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); - await handle.RunToCompletionAsync(); + + await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + { + switch (evt) + { + case WorkflowCompletedEvent workflowCompleteEvt: + // The workflow has completed successfully, return the result + string workflowResult = workflowCompleteEvt.Data!.ToString()!; + writer.WriteLine($"Result: {workflowResult}"); + return workflowResult; + case ExecutorCompleteEvent executorCompleteEvt: + writer.WriteLine($"'{executorCompleteEvt.ExecutorId}: {executorCompleteEvt.Data}"); + break; + } + } + + throw new InvalidOperationException("Workflow failed to yield the completion event."); } } @@ -63,7 +81,9 @@ await context.AddEventAsync(new WorkflowCompletedEvent { Data = $"Guessed the nu break; } - return this._currGuess = this.NextGuess; + this._currGuess = this.NextGuess; + await context.SendMessageAsync(this._currGuess).ConfigureAwait(false); + return this._currGuess; } } @@ -76,19 +96,23 @@ public JudgeExecutor(int targetNumber) this._targetNumber = targetNumber; } - public ValueTask HandleAsync(int message, IWorkflowContext context) + public async ValueTask HandleAsync(int message, IWorkflowContext context) { + NumberSignal result; if (message == this._targetNumber) { - return new ValueTask(NumberSignal.Matched); + result = NumberSignal.Matched; } else if (message < this._targetNumber) { - return new ValueTask(NumberSignal.Below); + result = NumberSignal.Below; } else { - return new ValueTask(NumberSignal.Above); + result = NumberSignal.Above; } + + await context.SendMessageAsync(result).ConfigureAwait(false); + return result; } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs index 7fdee9601c..1acf4aa1d8 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs @@ -27,4 +27,28 @@ public async Task Test_RunSample_Step1Async() line => Assert.Contains($"ReverseTextExecutor: {new string(INPUT.ToUpperInvariant().Reverse().ToArray())}", line) ); } + + [Fact] + public async Task Test_RunSample_Step2Async() + { + using StringWriter writer = new(); + + string spamResult = await Step2EntryPoint.RunAsync(writer); + + Assert.Equal(RemoveSpamExecutor.ActionResult, spamResult); + + string nonSpamResult = await Step2EntryPoint.RunAsync(writer, "This is a valid message."); + + Assert.Equal(RespondToMessageExecutor.ActionResult, nonSpamResult); + } + + [Fact] + public async Task Test_RunSample_Step3Async() + { + using StringWriter writer = new(); + + string guessResult = await Step3EntryPoint.RunAsync(writer); + + Assert.Equal("Guessed the number: 42", guessResult); + } } From 461a132c046828b56d652df42f836f51803db582 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 12:02:13 -0400 Subject: [PATCH 027/232] fix: ExternalRequest should block Workflow completion --- .../Microsoft.Agents.Workflow/Core/Events.cs | 2 +- .../Core/Executor.cs | 2 +- .../Core/ExternalRequest.cs | 3 +- .../Core/RouteBuilder.cs | 40 ++++++++++ .../Core/Workflow.cs | 3 +- .../Execution/EdgeMap.cs | 2 +- .../Execution/ISuperStepRunner.cs | 3 + .../Execution/LocalRunner.cs | 22 +++--- .../Execution/StreamingExecutionHandle.cs | 25 +++++- .../Microsoft.Agents.Workflow/ExecutorIsh.cs | 2 +- .../Specialized/OutputCollectorExecutor.cs | 15 +++- .../Specialized/RequestInputExecutor.cs | 25 ++++-- .../StreamingAggregators.cs | 9 ++- .../WorkflowBuilder.cs | 10 ++- .../WorkflowBuilderExtensions.cs | 22 ++---- .../05_Simple_Workflow_ExternalRequest.cs | 76 +++++++++++++++++++ .../SampleSmokeTest.cs | 38 ++++++++++ 17 files changed, 250 insertions(+), 49 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs index 0a06cf1bb0..63013e03a3 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs @@ -22,7 +22,7 @@ public record WorkflowCompletedEvent : WorkflowEvent; /// /// . /// -public record RequestInputEvent(ExternalRequest request) : WorkflowEvent; +public record RequestInputEvent(ExternalRequest Request) : WorkflowEvent; /// /// . diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index e3f5af19d9..4312f2a7cf 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -78,7 +78,7 @@ internal MessageRouter Router await context.AddEventAsync(new ExecutorInvokeEvent(this.Id)).ConfigureAwait(false); CallResult? result = await this.Router.RouteMessageAsync(message, context, requireRoute: true) - .ConfigureAwait(false); + .ConfigureAwait(false); ExecutorCompleteEvent completeEvent = new(this.Id) { diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs index c7ab3b2d5e..e06cdfaf02 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs @@ -66,8 +66,7 @@ public ExternalResponse CreateResponse(object data) /// . /// /// - /// /// /// - public ExternalResponse CreateResponse(InputPort port, T data) => this.CreateResponse(port, (object)Throw.IfNull(data)); + public ExternalResponse CreateResponse(T data) => this.CreateResponse((object)Throw.IfNull(data)); } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs index e480aeb97d..83eb64020a 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs @@ -44,6 +44,46 @@ internal RouteBuilder AddHandler(Type messageType, MessageHandlerF handler, bool return this; } + /// + /// . + /// + /// + /// + /// + /// + internal RouteBuilder AddHandler(Type type, Func handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddHandler(type, WrappedHandlerAsync, overwrite); + + async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) + { + await handler.Invoke(msg, ctx).ConfigureAwait(false); + return CallResult.ReturnVoid(); + } + } + + /// + /// . + /// + /// + /// + /// + /// + internal RouteBuilder AddHandler(Type type, Func> handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddHandler(type, WrappedHandlerAsync, overwrite); + + async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) + { + TResult result = await handler.Invoke(msg, ctx).ConfigureAwait(false); + return CallResult.ReturnResult(result); + } + } + /// /// . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs index b12ff25113..d26ee4f12f 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs @@ -25,7 +25,7 @@ public class Workflow /// /// . /// - public Dictionary Ports { get; } = new(); + public Dictionary Ports { get; internal init; } = new(); /// /// . @@ -68,6 +68,7 @@ internal Workflow Promote(OutputSink outputSource) { ExecutorProviders = this.ExecutorProviders, Edges = this.Edges, + Ports = this.Ports }; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs index 34e13c898d..80c3daab19 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs @@ -100,6 +100,6 @@ public EdgeMap(IRunnerContext runContext, throw new InvalidOperationException($"Port {response.Port.Id} not found in the edge map."); } - return [await portRunner.ChaseAsync(response.Data).ConfigureAwait(false)]; + return [await portRunner.ChaseAsync(response).ConfigureAwait(false)]; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs index f2c6b5f929..073d8d398c 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs @@ -9,6 +9,9 @@ namespace Microsoft.Agents.Workflows.Execution; internal interface ISuperStepRunner { + bool HasUnservicedRequests { get; } + bool HasUnprocessedMessages { get; } + ValueTask EnqueueMessageAsync(object message); event EventHandler? WorkflowEvent; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index 0a089619e6..54158616dc 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -89,23 +89,19 @@ public async ValueTask StreamAsync(TInput input, Cance return new StreamingExecutionHandle(this); } - private StepContext? _currentStep = null; + bool ISuperStepRunner.HasUnservicedRequests => this.RunContext.HasUnservicedRequests; + bool ISuperStepRunner.HasUnprocessedMessages => this.RunContext.NextStepHasActions; + + //private StepContext? _currentStep = null; async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cancellation) { cancellation.ThrowIfCancellationRequested(); - if (this._currentStep == null) - { - // TODO: Python-side does not raise this event. - // await this.RunContext.AddEventAsync(this.Workflow.StartExecutorId, new WorkflowStartedEvent()).ConfigureAwait(false); - this._currentStep = this.RunContext.Advance(); - } + StepContext currentStep = this.RunContext.Advance(); - if (this._currentStep.HasMessages) + if (currentStep.HasMessages) { - await this.RunSuperstepAsync(this._currentStep).ConfigureAwait(false); - this._currentStep = this.RunContext.Advance(); - + await this.RunSuperstepAsync(currentStep).ConfigureAwait(false); return true; } @@ -175,11 +171,11 @@ public LocalRunner(Workflow workflow) /// /// /// - public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) + public async ValueTask> StreamAsync(TInput input, CancellationToken cancellation = default) { await this._innerRunner.EnqueueMessageAsync(input).ConfigureAwait(false); - return new StreamingExecutionHandle(this._innerRunner); + return new StreamingExecutionHandle(this); } /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs index 1fee2c7695..47d5b24ecd 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs @@ -15,6 +15,7 @@ namespace Microsoft.Agents.Workflows.Execution; /// public class StreamingExecutionHandle { + private TaskCompletionSource? _waitForResponseSource = null; private readonly ISuperStepRunner _stepRunner; internal StreamingExecutionHandle(ISuperStepRunner stepRunner) @@ -30,6 +31,8 @@ internal StreamingExecutionHandle(ISuperStepRunner stepRunner) /// public ValueTask SendResponseAsync(ExternalResponse response) { + this._waitForResponseSource?.TrySetResult(new()); + return this._stepRunner.EnqueueMessageAsync(response); } @@ -47,8 +50,11 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell try { - while (await this._stepRunner.RunSuperStepAsync(cancellation).ConfigureAwait(false)) + do { + // Drain SuperSteps while there are steps to run + await this._stepRunner.RunSuperStepAsync(cancellation).ConfigureAwait(false); + bool hadCompletionEvent = false; List outputEvents = Interlocked.Exchange(ref eventSink, new()); foreach (WorkflowEvent raisedEvent in outputEvents) @@ -67,7 +73,22 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell // If we had a completion event, we are done. yield break; } - } + + // If we do not have any actions to take on the Workflow, but have unprocessed + // requests, wait for the responses to come in before exiting out of the workflow + // execution. + if (!this._stepRunner.HasUnprocessedMessages && + this._stepRunner.HasUnservicedRequests) + { + if (this._waitForResponseSource == null) + { + this._waitForResponseSource = new(); + } + + await this._waitForResponseSource.Task.ConfigureAwait(false); + this._waitForResponseSource = null; + } + } while (this._stepRunner.HasUnprocessedMessages); } finally { diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs index 28384a2cf8..1a1bfb193e 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -48,7 +48,7 @@ public enum Type private readonly string? _idValue; private readonly Executor? _executorValue; - private readonly InputPort? _inputPortValue; + internal readonly InputPort? _inputPortValue; private readonly AIAgent? _aiAgentValue; /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs index f3658fb479..a1a6099572 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; @@ -17,14 +18,24 @@ internal OutputSink(string? id = null) : base(id) internal class OutputCollectorExecutor : OutputSink, IMessageHandler { private readonly StreamingAggregator _aggregator; - public OutputCollectorExecutor(StreamingAggregator aggregator, string? id = null) : base(id) + private readonly Func? _completionCondition; + + public OutputCollectorExecutor(StreamingAggregator aggregator, Func? completionCondition = null, string? id = null) : base(id) { this._aggregator = Throw.IfNull(aggregator); + this._completionCondition = completionCondition; } public ValueTask HandleAsync(TInput message, IWorkflowContext context) { - this.Result = this._aggregator(message); + this.Result = this._aggregator(message, this.Result); + + if (this._completionCondition is not null && + this._completionCondition!(message, this.Result)) + { + return context.AddEventAsync(new WorkflowCompletedEvent() { Data = this.Result }); + } + return default; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs index be460d2955..9cd3a8e2c9 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.Workflows.Specialized; -internal class RequestInputExecutor : Executor, IMessageHandler, IMessageHandler +internal class RequestInputExecutor : Executor, IMessageHandler, IMessageHandler { private InputPort Port { get; } private IExternalRequestSink? RequestSink { get; set; } @@ -18,19 +18,32 @@ public RequestInputExecutor(InputPort port) : base(port.Id) this.Port = port; } + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + return routeBuilder + // Handle incoming requests (as raw request payloads) + .AddHandler(this.Port.Request, this.HandleAsync) + .AddHandler(typeof(object), this.HandleAsync) + // Handle incoming responses (as wrapped Response object) + .AddHandler(this.HandleAsync); + } + internal void AttachRequestSink(IExternalRequestSink requestSink) { this.RequestSink = Throw.IfNull(requestSink); } - public ValueTask HandleAsync(object message, IWorkflowContext context) + public async ValueTask HandleAsync(object message, IWorkflowContext context) { Throw.IfNull(message); - return this.RequestSink!.PostAsync(ExternalRequest.Create(this.Port, message)); + ExternalRequest request = ExternalRequest.Create(this.Port, message); + await this.RequestSink!.PostAsync(request).ConfigureAwait(false); + + return request; } - public ValueTask HandleAsync(ExternalResponse message, IWorkflowContext context) + public async ValueTask HandleAsync(ExternalResponse message, IWorkflowContext context) { Throw.IfNull(message); Throw.IfNull(message.Data); @@ -41,6 +54,8 @@ public ValueTask HandleAsync(ExternalResponse message, IWorkflowContext context) $"Message type {message.Data.GetType().Name} is not assignable to the response type {this.Port.Response.Name} of input port {this.Port.Id}."); } - return context.SendMessageAsync(message.Data); + await context.SendMessageAsync(message.Data).ConfigureAwait(false); + + return message; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs b/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs index 942eb81ab6..b90048a43a 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs @@ -11,8 +11,9 @@ namespace Microsoft.Agents.Workflows; /// /// /// +/// /// -public delegate TResult? StreamingAggregator(TInput input); +public delegate TResult? StreamingAggregator(TInput input, TResult? runningResult); /// /// . @@ -34,7 +35,7 @@ public static StreamingAggregator First(Func Last(Func> Union Aggregate(TInput input) + IEnumerable Aggregate(TInput input, IEnumerable? runningResult) { results.Add(conversion(input)); return results; diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index d92bef1c8a..cb96b72c78 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -37,6 +37,7 @@ private record struct EdgeId(string SourceId, string TargetId) private readonly Dictionary> _edges = new(); private readonly HashSet _unboundExecutors = new(); private readonly HashSet _conditionlessEdges = new(); + private readonly Dictionary _inputPorts = new(); private readonly string _startExecutorId; @@ -67,6 +68,12 @@ private ExecutorIsh Track(ExecutorIsh executorish) this._executors[executorish.Id] = provider; } + if (executorish.ExecutorType == ExecutorIsh.Type.InputPort) + { + InputPort port = executorish._inputPortValue!; + this._inputPorts[port.Id] = port; + } + return executorish; } @@ -217,7 +224,8 @@ public Workflow Build() return new Workflow(this._startExecutorId) // Why does it not see the default ctor? { ExecutorProviders = this._executors, - Edges = this._edges + Edges = this._edges, + Ports = this._inputPorts }; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index 7643d05078..69894710fa 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -74,26 +74,18 @@ public static WorkflowBuilder AddExternalCall(this Workflow /// /// /// + /// /// - public static Workflow BuildWithOutput(this WorkflowBuilder builder, ExecutorIsh outputSource, StreamingAggregator aggregator) - => builder.BuildWithOutput(outputSource, aggregator); - - /// - /// . - /// - /// - /// - /// - /// - /// - /// - /// - public static Workflow BuildWithOutput(this WorkflowBuilder builder, ExecutorIsh outputSource, StreamingAggregator aggregator) + public static Workflow BuildWithOutput( + this WorkflowBuilder builder, + ExecutorIsh outputSource, + StreamingAggregator aggregator, + Func? completionCondition = null) { Throw.IfNull(outputSource); Throw.IfNull(aggregator); - OutputCollectorExecutor outputSink = new(aggregator); + OutputCollectorExecutor outputSink = new(aggregator, completionCondition); // TODO: Check taht the outputSource has a TResult output? builder.AddEdge(outputSource, outputSink); diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs new file mode 100644 index 0000000000..ca2de73632 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; +using Microsoft.Agents.Workflows.Sample; + +namespace Microsoft.Agents.Workflow.UnitTests.Sample; + +internal static class Step5EntryPoint +{ + public static async ValueTask RunAsync(TextWriter writer, Func userGuessCallback) + { + InputPort guessNumber = new("GuessNumber", typeof(NumberSignal), typeof(int)); + JudgeExecutor judge = new(42); // Let's say the target number is 42 + + Workflow workflow = new WorkflowBuilder(guessNumber) + .AddEdge(guessNumber, judge) + .AddEdge(judge, guessNumber, (message) => message is NumberSignal signal && signal != NumberSignal.Matched) + .BuildWithOutput(judge, ComputeStreamingOutput, (NumberSignal s, string? _) => s == NumberSignal.Matched); + + LocalRunner runner = new(workflow); + StreamingExecutionHandle handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); + + await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + { + switch (evt) + { + case RequestInputEvent requestInputEvt: + ExternalResponse response = ExecuteExternalRequest(requestInputEvt.Request, userGuessCallback, workflow.RunningOutput); + await handle.SendResponseAsync(response).ConfigureAwait(false); + break; + + case WorkflowCompletedEvent workflowCompleteEvt: + // The workflow has completed successfully, return the result + string workflowResult = workflowCompleteEvt.Data!.ToString()!; + writer.WriteLine($"Result: {workflowResult}"); + return workflowResult; + case ExecutorCompleteEvent executorCompleteEvt: + writer.WriteLine($"'{executorCompleteEvt.ExecutorId}: {executorCompleteEvt.Data}"); + break; + } + } + + throw new InvalidOperationException("Workflow failed to yield the completion event."); + } + + private static ExternalResponse ExecuteExternalRequest( + ExternalRequest request, + Func userGuessCallback, + string? runningState) + { + object result = request.Port.Id switch + { + "GuessNumber" => userGuessCallback(runningState ?? "Guess the number."), + _ => throw new NotSupportedException($"Request {request.Port.Id} is not supported") + }; + + return request.CreateResponse(result); + } + + private static string ComputeStreamingOutput(NumberSignal signal, string? runningResult) + { + return signal switch + { + NumberSignal.Matched => "You guessed correctly! You Win!", + NumberSignal.Above => "Your guess was too high. Try again.", + NumberSignal.Below => "Your guess was too low. Try again.", + + _ => runningResult ?? string.Empty + }; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs index 1acf4aa1d8..7ec53c5756 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using Microsoft.Agents.Workflow.UnitTests.Sample; using Microsoft.Agents.Workflows.Sample; namespace Microsoft.Agents.Workflow.UnitTests; @@ -51,4 +52,41 @@ public async Task Test_RunSample_Step3Async() Assert.Equal("Guessed the number: 42", guessResult); } + + [Fact] + public async Task Test_RunSample_Step5Async() + { + using StringWriter writer = new(); + + VerifyingPlaybackResponder responder = new( + ("Guess the number.", 50), + ("Your guess was too high. Try again.", 23), + ("Your guess was too low. Try again.", 42)); + + string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext); + Assert.Equal("You guessed correctly! You Win!", guessResult); + } +} + +internal sealed class VerifyingPlaybackResponder +{ + public (TInput input, TResponse response)[] Responses { get; } + private int _position = 0; + + public VerifyingPlaybackResponder(params (TInput input, TResponse response)[] responses) + { + this.Responses = responses; + } + + public int Remaining => Math.Max(0, this.Responses.Length - this._position); + + public TResponse InvokeNext(TInput input) + { + Assert.True(this.Remaining > 0); + + (TInput expectedInput, TResponse expectedResponse) = this.Responses[this._position++]; + Assert.Equal(expectedInput, input); + + return expectedResponse; + } } From 6164ba91797d6fe9c7c67740bb454f60f1affba2 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 13:54:53 -0400 Subject: [PATCH 028/232] feat: Normalize API surface against Python * Also adds xmldoc to all public APIs --- .../Microsoft.Agents.Workflow/Core/Edge.cs | 64 +++++---- .../Microsoft.Agents.Workflow/Core/Events.cs | 32 +++-- .../Core/Executor.cs | 125 +++--------------- .../Core/ExecutorCapabilities.cs | 69 ---------- .../Core/ExternalRequest.cs | 48 +++---- .../Core/ExternalResponse.cs | 8 +- .../Core/InputPort.cs | 14 +- .../Core/RouteBuilder.cs | 52 ++++---- .../Core/Workflow.cs | 40 +++--- .../Execution/IRunnerWithOutput.cs | 10 ++ .../Execution/IRunnerWithResult.cs | 13 -- .../Execution/LocalRunner.cs | 74 ++++++----- .../Execution/LocalRunnerContext.cs | 2 - .../Execution/StreamingExecutionHandle.cs | 66 +++++---- .../Microsoft.Agents.Workflow/ExecutorIsh.cs | 52 ++++---- .../StreamingAggregators.cs | 91 ++++++++----- .../WorkflowBuilder.cs | 75 +++++++---- .../WorkflowBuilderExtensions.cs | 60 ++++++--- 18 files changed, 418 insertions(+), 477 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithOutput.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs index de2dc0ed44..0769d0012e 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs @@ -10,32 +10,34 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// Represents a directed edge between two nodes, optionally associated with a condition that determines whether the +/// edge is active. /// -/// -/// -/// +/// The id of the source executor node. +/// The id of the target executor node. +/// A predicate determining whether the edge is active for a given message. public record DirectEdgeData( string SourceId, string SinkId, PredicateT? Condition = null) { /// - /// . + /// Converts a instance to an using an implicit conversion. /// - /// + /// The to convert to an . Cannot be null. public static implicit operator Edge(DirectEdgeData data) { - return new Edge(data); + return new Edge(Throw.IfNull(data)); } } /// -/// . +/// Represents a connection from a single node to a set of nodes, optionally associated with a paritition selector +/// function which maps incoming messages to a subset of the target set. /// -/// -/// -/// +/// The id of the source executor node. +/// A list of ids of the target executor nodes. +/// A function that maps an incoming message to a subset of the target executor nodes. public record FanOutEdgeData( string SourceId, List SinkIds, @@ -52,26 +54,29 @@ public static implicit operator Edge(FanOutEdgeData data) } /// -/// . +/// Specifies the condition under which a fan-in operation is triggered in a workflow. +/// Use to trigger the operation when all incoming edges have data, or +/// to trigger when any incoming edge has data. /// public enum FanInTrigger { /// - /// . + /// Trigger when all incoming edges have data. /// WhenAll, /// - /// . + /// Trigger when any incoming edge has data. /// WhenAny } /// -/// . +/// Represents a connection from a set of nodes to a single node. It can trigger either when all edges have data +/// or when any of them have data. /// -/// -/// -/// +/// An enumeration of ids of the source executor nodes. +/// The id of the target executor node. +/// The that determines when the fan-in edge is activated. public record FanInEdgeData( IEnumerable SourceIds, string SinkId, @@ -90,37 +95,46 @@ public static implicit operator Edge(FanInEdgeData data) } /// -/// . +/// Represents a connection or relationship between nodes, characterized by its type and associated data. /// +/// +/// An can be of type , , or , as specified by the property. The property holds +/// additional information relevant to the edge, and its concrete type depends on the value of , functioning as a tagged union. +/// public class Edge { /// - /// . + /// Specified the edge type. /// public enum Type { /// - /// . + /// A direct connection from one node to another. /// Direct, /// - /// . + /// A connection from one node to a set of nodes. /// FanOut, /// - /// . + /// A connection from a set of nodes to a single node. /// FanIn } /// - /// . + /// Specifies the type of the edge, which determines how the edge is processed in the workflow. /// public Type EdgeType { get; init; } /// - /// . + /// The -dependent edge data. /// + /// + /// + /// public object Data { get; init; } internal Edge(DirectEdgeData data) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs index 63013e03a3..1bbf5150a5 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs @@ -5,27 +5,31 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// Base class for -scoped events. /// public record WorkflowEvent(object? Data = null); /// -/// . +/// Event triggered when a workflow starts execution. /// public record WorkflowStartedEvent : WorkflowEvent; /// -/// . +/// Event triggered when a workflow completes execution. /// +/// +/// The user is expected to raise this event from a terminating , or to build +/// the workflow with output capture using . +/// public record WorkflowCompletedEvent : WorkflowEvent; /// -/// . +/// Event triggered when a workflow executor request external information. /// public record RequestInputEvent(ExternalRequest Request) : WorkflowEvent; /// -/// . +/// Base class for -scoped events. /// public record ExecutorEvent : WorkflowEvent { @@ -35,8 +39,11 @@ public record ExecutorEvent : WorkflowEvent public string ExecutorId { get; } /// - /// . + /// Initializes a new instance of the class with the specified executor identifier and + /// optional event data. /// + /// The unique identifier of the executor associated with this event. Cannot be null. + /// Optional event data to associate with the event. May be null if no additional data is required. public ExecutorEvent(string executorId, object? data = null) : base(data) { this.ExecutorId = Throw.IfNull(executorId); @@ -44,12 +51,12 @@ public ExecutorEvent(string executorId, object? data = null) : base(data) } /// -/// . +/// Event triggered when an executor handler is invoked. /// public record ExecutorInvokeEvent : ExecutorEvent { /// - /// . + /// Initializes a new instance of the class. /// public ExecutorInvokeEvent(string executorId, object? data = null) : base(executorId, data) { @@ -57,14 +64,17 @@ public ExecutorInvokeEvent(string executorId, object? data = null) : base(execut } /// -/// . +/// Event triggered when an executor handler has completed. /// public record ExecutorCompleteEvent : ExecutorEvent { /// - /// . + /// Initializes a new instance of the class to signal that an executor has + /// completed its operation. /// - public ExecutorCompleteEvent(string executorId, object? data = null) : base(executorId, data) { } + /// The unique identifier of the executor that has completed. Cannot be null or empty. + /// The result produced by the executor upon completion, or null if no result is available. + public ExecutorCompleteEvent(string executorId, object? result = null) : base(executorId, result) { } } // TODO: This is a placeholder for streaming chat message content. diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 4312f2a7cf..8081bb9af3 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -2,49 +2,39 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Diagnostics; using System.Reflection; using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// A component that processes messages in a . /// -[DebuggerDisplay("{GetType().Name}{Id}({Name})")] +[DebuggerDisplay("{GetType().Name}{Id}")] public abstract class Executor : IIdentified, IAsyncDisposable { /// - /// . + /// A unique identifier for the executor. /// public string Id { get; } - /// - /// . - /// - public string Name { get; } - private Dictionary State { get; } = new(); /// - /// . + /// Initialize the executor with a unique identifier /// - /// - /// - protected Executor(string? id = null, string? name = null) + /// A optional unique identifier for the executor. If null, a type-tagged + /// UUID will be generated. + protected Executor(string? id = null) { - this.Name = name ?? this.GetType().Name; - this.Id = id ?? $"{this.Name}{Guid.NewGuid():N}"; + this.Id = id ?? $"{this.GetType().Name}/{Guid.NewGuid():N}"; } /// /// Override this method to register handlers for the executor. The deafult implementation uses reflection to /// look for implementations of and . /// - /// - /// protected virtual RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) { return routeBuilder.ReflectHandlers(this); @@ -66,13 +56,13 @@ internal MessageRouter Router } /// - /// . + /// Process an incoming message using the registered handlers. /// - /// - /// - /// - /// - /// + /// The message to be processed by the executor. + /// The workflow context in which the executor executes. + /// A ValueTask representing the asynchronous operation, wrapping the output from the executor. + /// No handler found for the message type. + /// An exception is generated while handling the message. public async ValueTask ExecuteAsync(object message, IWorkflowContext context) { await context.AddEventAsync(new ExecutorInvokeEvent(this.Id)).ConfigureAwait(false); @@ -124,104 +114,29 @@ protected void CheckInitialized() } /// - /// . + /// A set of s, representing the messages this executor can handle. /// public ISet InputTypes => this.Router.IncomingTypes; /// - /// . + /// A set of s, representing the messages this executor can produce as output. /// - public virtual ISet OutputTypes => new HashSet(); + public virtual ISet OutputTypes => new HashSet([typeof(object)]); /// - /// . + /// Checks if the executor can handle a specific message type. /// /// /// public bool CanHandle(Type messageType) => this.Router.CanHandle(messageType); - /// - /// . - /// - /// - /// - public async ValueTask InitializeAsync(IWorkflowContext context) - { - if (this._initialized) - { - return; - } - - await this.InitializeOverride(context).ConfigureAwait(false); - - this._initialized = true; - } - - /// - /// . - /// - public ExecutorCapabilities Capabilities - => new() - { - Id = this.Id, - Name = this.Name, - ExecutorType = this.GetType(), - HandledMessageTypes = new HashSet(this.InputTypes), - IsInitialized = this._initialized, - StateKeys = new HashSet(this.State.Keys) - }; - - /// - /// . - /// - /// - public ReadOnlyDictionary CurrentState => new(this.State); - - /// - /// . - /// - /// - /// - public void RestoreState(IDictionary state) - { - Throw.IfNull(state); - - this.State.Clear(); - - foreach (KeyValuePair kvp in state) - { - this.State[kvp.Key] = kvp.Value; - } - } - - /// - /// . - /// - /// - protected virtual ValueTask PrepareForCheckpointAsync() => default; - - /// - /// . - /// - /// - protected virtual ValueTask AfterCheckpointRestoreAsync() => default; - - /// - /// . - /// - /// - /// - protected virtual ValueTask InitializeOverride(IWorkflowContext context) => default; - - /// - /// . - /// - /// + /// protected virtual async ValueTask DisposeAsync() { this._initialized = false; } + /// ValueTask IAsyncDisposable.DisposeAsync() { GC.SuppressFinalize(this); // Should we be suppressing the finalizer here? CodeAnalysis seems to want it (CA1816) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs deleted file mode 100644 index 7f6ab5aebd..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// . -/// -public record ExecutorCapabilities -{ - /// - /// . - /// - public string Id { get; init; } - /// - /// . - /// - public string Name { get; init; } - /// - /// . - /// - public Type ExecutorType { get; init; } - /// - /// . - /// - public ISet HandledMessageTypes { get; init; } - /// - /// . - /// - public bool IsInitialized { get; init; } - /// - /// . - /// - public ISet StateKeys { get; init; } - - /// - /// . - /// - public ExecutorCapabilities() - { - this.Id = string.Empty; - this.Name = string.Empty; - this.ExecutorType = typeof(Executor); - this.HandledMessageTypes = new HashSet(); - this.IsInitialized = false; - this.StateKeys = new HashSet(); - } - - /// - /// . - /// - /// - /// - /// - /// - /// - /// - public ExecutorCapabilities(string id, string name, Type executorType, ISet handledMessageTypes, bool isInitialized, ISet stateKeys) - { - this.Id = id; - this.Name = name; - this.ExecutorType = executorType; - this.HandledMessageTypes = handledMessageTypes; - this.IsInitialized = isInitialized; - this.StateKeys = stateKeys; - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs index e06cdfaf02..0dcc3008a8 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs @@ -7,21 +7,21 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// Represents a request to an external input port. /// -/// -/// -/// +/// The port to invoke. +/// A unique identifier for this request instance. +/// The data contained in the request. public record ExternalRequest(InputPort Port, string RequestId, object Data) { /// - /// . + /// Creates a new for the specified input port and data payload. /// - /// - /// - /// - /// - /// + /// The port to invoke. + /// The data contained in the request. + /// An optional unique identifier for this request instance. If null, a UUID will be generated. + /// An instance containing the specified port, data, and request identifier. + /// Thrown when the input data object does not match the expected request type. public static ExternalRequest Create(InputPort port, [NotNull] object data, string? requestId = null) { if (!port.Request.IsAssignableFrom(Throw.IfNull(data).GetType())) @@ -36,21 +36,21 @@ public static ExternalRequest Create(InputPort port, [NotNull] object data, stri } /// - /// . + /// Creates a new for the specified input port and data payload. /// - /// - /// - /// - /// - /// + /// The type of request data. + /// The input port that identifies the target endpoint for the request. Must not be null. + /// The data payload to include in the request. Must not be null. + /// An optional identifier for the request. If null, a default identifier may be assigned. + /// An instance containing the specified port, data, and request identifier. public static ExternalRequest Create(InputPort port, T data, string? requestId = null) => Create(port, (object)Throw.IfNull(data), requestId); /// - /// . + /// Creates a new corresponding to the request, with the speicified data payload. /// - /// - /// - /// + /// The data contained in the response. + /// An instance corresponding to this request with the specified data. + /// Thrown when the input data object does not match the expected response type. public ExternalResponse CreateResponse(object data) { if (!Throw.IfNull(this.Port).Response.IsAssignableFrom(Throw.IfNull(data).GetType())) @@ -63,10 +63,10 @@ public ExternalResponse CreateResponse(object data) } /// - /// . + /// Creates a new corresponding to the request, with the speicified data payload. /// - /// - /// - /// + /// The type of the response data. + /// The data contained in the response. + /// An instance corresponding to this request with the specified data. public ExternalResponse CreateResponse(T data) => this.CreateResponse((object)Throw.IfNull(data)); } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs index 00dde3bcbd..58ed3a1be9 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs @@ -3,11 +3,11 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// Represents a request from an external input port. /// -/// -/// -/// +/// The port invoked. +/// The unique identifier of the corresponding request. +/// The data contained in the response. public record ExternalResponse(InputPort Port, string RequestId, object Data) { } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs index cfa4e27f8d..bf49afb78f 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs @@ -5,10 +5,20 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// An external request port for a with the specified request and response types. /// /// /// /// public record InputPort(string Id, Type Request, Type Response) -{ }; +{ + /// + /// Creates a new instance configured for the specified request and response types. + /// + /// The type of the request messages that the input port will accept. + /// The type of the response messages that the input port will produce. + /// The unique identifier for the input port. + /// An instance associated with the specified , configured to handle + /// requests of type and responses of type . + public static InputPort Create(string id) => new(id, typeof(TRequest), typeof(TResponse)); +}; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs index 83eb64020a..467a374bfa 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs @@ -15,8 +15,13 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// Provides a builder for configuring message type handlers for an . /// +/// +/// Override the method to customize the routing of messages to handlers. By +/// default, uses reflection to find implementations of and +/// . +/// public class RouteBuilder { private readonly Dictionary _typedHandlers = new(); @@ -44,13 +49,6 @@ internal RouteBuilder AddHandler(Type messageType, MessageHandlerF handler, bool return this; } - /// - /// . - /// - /// - /// - /// - /// internal RouteBuilder AddHandler(Type type, Func handler, bool overwrite = false) { Throw.IfNull(handler); @@ -64,13 +62,6 @@ async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx } } - /// - /// . - /// - /// - /// - /// - /// internal RouteBuilder AddHandler(Type type, Func> handler, bool overwrite = false) { Throw.IfNull(handler); @@ -85,12 +76,18 @@ async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx } /// - /// . + /// Registers a handler for messages of the specified input type in the workflow route. /// + /// If a handler for the specified input type already exists and is + /// , the existing handler will not be replaced. Handlers are invoked asynchronously and are + /// expected to complete their processing before the workflow continues. /// - /// - /// - /// + /// A delegate that processes messages of type within the workflow context. The + /// delegate is invoked for each incoming message of the specified type. + /// to replace any existing handler for the specified input type; otherwise, to preserve the existing handler. + /// The current instance, enabling fluent configuration of additional handlers or route + /// options. public RouteBuilder AddHandler(Func handler, bool overwrite = false) { Throw.IfNull(handler); @@ -105,13 +102,18 @@ async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx } /// - /// . + /// Registers a handler function for messages of the specified input type in the workflow route. /// - /// - /// - /// - /// - /// + /// If a handler for the given input type already exists, setting to + /// will replace the existing handler; otherwise, an exception may be thrown. The handler + /// receives the input message and workflow context, and returns a result asynchronously. + /// The type of input message the handler will process. + /// The type of result produced by the handler. + /// A function that processes messages of type within the workflow context and returns + /// a representing the asynchronous result. + /// to replace any existing handler for the input type; otherwise, to + /// preserve existing handlers. + /// The current instance, enabling fluent configuration of workflow routes. public RouteBuilder AddHandler(Func> handler, bool overwrite = false) { Throw.IfNull(handler); diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs index d26ee4f12f..0376119559 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs @@ -8,54 +8,61 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// A class that represents a workflow that can be executed. /// public class Workflow { /// - /// . + /// A dictionary of executor providers, keyed by executor ID. /// public Dictionary> ExecutorProviders { get; internal init; } = new(); /// - /// . + /// Gets the collection of edges grouped by their source node identifier. /// public Dictionary> Edges { get; internal init; } = new(); /// - /// . + /// Gets the collection of external request ports, keyed by their ID. /// + /// + /// Each port has a corresponding entry in the dictionary. + /// public Dictionary Ports { get; internal init; } = new(); /// - /// . + /// Gets the identifier of the starting executor of the workflow. /// public string StartExecutorId { get; } /// - /// . + /// Gets the type of input expected by the starting executor of the workflow. /// public Type InputType { get; } + /// + /// Initializes a new instance of the class with the specified starting executor identifier + /// and input type. + /// + /// The unique identifier of the starting executor for the workflow. Cannot be null. + /// The representing the input data for the workflow. Cannot be null. internal Workflow(string startExecutorId, Type type) { this.StartExecutorId = Throw.IfNull(startExecutorId); this.InputType = Throw.IfNull(type); - - // TODO: How do we (1) ensure the types are happy, and (2) work under AOT? } } /// -/// . +/// Represents a workflow that operates on data of type . /// -/// +/// The type of input to the workflow. public class Workflow : Workflow { /// - /// . + /// Initializes a new instance of the class with the specified starting executor identifier /// - /// + /// The unique identifier of the starting executor for the workflow. Cannot be null. public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) { } @@ -74,10 +81,11 @@ internal Workflow Promote(OutputSink outputSource) } /// -/// . +/// Represents a workflow that operates on data of type , resulting in +/// . /// -/// -/// +/// The type of input to the workflow. +/// The type of the output from the workflow. public class Workflow : Workflow { private readonly OutputSink _output; @@ -89,7 +97,7 @@ internal Workflow(string startExecutorId, OutputSink outputSource) } /// - /// . + /// The running (partial) output of the workflow, if any. /// public TResult? RunningOutput => this._output.Result; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithOutput.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithOutput.cs new file mode 100644 index 0000000000..032fe9e944 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithOutput.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows.Execution; + +internal interface IRunnerWithOutput +{ + ISuperStepRunner StepRunner { get; } + + TResult? RunningOutput { get; } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs deleted file mode 100644 index 0d8a8ff422..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Execution; - -internal interface IRunnerWithResult -{ - ISuperStepRunner StepRunner { get; } - - ValueTask GetResultAsync(CancellationToken cancellation = default); -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index 54158616dc..729b95bedb 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -11,15 +11,22 @@ namespace Microsoft.Agents.Workflows.Execution; /// -/// . +/// Provides a local, in-process runner for executing a workflow using the specified input type. /// -/// +/// enables step-by-step execution of a workflow graph entirely +/// within the current process, without distributed coordination. It is primarily intended for testing, debugging, or +/// scenarios where workflow execution does not require executor distribution. +/// The type of input accepted by the workflow. Must be non-nullable. public class LocalRunner : ISuperStepRunner where TInput : notnull { /// - /// . + /// Initializes a new instance of the class to execute the specified workflow + /// locally. /// - /// + /// The manages the execution context and edge mapping for the + /// provided workflow, enabling local, in-process execution. The workflow's structure, including its edges and + /// ports, is used to set up the runner's internal state. + /// The workflow to be executed. Must not be null. public LocalRunner(Workflow workflow) { this.Workflow = Throw.IfNull(workflow); @@ -77,11 +84,15 @@ private bool IsResponse(object message) } /// - /// . + /// Initiates an asynchronous streaming execution using the specified input. /// - /// - /// - /// + /// The returned provides methods to observe and control + /// the ongoing streaming execution. The operation will continue until the streaming execution is finished or + /// cancelled. + /// The input message to be processed as part of the streaming execution. + /// A that can be used to cancel the streaming operation. + /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming execution. public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) { await this.RunContext.AddExternalMessageAsync(input).ConfigureAwait(false); @@ -146,19 +157,25 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) } /// -/// . +/// Provides a local, in-process runner for executing a workflow with input and producing a result. /// -/// -/// -public class LocalRunner : IRunnerWithResult where TInput : notnull +/// manages the execution of a instance locally, allowing for streaming input and asynchronous result retrieval. +/// This class is intended for scenarios where workflow execution does not require distributed procesing. +/// It supports streaming execution and exposes methods to retrieve the final result asynchronously. +/// +/// The type of input accepted by the workflow. Must be non-nullable. +/// The type of output produced by the workflow. +public class LocalRunner : IRunnerWithOutput where TInput : notnull { private readonly Workflow _workflow; private readonly ISuperStepRunner _innerRunner; /// - /// . + /// Initializes a new instance of the class to execute the specified + /// workflow locally. /// - /// + /// The workflow to be executed. Must not be null. public LocalRunner(Workflow workflow) { this._workflow = Throw.IfNull(workflow); @@ -166,11 +183,15 @@ public LocalRunner(Workflow workflow) } /// - /// . + /// Initiates an asynchronous streaming execution for the specified input. /// - /// - /// - /// + /// The returned can be used to retrieve results + /// as they become available. If the operation is cancelled via the token, the + /// streaming execution will be terminated. + /// The input value to be processed by the streaming execution. + /// A that can be used to cancel the streaming operation. + /// A that provides access to the results of the streaming + /// execution. public async ValueTask> StreamAsync(TInput input, CancellationToken cancellation = default) { await this._innerRunner.EnqueueMessageAsync(input).ConfigureAwait(false); @@ -178,21 +199,8 @@ public async ValueTask> StreamAsync(TInput inp return new StreamingExecutionHandle(this); } - /// - /// . - /// - /// - /// - public ValueTask GetResultAsync(CancellationToken cancellation = default) - { - // TODO: Block on finishing consuming StreamAsync()? - return new ValueTask(this.RunningOutput!); - } - - /// - /// . - /// + /// public TResult? RunningOutput => this._workflow.RunningOutput; - ISuperStepRunner IRunnerWithResult.StepRunner => this._innerRunner; + ISuperStepRunner IRunnerWithOutput.StepRunner => this._innerRunner; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index ad6fafe741..71ccc6d1d1 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -35,8 +35,6 @@ public async ValueTask EnsureExecutorAsync(string executorId) this._executors[executorId] = executor = provider(); - await executor.InitializeAsync(this.Bind(executor.Id)).ConfigureAwait(false); - if (executor is RequestInputExecutor requestInputExecutor) { requestInputExecutor.AttachRequestSink(this); diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs index 47d5b24ecd..6a93989a85 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs @@ -11,7 +11,8 @@ namespace Microsoft.Agents.Workflows.Execution; /// -/// . +/// Provides a handle for managing and interacting with a streaming workflow execution, enabling asynchronous response +/// delivery and event monitoring. /// public class StreamingExecutionHandle { @@ -24,11 +25,13 @@ internal StreamingExecutionHandle(ISuperStepRunner stepRunner) } /// - /// . + /// Asynchronously sends the specified response to the external system and signals completion of the current + /// response wait operation. /// - /// - /// - /// + /// The response will be queued for processing for the next superstep. + /// The to send. Must not be null. + /// A that represents the asynchronous send operation. The task completes when the response + /// has been enqueued for processing, but will not wait for processing to complete. public ValueTask SendResponseAsync(ExternalResponse response) { this._waitForResponseSource?.TrySetResult(new()); @@ -37,11 +40,15 @@ public ValueTask SendResponseAsync(ExternalResponse response) } /// - /// . + /// Asynchronously streams workflow events as they occur during workflow execution. /// - /// - /// - /// + /// This method yields instances in real time as the workflow + /// progresses. The stream completes when a is encountered. Events are + /// delivered in the order they are raised. + /// A that can be used to cancel the streaming operation. If cancellation is + /// requested, the stream will end and no further events will be yielded. + /// An asynchronous stream of objects representing significant workflow state changes. + /// The stream ends when the workflow completes or when cancellation is requested. public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancellation] CancellationToken cancellation = default) { List eventSink = new(); @@ -103,33 +110,25 @@ void OnWorkflowEvent(object? sender, WorkflowEvent e) } /// -/// . +/// Represents a handle for managing and retrieving the result of a streaming execution operation. /// /// public class StreamingExecutionHandle : StreamingExecutionHandle { - private readonly IRunnerWithResult _resultSource; + private readonly IRunnerWithOutput _resultSource; - internal StreamingExecutionHandle(IRunnerWithResult runner) + internal StreamingExecutionHandle(IRunnerWithOutput runner) : base(Throw.IfNull(runner.StepRunner)) { this._resultSource = runner; } - /// - /// . - /// - /// - /// - /// - public ValueTask GetResultAsync(CancellationToken cancellation = default) - { - return this._resultSource.GetResultAsync(cancellation); - } + /// + public TResult? RunningOutput => this._resultSource.RunningOutput; } /// -/// . +/// Provides extension methods for processing and executing workflows using streaming execution handles. /// public static class ExecutionHandleExtensions { @@ -141,10 +140,9 @@ public static class ExecutionHandleExtensions /// non- response, the response is sent back to the workflow using the handle. /// The representing the workflow execution stream to monitor. /// An optional callback function invoked for each received from the stream. - /// The /// callback can return a response object to be sent back to the workflow, or if no response + /// The callback can return a response object to be sent back to the workflow, or if no response /// is required. - /// A to observe while waiting for events. Defaults to . + /// A to observe while waiting for events. /// A that represents the asynchronous operation. The task completes when the workflow /// execution stream is fully processed. public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) @@ -169,20 +167,18 @@ public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle /// is provided, it will be invoked for each event emitted during the workflow's /// execution, allowing for custom event handling. /// The type of the result produced by the workflow. - /// The representing the workflow to execute. This parameter cannot - /// be . - /// An optional callback function that is invoked for each emitted during the workflow - /// execution. The callback can process the event and return an object, or if no processing - /// is required. - /// A that can be used to cancel the workflow execution. The default value is . - /// A that represents the asynchronous operation. The task's result is the final + /// The representing the workflow to execute. + /// An optional callback function that is invoked for each + /// emitted during execution. The callback can process the event and return an object, or + /// if no response is required. + /// A that can be used to cancel the workflow execution. + /// A that represents the asynchronous operation. The task's result is the final /// result of the workflow execution. public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) { Throw.IfNull(handle); await handle.RunToCompletionAsync(eventCallback, cancellation).ConfigureAwait(false); - return await handle.GetResultAsync(cancellation).ConfigureAwait(false); + return handle.RunningOutput!; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs index 1a1bfb193e..428fec3491 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -9,7 +9,8 @@ namespace Microsoft.Agents.Workflows; /// -/// . +/// A tagged union representing an object that can function like an in a , +/// or a reference to one by ID. /// public sealed class ExecutorIsh : IIdentified, @@ -18,31 +19,30 @@ public sealed class ExecutorIsh : IEquatable { /// - /// . + /// The type of the . /// public enum Type { /// - /// . + /// An unbound executor reference, identified only by ID. /// Unbound, /// - /// . + /// An actual instance. /// Executor, /// - /// . + /// An for servicing external requests. /// InputPort, /// - /// . + /// An instance. /// Agent, - //ProcessStep } /// - /// . + /// Gets the type of data contained in this instance. /// public Type ExecutorType { get; init; } @@ -52,9 +52,9 @@ public enum Type private readonly AIAgent? _aiAgentValue; /// - /// . + /// Initializes a new instance of the class as an unbound reference by ID. /// - /// + /// A unique identifier for an in the public ExecutorIsh(string id) { this.ExecutorType = Type.Unbound; @@ -62,9 +62,9 @@ public ExecutorIsh(string id) } /// - /// . + /// Initializes a new instance of the ExecutorIsh class using the specified executor. /// - /// + /// The executor instance to be wrapped. public ExecutorIsh(Executor executor) { this.ExecutorType = Type.Executor; @@ -72,9 +72,9 @@ public ExecutorIsh(Executor executor) } /// - /// . + /// Initializes a new instance of the ExecutorIsh class using the specified input port. /// - /// + /// The input port to associate to be wrapped. public ExecutorIsh(InputPort port) { this.ExecutorType = Type.InputPort; @@ -82,7 +82,7 @@ public ExecutorIsh(InputPort port) } /// - /// . + /// Initializes a new instance of the ExecutorIsh class using the specified AI agent. /// /// public ExecutorIsh(AIAgent aiAgent) @@ -100,12 +100,12 @@ public ExecutorIsh(AIAgent aiAgent) Type.Executor => this._executorValue!.Id, Type.InputPort => this._inputPortValue!.Id, Type.Agent => this._aiAgentValue!.Id, - //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") }; /// - /// . + /// Gets an that can be used to obtain an instance + /// corresponding to this . /// public ExecutorProvider ExecutorProvider => this.ExecutorType switch { @@ -113,32 +113,31 @@ public ExecutorIsh(AIAgent aiAgent) Type.Executor => () => this._executorValue!, Type.InputPort => () => new RequestInputExecutor(this._inputPortValue!), Type.Agent => () => new AIAgentHostExecutor(this._aiAgentValue!), - //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") }; /// - /// . + /// Defines an implicit conversion from an instance to an object. /// - /// + /// The instance to convert to . public static implicit operator ExecutorIsh(Executor executor) => new(executor); /// - /// . + /// Defines an implicit conversion from an to an instance. /// - /// + /// The to convert to an . public static implicit operator ExecutorIsh(InputPort inputPort) => new(inputPort); /// - /// . + /// Defines an implicit conversion from an to an instance. /// - /// + /// The to convert to an . public static implicit operator ExecutorIsh(AIAgent aiAgent) => new(aiAgent); /// - /// . + /// Defines an implicit conversion from a string to an instance. /// - /// + /// The string ID to convert to an . public static implicit operator ExecutorIsh(string id) { return new ExecutorIsh(id); @@ -204,7 +203,6 @@ public override string ToString() Type.Executor => $"'{this.Id}':{this._executorValue!.GetType().Name}", Type.InputPort => $"'{this.Id}':Input({this._inputPortValue!.Request.Name}->{this._inputPortValue!.Response.Name})", Type.Agent => $"{this.Id}':AIAgent(@{this._aiAgentValue!.GetType().Name})", - //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", _ => $"'{this.Id}':" }; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs b/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs index b90048a43a..4a767bb282 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs @@ -6,28 +6,36 @@ namespace Microsoft.Agents.Workflows; /// -/// . +/// Represents a function that incrementally aggregates a sequence of input values, producing an updated result for each +/// input. /// -/// -/// -/// -/// -/// -public delegate TResult? StreamingAggregator(TInput input, TResult? runningResult); +/// The type of the input value to be aggregated. +/// The type of the aggregation result produced by the function. +/// The current input value to be incorporated into the aggregation. +/// The current aggregated result, or null if this is the first input. +/// The updated aggregation result after processing the input value, or null if no result can be produced. +public delegate TResult? StreamingAggregator(TInput input, TResult? runningResult); /// -/// . +/// Provides a set of streaming aggregation functions for processing sequences of input values in a stateful, +/// incremental manner. /// public static class StreamingAggregators { /// - /// . + /// Creates a streaming aggregator that returns the result of applying the specified conversion function to the + /// first input value, or a default value if no input is provided. /// - /// - /// - /// - /// - /// + /// Subsequent inputs after the first are ignored by the aggregator. This method is useful for + /// scenarios where only the first occurrence in a stream is relevant. The conversion function is invoked at most + /// once. + /// The type of the input elements to be aggregated. + /// The type of the result produced by the conversion function. + /// A function that converts an input value of type to a result of type . This function is applied to the first input received. + /// The value to return if no input is provided. + /// A that yields the converted result of the first input, or the + /// specified default value if no input is received. public static StreamingAggregator First(Func conversion, TResult? defaultValue = default) { bool hasRun = false; @@ -47,22 +55,26 @@ public static StreamingAggregator First(Func - /// . + /// Creates a streaming aggregator that returns the first input element, or a specified default value if no elements + /// are provided. /// - /// - /// - /// + /// The type of the input elements to aggregate. + /// The value to return if the input sequence contains no elements. + /// A that yields the first input element, or if the sequence is empty. public static StreamingAggregator First(TInput? defaultValue = default) => First(input => input, defaultValue); /// - /// . + /// Creates a streaming aggregator that returns the result of applying the specified conversion to the most recent + /// input value. /// - /// - /// - /// - /// - /// + /// The type of the input elements to be aggregated. + /// The type of the result produced by the conversion function. + /// A function that converts each input value to a result. Cannot be null. + /// The initial result value to use before any input is processed. + /// A streaming aggregator that yields the converted value of the last input received, or the specified default + /// value if no input has been processed. public static StreamingAggregator Last(Func conversion, TResult? defaultValue = default) { TResult? local = defaultValue; @@ -77,21 +89,25 @@ public static StreamingAggregator Last(Func - /// . + /// Creates a streaming aggregator that returns the last element in a sequence, or a specified default value if the + /// sequence is empty. /// - /// - /// - /// + /// The type of elements in the input sequence. + /// The value to return if the input sequence contains no elements. + /// A that yields the last element of the sequence, or if the sequence is empty. public static StreamingAggregator Last(TInput? defaultValue = default) => Last(input => input, defaultValue); /// - /// . + /// Creates a streaming aggregator that produces the union of results by applying a conversion function to each + /// input and accumulating the results. /// - /// - /// - /// - /// + /// The type of the input elements to be aggregated. + /// The type of the result elements produced by the conversion function. + /// A function that converts each input element to a result element to be included in the union. + /// A streaming aggregator that, for each input, returns an enumerable containing all result elements produced so + /// far. public static StreamingAggregator> Union(Func conversion) { List results = new(); @@ -106,10 +122,13 @@ IEnumerable Aggregate(TInput input, IEnumerable? runningResult } /// - /// . + /// Creates a streaming aggregator that produces the union of all input sequences of type TInput. /// - /// - /// + /// The resulting aggregator combines all input sequences into a single sequence containing + /// distinct elements. The order of elements in the output sequence is not guaranteed. + /// The type of the elements in the input sequences to be aggregated. + /// A StreamingAggregator that, when applied to multiple input sequences, returns an IEnumerable containing the + /// union of all elements from those sequences. public static StreamingAggregator> Union() => Union(input => input); } diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index cb96b72c78..e7084b2895 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -24,8 +24,13 @@ public delegate TExecutor ExecutorProvider() where TExecutor : Executor; /// -/// . +/// Provides a builder for constructing and configuring a workflow by defining executors and the connections between +/// them. /// +/// Use the WorkflowBuilder to incrementally add executors and edges, including fan-in and fan-out +/// patterns, before building a strongly-typed workflow instance. Executors must be bound before building the workflow. +/// All executors must be bound by calling into if they were intially specified as +/// . public class WorkflowBuilder { private record struct EdgeId(string SourceId, string TargetId) @@ -42,9 +47,9 @@ private record struct EdgeId(string SourceId, string TargetId) private readonly string _startExecutorId; /// - /// . + /// Initializes a new instance of the WorkflowBuilder class with the specified starting executor. /// - /// + /// The executor that defines the starting point of the workflow. Cannot be null. public WorkflowBuilder(ExecutorIsh start) { this._startExecutorId = this.Track(start).Id; @@ -83,11 +88,11 @@ private void UpdateExecutor(string id, ExecutorProvider provider) } /// - /// . + /// Binds the specified executor to the workflow, allowing it to participate in workflow execution. /// - /// - /// - /// + /// The executor instance to bind. The executor must exist in the workflow and not be already bound. + /// The current instance, enabling fluent configuration. + /// Thrown if the specified executor is already bound or does not exist in the workflow. public WorkflowBuilder BindExecutor(Executor executor) { if (!this._unboundExecutors.Contains(executor.Id)) @@ -114,13 +119,16 @@ private HashSet EnsureEdgesFor(string sourceId) } /// - /// . + /// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a + /// condition. /// - /// - /// - /// - /// - /// + /// The executor that acts as the source node of the edge. Cannot be null. + /// The executor that acts as the target node of the edge. Cannot be null. + /// An optional predicate that determines whether the edge should be followed based on the input. + /// If null, the edge is always activated when the source sends a message. + /// The current instance of . + /// Thrown if an unconditional edge between the specified source and target + /// executors already exists. public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func? condition = null) { // Add an edge from source to target with an optional condition. @@ -144,12 +152,16 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func - /// . + /// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a + /// custom partitioning function. /// - /// - /// - /// - /// + /// If a partitioner function is provided, it will be used to distribute input across the target + /// executors. The order of targets determines their mapping in the partitioning process. + /// The source executor from which the fan-out edge originates. Cannot be null. + /// An optional function that determines how input is partitioned among the target executors. + /// If null, messages will route to all targets. + /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. + /// The current instance of . public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func>? partitioner = null, params ExecutorIsh[] targets) { Throw.IfNull(source); @@ -165,13 +177,18 @@ public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func - /// . + /// Adds a fan-in edge to the workflow, connecting multiple source executors to a single target executor with an + /// optional trigger condition. /// - /// - /// - /// - /// - public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = default, params ExecutorIsh[] sources) + /// This method establishes a fan-in relationship, allowing the target executor to be activated + /// based on the completion or state of multiple sources. The trigger parameter can be used to customize activation + /// behavior. + /// The target executor that receives input from the specified source executors. Cannot be null. + /// An optional trigger condition that determines when the fan-in edge activates. Defaults to + /// . + /// One or more source executors that provide input to the target. Cannot be null or empty. + /// The current instance of . + public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = FanInTrigger.WhenAll, params ExecutorIsh[] sources) { Throw.IfNull(target); Throw.IfNullOrEmpty(sources); @@ -190,11 +207,13 @@ public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = d } /// - /// . + /// Builds and returns a workflow instance configured to process messages of the specified input type. /// - /// - /// - /// + /// The type of input messages that the workflow will accept and process. + /// A new instance of . + /// Thrown if there are unbound executors in the workflow definition, + /// if the start executor is not bound, or if the start executor does not contain a handler for the specified input + /// type . public Workflow Build() { if (this._unboundExecutors.Count > 0) diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index 69894710fa..c765bc53ca 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -9,18 +9,24 @@ namespace Microsoft.Agents.Workflows; /// -/// . +/// Provides extension methods for configuring and building workflows using the WorkflowBuilder type. /// +/// These extension methods simplify the process of connecting executors, adding external calls, and +/// constructing workflows with output aggregation. They are intended to streamline workflow graph construction and +/// promote common patterns for chaining and aggregating workflow steps. public static class WorkflowBuilderExtensions { /// - /// . + /// Adds a sequential chain of executors to the workflow, connecting each executor in order so that each is + /// executed after the previous one. /// - /// - /// - /// - /// - /// + /// Each executor in the chain is connected so that execution flows from the source to each subsequent + /// executor in the order provided. + /// The workflow builder to which the executor chain will be added. + /// The initial executor in the chain. Cannot be null. + /// An ordered array of executors to be added to the chain after the source. + /// The original workflow builder instance with the specified executor chain added. + /// Thrown if there is a cycle in the chain. public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors) { Throw.IfNull(builder); @@ -47,14 +53,18 @@ public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh } /// - /// . + /// Adds an external call to the workflow by connecting the specified source to a new input port with the given + /// request and response types. /// - /// - /// - /// - /// - /// - /// + /// This method creates a bidirectional connection between the source and the new input port, + /// allowing the workflow to send requests and receive responses through the specified external call. The port is + /// configured to handle messages of the specified request and response types. + /// The type of the request message that the external call will accept. + /// The type of the response message that the external call will produce. + /// The workflow builder to which the external call will be added. + /// The source executor representing the external system or process to connect. Cannot be null. + /// The unique identifier for the input port that will handle the external call. Cannot be null. + /// The original workflow builder instance with the external call added. public static WorkflowBuilder AddExternalCall(this WorkflowBuilder builder, ExecutorIsh source, string portId) { Throw.IfNull(builder); @@ -67,15 +77,21 @@ public static WorkflowBuilder AddExternalCall(this Workflow } /// - /// . + /// Builds a workflow that collects output from the specified executor, aggregates results using the provided + /// streaming aggregator, and optionally completes based on a custom condition. /// - /// - /// - /// - /// - /// - /// - /// + /// The returned workflow promotes the output collector as its result source, allowing consumers + /// to access the aggregated output directly. The completion condition can be used to implement custom termination + /// logic, such as early stopping when a desired result is reached. + /// The type of input items processed by the workflow. + /// The type of aggregated result produced by the workflow. + /// The workflow builder used to construct the workflow and define its execution graph. + /// The executor that produces output items to be collected and aggregated. Cannot be null. + /// The streaming aggregator that processes input items and produces aggregated results. Cannot be null. + /// An optional predicate that determines when the workflow should complete based on the current input and + /// aggregated result. If null, the workflow will not raise a . + /// A workflow that collects output from the specified executor, aggregates results, and exposes the aggregated + /// output. public static Workflow BuildWithOutput( this WorkflowBuilder builder, ExecutorIsh outputSource, From bfc30f3fd9fa8ad304effed3b859868a7e61c777 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 14:00:39 -0400 Subject: [PATCH 029/232] refactor: Normalize UnitTest and Sample namespaces --- .../ReflectionSmokeTest.cs | 2 +- .../Sample/05_Simple_Workflow_ExternalRequest.cs | 4 +--- .../Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs index ae6cb303a1..5430e30d94 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs @@ -5,7 +5,7 @@ using Microsoft.Agents.Workflows.Core; using Moq; -namespace Microsoft.Agents.Orchestration.UnitTest; +namespace Microsoft.Agents.Workflows.UnitTests; public class BaseTestExecutor : Executor { diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs index ca2de73632..548790cc27 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs @@ -3,12 +3,10 @@ using System; using System.IO; using System.Threading.Tasks; -using Microsoft.Agents.Workflows; using Microsoft.Agents.Workflows.Core; using Microsoft.Agents.Workflows.Execution; -using Microsoft.Agents.Workflows.Sample; -namespace Microsoft.Agents.Workflow.UnitTests.Sample; +namespace Microsoft.Agents.Workflows.Sample; internal static class Step5EntryPoint { diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs index 7ec53c5756..12fe7f8f37 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs @@ -4,10 +4,9 @@ using System.IO; using System.Linq; using System.Threading.Tasks; -using Microsoft.Agents.Workflow.UnitTests.Sample; using Microsoft.Agents.Workflows.Sample; -namespace Microsoft.Agents.Workflow.UnitTests; +namespace Microsoft.Agents.Workflows.UnitTests; public class SampleSmokeTest { From 39f4c1d3abe9e4f5e5acec5463aef34dd2495347 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 14:21:28 -0400 Subject: [PATCH 030/232] fix: Formatting --- dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs | 4 ++-- dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs | 7 ------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs index 0769d0012e..4334a0651d 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs @@ -1,11 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using Microsoft.Shared.Diagnostics; -using PredicateT = System.Func; using PartitionerT = System.Func>; -using System; +using PredicateT = System.Func; namespace Microsoft.Agents.Workflows.Core; diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index e7084b2895..da80121a36 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -1,17 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. -#pragma warning disable IDE0005 // Using directive is unnecessary. using System; using System.Collections.Generic; using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Execution; using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; -using System.Collections.Concurrent; - -#pragma warning restore IDE0005 // Using directive is unnecessary. namespace Microsoft.Agents.Workflows; From be9f29127450c3e310d50809908a8c14146b7209 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 15:15:03 -0400 Subject: [PATCH 031/232] refactor: Normalize project/folder names --- dotnet/agent-framework-dotnet.slnx | 4 ++-- .../Core/CallResult.cs | 0 .../Core/Edge.cs | 0 .../Core/Events.cs | 0 .../Core/Executor.cs | 0 .../Core/ExternalRequest.cs | 0 .../Core/ExternalResponse.cs | 0 .../Core/IIdentified.cs | 0 .../Core/IMessageHandler.cs | 0 .../Core/IMessageRouter.cs | 0 .../Core/IWorkflowContext.cs | 0 .../Core/InputPort.cs | 0 .../Core/Message.cs | 0 .../Core/MessageHandlerInfo.cs | 0 .../Core/MessageRouter.cs | 0 .../Core/RouteBuilder.cs | 0 .../Core/RouteBuilderExtensions.cs | 0 .../Core/StreamsMessageAttribute.cs | 0 .../Core/ValueTaskTypeErasure.cs | 0 .../Core/Workflow.cs | 0 .../Execution/DirectEdgeRunner.cs | 0 .../Execution/EdgeMap.cs | 0 .../Execution/EdgeRunner.cs | 0 .../Execution/ExecutorIdentity.cs | 0 .../Execution/FanInEdgeRunner.cs | 0 .../Execution/FanInEdgeState.cs | 0 .../Execution/FanOutEdgeRunner.cs | 0 .../Execution/IExternalRequestSink.cs | 0 .../Execution/IRunnerContext.cs | 0 .../Execution/IRunnerWithOutput.cs | 0 .../Execution/ISuperStepRunner.cs | 0 .../Execution/InputEdgeRunner.cs | 0 .../Execution/LocalRunner.cs | 0 .../Execution/LocalRunnerContext.cs | 0 .../Execution/StepContext.cs | 0 .../Execution/StreamingExecutionHandle.cs | 0 .../ExecutorIsh.cs | 0 .../Microsoft.Agents.Workflows.csproj | 2 +- .../Specialized/AIAgentHostExecutor.cs | 0 .../Specialized/OutputCollectorExecutor.cs | 0 .../Specialized/RequestInputExecutor.cs | 0 .../StreamingAggregators.cs | 0 .../WorkflowBuilder.cs | 0 .../WorkflowBuilderExtensions.cs | 0 .../Microsoft.Agents.Workflows.UnitTests.csproj} | 2 +- .../ReflectionSmokeTest.cs | 0 .../Sample/01_Simple_Workflow_Sequential.cs | 0 .../Sample/02_Simple_Workflow_Condition.cs | 0 .../Sample/03_Simple_Workflow_Loop.cs | 0 .../Sample/05_Simple_Workflow_ExternalRequest.cs | 0 .../SampleSmokeTest.cs | 0 51 files changed, 4 insertions(+), 4 deletions(-) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/CallResult.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/Edge.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/Events.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/Executor.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/ExternalRequest.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/ExternalResponse.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/IIdentified.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/IMessageHandler.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/IMessageRouter.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/IWorkflowContext.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/InputPort.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/Message.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/MessageHandlerInfo.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/MessageRouter.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/RouteBuilder.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/RouteBuilderExtensions.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/StreamsMessageAttribute.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/ValueTaskTypeErasure.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/Workflow.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/DirectEdgeRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/EdgeMap.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/EdgeRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/ExecutorIdentity.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/FanInEdgeRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/FanInEdgeState.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/FanOutEdgeRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/IExternalRequestSink.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/IRunnerContext.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/IRunnerWithOutput.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/ISuperStepRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/InputEdgeRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/LocalRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/LocalRunnerContext.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/StepContext.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/StreamingExecutionHandle.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/ExecutorIsh.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Microsoft.Agents.Workflows.csproj (94%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Specialized/AIAgentHostExecutor.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Specialized/OutputCollectorExecutor.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Specialized/RequestInputExecutor.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/StreamingAggregators.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/WorkflowBuilder.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/WorkflowBuilderExtensions.cs (100%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj => Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj} (92%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests => Microsoft.Agents.Workflows.UnitTests}/ReflectionSmokeTest.cs (100%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests => Microsoft.Agents.Workflows.UnitTests}/Sample/01_Simple_Workflow_Sequential.cs (100%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests => Microsoft.Agents.Workflows.UnitTests}/Sample/02_Simple_Workflow_Condition.cs (100%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests => Microsoft.Agents.Workflows.UnitTests}/Sample/03_Simple_Workflow_Loop.cs (100%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests => Microsoft.Agents.Workflows.UnitTests}/Sample/05_Simple_Workflow_ExternalRequest.cs (100%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests => Microsoft.Agents.Workflows.UnitTests}/SampleSmokeTest.cs (100%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index e79922db8d..253364f376 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -116,7 +116,7 @@ - + @@ -129,7 +129,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ExternalRequest.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/ExternalRequest.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ExternalResponse.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/ExternalResponse.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IIdentified.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IIdentified.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/IIdentified.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/IIdentified.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IMessageHandler.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/IMessageHandler.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/IMessageHandler.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IMessageRouter.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/IMessageRouter.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/IMessageRouter.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/InputPort.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/InputPort.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Message.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/Message.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandlerInfo.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandlerInfo.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageRouter.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/MessageRouter.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/StreamsMessageAttribute.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/StreamsMessageAttribute.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ValueTaskTypeErasure.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/ValueTaskTypeErasure.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/DirectEdgeRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/DirectEdgeRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/EdgeMap.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/EdgeMap.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/EdgeRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/EdgeRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutorIdentity.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/ExecutorIdentity.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutorIdentity.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/ExecutorIdentity.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeState.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeState.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeState.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeState.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IExternalRequestSink.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/IExternalRequestSink.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/IExternalRequestSink.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/IExternalRequestSink.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerContext.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerContext.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithOutput.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerWithOutput.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithOutput.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerWithOutput.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/ISuperStepRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/ISuperStepRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/InputEdgeRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/InputEdgeRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StepContext.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/StepContext.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingExecutionHandle.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingExecutionHandle.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs rename to dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj b/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj similarity index 94% rename from dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj rename to dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj index 65f8a0dfab..4cd49dd698 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj @@ -26,7 +26,7 @@ - + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Specialized/AIAgentHostExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInputExecutor.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInputExecutor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs b/dotnet/src/Microsoft.Agents.Workflows/StreamingAggregators.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs rename to dotnet/src/Microsoft.Agents.Workflows/StreamingAggregators.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs rename to dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs rename to dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj similarity index 92% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj index d384557d8f..006de84807 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj @@ -6,7 +6,7 @@ - + diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/01_Simple_Workflow_Sequential.cs rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Condition.cs rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/03_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/03_Simple_Workflow_Loop.cs rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs From 58c02e8996503e32d5cec1bfd226f7cb831dc5a5 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 16:20:25 -0400 Subject: [PATCH 032/232] feat: Remove DynamicCodeExecution from ValueTaskTypeErasure --- .../Core/ReflectionExtensions.cs | 45 ++++++++++ .../Core/ValueTaskTypeErasure.cs | 88 +++++++++++-------- 2 files changed, 97 insertions(+), 36 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs new file mode 100644 index 0000000000..8ec77241db --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics; +using System.Reflection; + +#if !NET +using System.Linq; +#endif + +namespace Microsoft.Agents.Workflows.Core; + +internal static class ReflectionExtensions +{ + public static object? ReflectionInvoke(this MethodInfo method, object? target, params object?[] arguments) + { +#if NET + return method.Invoke(target, BindingFlags.DoNotWrapExceptions, binder: null, arguments, culture: null); +#else + try + { + return method.Invoke(target, BindingFlags.Default, binder: null, arguments, culture: null); + } + catch (TargetInvocationException e) when (e.InnerException is not null) + { + // If we're targeting .NET Framework, such that BindingFlags.DoNotWrapExceptions + // is ignored, the original exception will be wrapped in a TargetInvocationException. + // Unwrap it and throw that original exception, maintaining its stack information. + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(e.InnerException).Throw(); + throw; + } +#endif + } + + public static MethodInfo GetMethodFromGenericMethodDefinition(this Type specializedType, MethodInfo genericMethodDefinition) + { + Debug.Assert(specializedType.IsGenericType && specializedType.GetGenericTypeDefinition() == genericMethodDefinition.DeclaringType, "generic member definition doesn't match type."); +#if NET + return (MethodInfo)specializedType.GetMemberWithSameMetadataDefinitionAs(genericMethodDefinition); +#else + const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; + return specializedType.GetMethods(All).First(m => m.MetadataToken == genericMethodDefinition.MetadataToken); +#endif + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs index 8166a7a5c6..238fc95fef 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs @@ -1,60 +1,76 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics; using System.Reflection; using System.Threading.Tasks; namespace Microsoft.Agents.Workflows.Core; +internal static class ValueTaskReflection +{ + private const string Nameof_AsTask = nameof(ValueTask.AsTask); + internal static readonly MethodInfo AsTask = typeof(ValueTask<>).GetMethod(Nameof_AsTask, BindingFlags.Public | BindingFlags.Instance)!; + + internal static MethodInfo ReflectAsTask(this Type specializedType) + { + Debug.Assert(specializedType.IsGenericType && + specializedType.GetGenericTypeDefinition() == typeof(ValueTask<>), "specializedType must be a ValueTask<> type."); + + return specializedType.GetMethodFromGenericMethodDefinition(AsTask); + } + + internal static bool IsValueTaskType(this Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>); +} + +internal static class TaskReflection +{ + private const string Nameof_Result = nameof(Task.Result); + internal static readonly MethodInfo Result_get = typeof(Task<>).GetProperty(Nameof_Result)!.GetMethod!; + + internal static MethodInfo ReflectResult_get(this Type specializedType) + { + Debug.Assert(specializedType.IsGenericType && + specializedType.GetGenericTypeDefinition() == typeof(Task<>), "specializedType must be a ValueTask<> type."); + + return specializedType.GetMethodFromGenericMethodDefinition(Result_get); + } + + internal static bool IsTaskType(this Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Task<>); +} + internal static class ValueTaskTypeErasure { - internal static Func> CreateErasingUnwrapper() + internal static Func> UnwrapperFor(Type expectedResultType) { return UnwrapAndEraseAsync; - static async ValueTask UnwrapAndEraseAsync(object maybeValueTask) + async ValueTask UnwrapAndEraseAsync(object maybeGenericVT) { - if (maybeValueTask is ValueTask vt) + // This method handles only ValueTask types. + Type maybeVTType = maybeGenericVT.GetType(); + + if (!maybeVTType.IsValueTaskType()) { - // If the input is a ValueTask, unwrap it. - TResult result = await vt.ConfigureAwait(false); - return (object?)result; + throw new InvalidOperationException($"Expected ValueTask or ValueTask<{expectedResultType.Name}>, but got {maybeGenericVT.GetType().Name}."); } - throw new InvalidOperationException($"Expected ValueTask or ValueTask<{typeof(TResult).Name}>, but got {maybeValueTask.GetType().Name}."); - } - } - -#if NET5_0_OR_GREATER - // This suppression is qualified because for some reason VS is not recognizing the attribute's presence, treating the - // import as an error (due to unnecessary using). - [System.Diagnostics.CodeAnalysis.RequiresDynamicCode("Calls System.Reflection.MethodInfo.MakeGenericMethod(params Type[])")] -#endif - internal static Func> UnwrapperFor(Type resultType) - { - // This method creates a type-erased unwrapper for ValueTask. - // It uses reflection to create a delegate that can handle any TResult type. + MethodInfo asTaskMethod = maybeVTType.ReflectAsTask(); + Debug.Assert(asTaskMethod.ReturnType.IsTaskType(), "AsTask must return a Task<> type."); - // TODO: AOT: This method is marked with RequiresDynamicCodeAttribute, which will not work well in NativeAOT - // scenarios; the solution is to break this up into a Cached/Reflector version (like the MessageRouter does - // with handlers), and SourceGenerate the UnwrapAndEraseAsync-equivalent method for each TResult type. + MethodInfo getResultMethod = asTaskMethod.ReturnType.ReflectResult_get(); + Type actualResultType = getResultMethod.ReturnType; - // Note that this is only necessary because ValueTask is a class-generic, rather than an interface - // type, which means that the type cannot be co/contravariantly used (e.g. ValueTask is not a valid - // supertype of ValueTask or ValueTask, T != object?). + if (!expectedResultType.IsAssignableFrom(actualResultType)) + { + throw new InvalidOperationException($"Expected ValueTask<{expectedResultType.Name}> or a compatible type, but got ValueTask<{actualResultType.Name}>."); + } - MethodInfo createMethod = - typeof(ValueTaskTypeErasure) - .GetMethod(nameof(CreateErasingUnwrapper), BindingFlags.NonPublic | BindingFlags.Static) - !.MakeGenericMethod(resultType); + Task task = (Task)asTaskMethod.ReflectionInvoke(maybeGenericVT)!; + await task.ConfigureAwait(false); // TODO: Could we need to capture the context here? + object? result = getResultMethod.ReflectionInvoke(task); - // Invoke createMethod (as static) to get the delegate. - object? maybeUnwrapper = createMethod.Invoke(null, Array.Empty()); - if (maybeUnwrapper is not Func> unwrapper) - { - throw new InvalidOperationException($"Expected a Func> delegate, but got {maybeUnwrapper?.GetType().Name ?? "null"}."); + return result; } - - return unwrapper; } } From bfd4fff1d0a4e8b5c032efd86f2d6ca85d70fb68 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 16:34:11 -0400 Subject: [PATCH 033/232] fix: Fix ILTrim warnings --- .../Core/RouteBuilderExtensions.cs | 106 +++++++++++++----- 1 file changed, 76 insertions(+), 30 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs index 2beadc3ec5..fc4c3c9845 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs @@ -3,23 +3,56 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Reflection; using Microsoft.Shared.Diagnostics; +#if NET9_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif + namespace Microsoft.Agents.Workflows.Core; +internal static class IMessageHandlerReflection +{ + private const string Nameof_HandleAsync = nameof(IMessageHandler.HandleAsync); + internal static readonly MethodInfo HandleAsync_1 = typeof(IMessageHandler<>).GetMethod(Nameof_HandleAsync, BindingFlags.Public | BindingFlags.Instance)!; + internal static readonly MethodInfo HandleAsync_2 = typeof(IMessageHandler<,>).GetMethod(Nameof_HandleAsync, BindingFlags.Public | BindingFlags.Instance)!; + + internal static MethodInfo ReflectHandleAsync(this Type specializedType, int genericArgumentCount) + { + Debug.Assert(specializedType.IsGenericType && + (specializedType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || + specializedType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>)), + "specializedType must be an IMessageHandler<> or IMessageHandler<,> type."); + return genericArgumentCount switch + { + 1 => specializedType.GetMethodFromGenericMethodDefinition(HandleAsync_1), + 2 => specializedType.GetMethodFromGenericMethodDefinition(HandleAsync_2), + _ => throw new ArgumentOutOfRangeException(nameof(genericArgumentCount), "Must be 1 or 2.") + }; + } + + internal static int GenericArgumentCount(this Type type) + { + Debug.Assert(type.IsMessageHandlerType(), "type must be an IMessageHandler<> or IMessageHandler<,> type."); + return type.GetGenericArguments().Length; + } + + internal static bool IsMessageHandlerType(this Type type) => + type.IsGenericType && + (type.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || + type.GetGenericTypeDefinition() == typeof(IMessageHandler<,>)); +} + internal static class RouteBuilderExtensions { - [SuppressMessage("Trimming", - "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + - "of the source method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - [SuppressMessage("Trimming", - "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of " + - "method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - private static IEnumerable GetHandlerInfos(this Type executorType) + private static IEnumerable GetHandlerInfos( +#if NET9_0_OR_GREATER + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.Interfaces)] +#endif + this Type executorType) { // Handlers are defined by implementations of IMessageHandler or IMessageHandler Debug.Assert(typeof(Executor).IsAssignableFrom(executorType), "executorType must be an Executor type."); @@ -27,33 +60,37 @@ private static IEnumerable GetHandlerInfos(this Type executo foreach (Type interfaceType in executorType.GetInterfaces()) { // Check if the interface is a message handler. - if (!interfaceType.IsGenericType) + if (!interfaceType.IsMessageHandlerType()) { continue; } - if (interfaceType.IsGenericType && (interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || - interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>))) + // Get the generic arguments of the interface. + Type[] genericArguments = interfaceType.GetGenericArguments(); + if (genericArguments.Length < 1 || genericArguments.Length > 2) + { + continue; // Invalid handler signature. + } + Type inType = genericArguments[0]; + Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; + + MethodInfo? method = interfaceType.ReflectHandleAsync(genericArguments.Length); + + if (method != null) { - // Get the generic arguments of the interface. - Type[] genericArguments = interfaceType.GetGenericArguments(); - if (genericArguments.Length < 1 || genericArguments.Length > 2) - { - continue; // Invalid handler signature. - } - Type inType = genericArguments[0]; - Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; - MethodInfo? method = interfaceType.GetMethod("HandleAsync"); - - if (method != null) - { - yield return new MessageHandlerInfo(method) { InType = inType, OutType = outType }; - } + yield return new MessageHandlerInfo(method) { InType = inType, OutType = outType }; } } } - public static RouteBuilder ReflectHandlers(this RouteBuilder builder, Type executorType, Executor executor) + public static RouteBuilder ReflectHandlers(this RouteBuilder builder, +#if NET9_0_OR_GREATER + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.Interfaces)] +#endif + Type executorType, + Executor executor) { Throw.IfNull(builder); Throw.IfNull(executorType); @@ -68,6 +105,15 @@ public static RouteBuilder ReflectHandlers(this RouteBuilder builder, Type execu return builder; } - public static RouteBuilder ReflectHandlers(this RouteBuilder builder, TExecutor executor) where TExecutor : Executor - => builder.ReflectHandlers(executor.GetType(), executor); + public static RouteBuilder ReflectHandlers< +#if NET9_0_OR_GREATER + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.Interfaces)] +#endif + TExecutor + >(this RouteBuilder builder, TExecutor executor) + { + return builder.ReflectHandlers(typeof(TExecutor), (Executor)(object)executor!); + } } From 776fe62729a73f92b6ab7f5a7cec097272a15f90 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 16:39:58 -0400 Subject: [PATCH 034/232] docs: Add missing docs and fix typos --- .../Microsoft.Agents.Workflows/Core/CallResult.cs | 2 +- dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs index 482a228e1a..79df6aba82 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs @@ -41,7 +41,7 @@ private CallResult(bool isVoid = false) } /// - /// Create a indicating a successful that returned a result (non-void). + /// Create a indicating a successful call that returned a result (non-void). /// /// The result to return. /// A indicating the result of the call. diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs index 4334a0651d..878ac98a09 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs @@ -22,9 +22,9 @@ public record DirectEdgeData( PredicateT? Condition = null) { /// - /// Converts a instance to an using an implicit conversion. + /// Converts a instance to an . /// - /// The to convert to an . Cannot be null. + /// The to convert t. public static implicit operator Edge(DirectEdgeData data) { return new Edge(Throw.IfNull(data)); @@ -44,9 +44,9 @@ public record FanOutEdgeData( PartitionerT? Partitioner = null) { /// - /// . + /// Converts a instance to an . /// - /// + /// The to convert. public static implicit operator Edge(FanOutEdgeData data) { return new Edge(data); @@ -85,9 +85,9 @@ public record FanInEdgeData( internal Guid UniqueKey { get; } = Guid.NewGuid(); /// - /// . + /// Converts a instance to an . /// - /// + /// The to convert. public static implicit operator Edge(FanInEdgeData data) { return new Edge(data); From a4f6974c99300a231dd93e3fb7deccb999d48770 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 16:46:31 -0400 Subject: [PATCH 035/232] feat: Hosted Agents should report Run events --- .../Microsoft.Agents.Workflows/Core/Events.cs | 49 +++---------------- .../Specialized/AIAgentHostExecutor.cs | 1 + 2 files changed, 8 insertions(+), 42 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs index 1bbf5150a5..06d2759d53 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Extensions.AI.Agents; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; @@ -77,44 +78,8 @@ public record ExecutorCompleteEvent : ExecutorEvent public ExecutorCompleteEvent(string executorId, object? result = null) : base(executorId, result) { } } -// TODO: This is a placeholder for streaming chat message content. /// -/// . -/// -public class StreamingChatMessageContent -{ } - -/// -/// . -/// -public record AgentRunStreamingEvent : ExecutorEvent -{ - /// - /// Initializes a new instance of the class. - /// - /// The identifier of the executor that generated this event. - /// - public AgentRunStreamingEvent(string executorId, StreamingChatMessageContent? content = null) : base(executorId, data: content) - { - this.Content = content; - } - - /// - /// Gets the content of the streaming chat message. - /// - public StreamingChatMessageContent? Content { get; } -} - -// TODO: This is a placeholder for non-streaming chat message content. -/// -/// . -/// -public class ChatMessageContent -{ -} - -/// -/// . +/// Event triggered when an agent run is completed. /// public record AgentRunEvent : ExecutorEvent { @@ -122,14 +87,14 @@ public record AgentRunEvent : ExecutorEvent /// Initializes a new instance of the class. /// /// The identifier of the executor that generated this event. - /// - public AgentRunEvent(string executorId, ChatMessageContent? content = null) : base(executorId, data: content) + /// + public AgentRunEvent(string executorId, AgentRunResponse? response = null) : base(executorId, data: response) { - this.Content = content; + this.Response = response; } /// - /// Gets the content of the chat message. + /// Gets the content of the agent response. /// - public ChatMessageContent? Content { get; } + public AgentRunResponse? Response { get; } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs index 2f156d9191..15129858a6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs @@ -26,6 +26,7 @@ public async ValueTask HandleAsync(IList message, IWorkflowContext // incremental updates from the chat model. AgentRunResponse runResponse = await this.Agent.RunAsync(messageList).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunEvent(this.Id, runResponse)).ConfigureAwait(false); await context.SendMessageAsync(runResponse).ConfigureAwait(false); } } From 6db62a35300dd0ff5d73e01e7bca86e90cdd8286 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 17:17:19 -0400 Subject: [PATCH 036/232] fix: Fix type propagation for ILTrim changes --- .../Microsoft.Agents.Workflows/Core/Events.cs | 4 +-- .../Core/Executor.cs | 34 ++++++++++++++---- .../Core/IWorkflowContext.cs | 2 +- .../Core/MessageHandlerInfo.cs | 9 +++-- .../Core/RouteBuilder.cs | 4 +-- .../Core/RouteBuilderExtensions.cs | 36 +++++-------------- .../Core/Workflow.cs | 8 ++--- .../Execution/DirectEdgeRunner.cs | 4 +-- .../Execution/FanInEdgeRunner.cs | 4 +-- .../Execution/FanOutEdgeRunner.cs | 4 +-- .../Execution/IRunnerContext.cs | 2 +- .../Execution/InputEdgeRunner.cs | 4 +-- .../Execution/LocalRunnerContext.cs | 6 ++-- .../Microsoft.Agents.Workflows/ExecutorIsh.cs | 18 +++++----- .../Microsoft.Agents.Workflows.csproj | 2 ++ .../Specialized/AIAgentHostExecutor.cs | 2 +- .../Specialized/OutputCollectorExecutor.cs | 14 ++++---- .../Specialized/RequestInputExecutor.cs | 2 +- .../WorkflowBuilder.cs | 14 ++++---- .../ReflectionSmokeTest.cs | 16 ++++----- .../Sample/01_Simple_Workflow_Sequential.cs | 4 +-- .../Sample/02_Simple_Workflow_Condition.cs | 6 ++-- .../Sample/03_Simple_Workflow_Loop.cs | 4 +-- 23 files changed, 107 insertions(+), 96 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs index 06d2759d53..0581b12ca6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs @@ -19,7 +19,7 @@ public record WorkflowStartedEvent : WorkflowEvent; /// Event triggered when a workflow completes execution. /// /// -/// The user is expected to raise this event from a terminating , or to build +/// The user is expected to raise this event from a terminating , or to build /// the workflow with output capture using . /// public record WorkflowCompletedEvent : WorkflowEvent; @@ -30,7 +30,7 @@ public record WorkflowCompletedEvent : WorkflowEvent; public record RequestInputEvent(ExternalRequest Request) : WorkflowEvent; /// -/// Base class for -scoped events. +/// Base class for -scoped events. /// public record ExecutorEvent : WorkflowEvent { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs index 8081bb9af3..284f656a2d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Threading.Tasks; @@ -12,7 +13,7 @@ namespace Microsoft.Agents.Workflows.Core; /// A component that processes messages in a . /// [DebuggerDisplay("{GetType().Name}{Id}")] -public abstract class Executor : IIdentified, IAsyncDisposable +public abstract class ExecutorBase : IIdentified, IAsyncDisposable { /// /// A unique identifier for the executor. @@ -26,7 +27,7 @@ public abstract class Executor : IIdentified, IAsyncDisposable /// /// A optional unique identifier for the executor. If null, a type-tagged /// UUID will be generated. - protected Executor(string? id = null) + protected ExecutorBase(string? id = null) { this.Id = id ?? $"{this.GetType().Name}/{Guid.NewGuid():N}"; } @@ -35,10 +36,7 @@ protected Executor(string? id = null) /// Override this method to register handlers for the executor. The deafult implementation uses reflection to /// look for implementations of and . /// - protected virtual RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) - { - return routeBuilder.ReflectHandlers(this); - } + protected abstract RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder); private MessageRouter? _router = null; internal MessageRouter Router @@ -145,3 +143,27 @@ ValueTask IAsyncDisposable.DisposeAsync() return this.DisposeAsync(); } } + +/// +/// A component that processes messages in a . +/// +/// The actual type of the . +/// This is used to reflectively discover handlers for messages without violating ILTrim requirements. +/// +public class Executor< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.Interfaces)] +TExecutor + > : ExecutorBase where TExecutor : Executor +{ + /// + protected Executor(string? id = null) : base(id) + { } + + /// + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + return routeBuilder.ReflectHandlers(this); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs index bf5528db9c..49495ca19f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs @@ -5,7 +5,7 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// Provides services for an during the execution of a workflow. +/// Provides services for an during the execution of a workflow. /// public interface IWorkflowContext { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs index da55f649c2..2b9d55fca9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs @@ -17,8 +17,6 @@ internal struct MessageHandlerInfo public MethodInfo HandlerInfo { get; init; } public Func>? Unwrapper { get; init; } = null; - [SuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality " + - "when AOT compiling.", Justification = "")] public MessageHandlerInfo(MethodInfo handlerInfo) { // The method is one of the following: @@ -118,7 +116,12 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext } } - public Func> Bind(Executor executor, bool checkType = false) + public Func> Bind< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.Interfaces)] TExecutor> + (Executor executor, bool checkType = false) + where TExecutor : Executor { MethodInfo handlerMethod = this.HandlerInfo; return MessageHandlerInfo.Bind(InvokeHandler, checkType, this.OutType, this.Unwrapper); diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs index 467a374bfa..b7d5bc22b5 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs @@ -15,10 +15,10 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// Provides a builder for configuring message type handlers for an . +/// Provides a builder for configuring message type handlers for an . /// /// -/// Override the method to customize the routing of messages to handlers. By +/// Override the method to customize the routing of messages to handlers. By /// default, uses reflection to find implementations of and /// . /// diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs index fc4c3c9845..91d594686e 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs @@ -3,13 +3,10 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using Microsoft.Shared.Diagnostics; -#if NET9_0_OR_GREATER -using System.Diagnostics.CodeAnalysis; -#endif - namespace Microsoft.Agents.Workflows.Core; internal static class IMessageHandlerReflection @@ -47,15 +44,13 @@ internal static bool IsMessageHandlerType(this Type type) => internal static class RouteBuilderExtensions { private static IEnumerable GetHandlerInfos( -#if NET9_0_OR_GREATER [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods | DynamicallyAccessedMemberTypes.Interfaces)] -#endif this Type executorType) { // Handlers are defined by implementations of IMessageHandler or IMessageHandler - Debug.Assert(typeof(Executor).IsAssignableFrom(executorType), "executorType must be an Executor type."); + Debug.Assert(typeof(ExecutorBase).IsAssignableFrom(executorType), "executorType must be an Executor type."); foreach (Type interfaceType in executorType.GetInterfaces()) { @@ -83,19 +78,18 @@ private static IEnumerable GetHandlerInfos( } } - public static RouteBuilder ReflectHandlers(this RouteBuilder builder, -#if NET9_0_OR_GREATER + public static RouteBuilder ReflectHandlers< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods | - DynamicallyAccessedMemberTypes.Interfaces)] -#endif - Type executorType, - Executor executor) + DynamicallyAccessedMemberTypes.Interfaces)] TExecutor> + (this RouteBuilder builder, Executor executor) + where TExecutor : Executor { Throw.IfNull(builder); - Throw.IfNull(executorType); - Debug.Assert(typeof(Executor).IsAssignableFrom(executorType), "executorType must be an Executor type."); + Type executorType = typeof(TExecutor); + Debug.Assert(executorType.IsAssignableFrom(executor.GetType()), + "executorType must be the same type or a base type of the executor instance."); foreach (MessageHandlerInfo handlerInfo in executorType.GetHandlerInfos()) { @@ -104,16 +98,4 @@ public static RouteBuilder ReflectHandlers(this RouteBuilder builder, return builder; } - - public static RouteBuilder ReflectHandlers< -#if NET9_0_OR_GREATER - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | - DynamicallyAccessedMemberTypes.NonPublicMethods | - DynamicallyAccessedMemberTypes.Interfaces)] -#endif - TExecutor - >(this RouteBuilder builder, TExecutor executor) - { - return builder.ReflectHandlers(typeof(TExecutor), (Executor)(object)executor!); - } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs index 0376119559..bf7c5ca3d0 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs @@ -15,7 +15,7 @@ public class Workflow /// /// A dictionary of executor providers, keyed by executor ID. /// - public Dictionary> ExecutorProviders { get; internal init; } = new(); + public Dictionary> ExecutorProviders { get; internal init; } = new(); /// /// Gets the collection of edges grouped by their source node identifier. @@ -67,7 +67,7 @@ public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) { } - internal Workflow Promote(OutputSink outputSource) + internal Workflow Promote(IOutputSink outputSource) { Throw.IfNull(outputSource); @@ -88,9 +88,9 @@ internal Workflow Promote(OutputSink outputSource) /// The type of the output from the workflow. public class Workflow : Workflow { - private readonly OutputSink _output; + private readonly IOutputSink _output; - internal Workflow(string startExecutorId, OutputSink outputSource) + internal Workflow(string startExecutorId, IOutputSink outputSource) : base(startExecutorId) { this._output = Throw.IfNull(outputSource); diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/DirectEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/DirectEdgeRunner.cs index df70b2b620..03ba90dbaf 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/DirectEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/DirectEdgeRunner.cs @@ -11,7 +11,7 @@ internal class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData edgeDa { public IWorkflowContext WorkflowContext { get; } = runContext.Bind(edgeData.SinkId); - private async ValueTask FindRouterAsync() + private async ValueTask FindRouterAsync() { return await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) .ConfigureAwait(false); @@ -24,7 +24,7 @@ private async ValueTask FindRouterAsync() return []; } - Executor target = await this.FindRouterAsync().ConfigureAwait(false); + ExecutorBase target = await this.FindRouterAsync().ConfigureAwait(false); if (target.CanHandle(message.GetType())) { return [await target.ExecuteAsync(message, this.WorkflowContext).ConfigureAwait(false)]; diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeRunner.cs index 9b790bd0e6..ed7847a1c4 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeRunner.cs @@ -22,8 +22,8 @@ internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData return null; } - Executor target = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) - .ConfigureAwait(false); + ExecutorBase target = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); if (target.CanHandle(message.GetType())) { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs index 7a21accf8b..e8508b90c9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs @@ -27,8 +27,8 @@ internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeDa async Task ProcessTargetAsync(string targetId) { - Executor executor = await this.RunContext.EnsureExecutorAsync(targetId) - .ConfigureAwait(false); + ExecutorBase executor = await this.RunContext.EnsureExecutorAsync(targetId) + .ConfigureAwait(false); if (executor.CanHandle(message.GetType())) { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerContext.cs index 692abdf3af..59d9afdb02 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerContext.cs @@ -14,5 +14,5 @@ internal interface IRunnerContext : IExternalRequestSink StepContext Advance(); IWorkflowContext Bind(string executorId); - ValueTask EnsureExecutorAsync(string executorId); + ValueTask EnsureExecutorAsync(string executorId); } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/InputEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/InputEdgeRunner.cs index bfe002b9bd..19dd1a3be9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/InputEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/InputEdgeRunner.cs @@ -19,14 +19,14 @@ public static InputEdgeRunner ForPort(IRunnerContext runContext, InputPort port) return new InputEdgeRunner(runContext, port.Id); } - private async ValueTask FindExecutorAsync() + private async ValueTask FindExecutorAsync() { return await this.RunContext.EnsureExecutorAsync(this.EdgeData).ConfigureAwait(false); } public async ValueTask ChaseAsync(object message) { - Executor target = await this.FindExecutorAsync().ConfigureAwait(false); + ExecutorBase target = await this.FindExecutorAsync().ConfigureAwait(false); if (target.CanHandle(message.GetType())) { return await target.ExecuteAsync(message, this.WorkflowContext) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs index 71ccc6d1d1..7b4786cd22 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs @@ -15,8 +15,8 @@ namespace Microsoft.Agents.Workflows.Execution; internal class LocalRunnerContext : IRunnerContext { private StepContext _nextStep = new(); - private readonly Dictionary> _executorProviders; - private readonly Dictionary _executors = new(); + private readonly Dictionary> _executorProviders; + private readonly Dictionary _executors = new(); private readonly Dictionary _externalRequests = new(); public LocalRunnerContext(Workflow workflow, ILogger? logger = null) @@ -24,7 +24,7 @@ public LocalRunnerContext(Workflow workflow, ILogger? logger = null) this._executorProviders = Throw.IfNull(workflow).ExecutorProviders; } - public async ValueTask EnsureExecutorAsync(string executorId) + public async ValueTask EnsureExecutorAsync(string executorId) { if (!this._executors.TryGetValue(executorId, out var executor)) { diff --git a/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs index 428fec3491..7c11ca15ee 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.Workflows; /// -/// A tagged union representing an object that can function like an in a , +/// A tagged union representing an object that can function like an in a , /// or a reference to one by ID. /// public sealed class ExecutorIsh : @@ -47,14 +47,14 @@ public enum Type public Type ExecutorType { get; init; } private readonly string? _idValue; - private readonly Executor? _executorValue; + private readonly ExecutorBase? _executorValue; internal readonly InputPort? _inputPortValue; private readonly AIAgent? _aiAgentValue; /// /// Initializes a new instance of the class as an unbound reference by ID. /// - /// A unique identifier for an in the + /// A unique identifier for an in the public ExecutorIsh(string id) { this.ExecutorType = Type.Unbound; @@ -65,7 +65,7 @@ public ExecutorIsh(string id) /// Initializes a new instance of the ExecutorIsh class using the specified executor. /// /// The executor instance to be wrapped. - public ExecutorIsh(Executor executor) + public ExecutorIsh(ExecutorBase executor) { this.ExecutorType = Type.Executor; this._executorValue = Throw.IfNull(executor); @@ -104,10 +104,10 @@ public ExecutorIsh(AIAgent aiAgent) }; /// - /// Gets an that can be used to obtain an instance + /// Gets an that can be used to obtain an instance /// corresponding to this . /// - public ExecutorProvider ExecutorProvider => this.ExecutorType switch + public ExecutorProvider ExecutorProvider => this.ExecutorType switch { Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), Type.Executor => () => this._executorValue!, @@ -117,10 +117,10 @@ public ExecutorIsh(AIAgent aiAgent) }; /// - /// Defines an implicit conversion from an instance to an object. + /// Defines an implicit conversion from an instance to an object. /// - /// The instance to convert to . - public static implicit operator ExecutorIsh(Executor executor) => new(executor); + /// The instance to convert to . + public static implicit operator ExecutorIsh(ExecutorBase executor) => new(executor); /// /// Defines an implicit conversion from an to an instance. diff --git a/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj b/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj index 4cd49dd698..4af22a5e8b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj @@ -9,6 +9,8 @@ true true + true + diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs index 15129858a6..ab29118d03 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.Workflows.Specialized; -internal class AIAgentHostExecutor : Executor, IMessageHandler> +internal class AIAgentHostExecutor : Executor, IMessageHandler> { private AIAgent Agent { get; set; } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs index a1a6099572..2cabca3bda 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs @@ -7,19 +7,21 @@ namespace Microsoft.Agents.Workflows.Specialized; -internal class OutputSink : Executor +internal interface IOutputSink { - public TResult? Result { get; protected set; } = default; - - internal OutputSink(string? id = null) : base(id) - { } + TResult? Result { get; } } -internal class OutputCollectorExecutor : OutputSink, IMessageHandler +internal class OutputCollectorExecutor : + Executor>, + IMessageHandler, + IOutputSink { private readonly StreamingAggregator _aggregator; private readonly Func? _completionCondition; + public TResult? Result { get; private set; } + public OutputCollectorExecutor(StreamingAggregator aggregator, Func? completionCondition = null, string? id = null) : base(id) { this._aggregator = Throw.IfNull(aggregator); diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInputExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInputExecutor.cs index 9cd3a8e2c9..20b5968639 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInputExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInputExecutor.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.Workflows.Specialized; -internal class RequestInputExecutor : Executor, IMessageHandler, IMessageHandler +internal class RequestInputExecutor : Executor, IMessageHandler, IMessageHandler { private InputPort Port { get; } private IExternalRequestSink? RequestSink { get; set; } diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs index da80121a36..e005d08caa 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs @@ -14,7 +14,7 @@ namespace Microsoft.Agents.Workflows; /// The executor type. /// A new instance. public delegate TExecutor ExecutorProvider() - where TExecutor : Executor; + where TExecutor : ExecutorBase; /// /// Provides a builder for constructing and configuring a workflow by defining executors and the connections between @@ -31,7 +31,7 @@ private record struct EdgeId(string SourceId, string TargetId) public override string ToString() => $"{this.SourceId} -> {this.TargetId}"; } - private readonly Dictionary> _executors = new(); + private readonly Dictionary> _executors = new(); private readonly Dictionary> _edges = new(); private readonly HashSet _unboundExecutors = new(); private readonly HashSet _conditionlessEdges = new(); @@ -50,7 +50,7 @@ public WorkflowBuilder(ExecutorIsh start) private ExecutorIsh Track(ExecutorIsh executorish) { - ExecutorProvider provider = executorish.ExecutorProvider; + ExecutorProvider provider = executorish.ExecutorProvider; // If the executor is unbound, create an entry for it, unless it already exists. // Otherwise, update the entry for it, and remove the unbound tag @@ -75,7 +75,7 @@ private ExecutorIsh Track(ExecutorIsh executorish) return executorish; } - private void UpdateExecutor(string id, ExecutorProvider provider) + private void UpdateExecutor(string id, ExecutorProvider provider) { this._executors[id] = provider; } @@ -86,7 +86,7 @@ private void UpdateExecutor(string id, ExecutorProvider provider) /// The executor instance to bind. The executor must exist in the workflow and not be already bound. /// The current instance, enabling fluent configuration. /// Thrown if the specified executor is already bound or does not exist in the workflow. - public WorkflowBuilder BindExecutor(Executor executor) + public WorkflowBuilder BindExecutor(ExecutorBase executor) { if (!this._unboundExecutors.Contains(executor.Id)) { @@ -216,14 +216,14 @@ public Workflow Build() } // Grab the start node, and make sure it has the right type? - if (!this._executors.TryGetValue(this._startExecutorId, out ExecutorProvider? startProvider)) + if (!this._executors.TryGetValue(this._startExecutorId, out ExecutorProvider? startProvider)) { // TODO: This should never be able to be hit throw new InvalidOperationException($"Start executor with ID '{this._startExecutorId}' is not bound."); } // TODO: Delay-instantiate the start executor, and ensure it is of type T. - Executor startExecutor = startProvider(); + ExecutorBase startExecutor = startProvider(); if (!startExecutor.InputTypes.Any(t => t.IsAssignableFrom(typeof(T)))) { diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs index 5430e30d94..fde763b3b0 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.Workflows.UnitTests; -public class BaseTestExecutor : Executor +public class BaseTestExecutor : Executor where TActual : Executor { protected void OnInvokedHandler() { @@ -21,7 +21,7 @@ public bool InvokedHandler } = false; } -public class DefaultHandler : BaseTestExecutor, IMessageHandler +public class DefaultHandler : BaseTestExecutor, IMessageHandler { public ValueTask HandleAsync(object message, IWorkflowContext context) { @@ -36,7 +36,7 @@ public Func Handler } = (message, context) => default; } -public class TypedHandler : BaseTestExecutor, IMessageHandler +public class TypedHandler : BaseTestExecutor>, IMessageHandler { public ValueTask HandleAsync(TInput message, IWorkflowContext context) { @@ -51,7 +51,7 @@ public Func Handler } = (message, context) => default; } -public class TypedHandlerWithOutput : BaseTestExecutor, IMessageHandler +public class TypedHandlerWithOutput : BaseTestExecutor>, IMessageHandler { public ValueTask HandleAsync(TInput message, IWorkflowContext context) { @@ -67,7 +67,7 @@ public Func> Handler public class RoutingReflectionTests { - private async ValueTask RunTestReflectAndRouteMessageAsync(BaseTestExecutor executor, TInput? input = default) where TInput : new() + private async ValueTask RunTestReflectAndRouteMessageAsync(BaseTestExecutor executor, TInput? input = default) where TInput : new() where TE : Executor { MessageRouter router = executor.Router; @@ -88,7 +88,7 @@ public async Task Test_ReflectAndExecute_DefaultHandlerAsync() { DefaultHandler executor = new(); - CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor); + CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor); Assert.NotNull(result); Assert.True(result.IsSuccess); @@ -102,7 +102,7 @@ public async Task Test_ReflectAndExecute_HandlerReturnsVoidAsync() { TypedHandler executor = new(); - CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor, 3); + CallResult? result = await this.RunTestReflectAndRouteMessageAsync>(executor, 3); Assert.NotNull(result); Assert.True(result.IsSuccess); @@ -123,7 +123,7 @@ public async Task Test_ReflectAndExecute_HandlerReturnsValueAsync() }; const string Expected = "3"; - CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor, int.Parse(Expected)); + CallResult? result = await this.RunTestReflectAndRouteMessageAsync>(executor, int.Parse(Expected)); Assert.NotNull(result); Assert.True(result.IsSuccess); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs index 5af83b752a..cf74362684 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs @@ -32,7 +32,7 @@ public static async ValueTask RunAsync(TextWriter writer) } } -internal sealed class UppercaseExecutor() : Executor("UppercaseExecutor"), IMessageHandler +internal sealed class UppercaseExecutor() : Executor("UppercaseExecutor"), IMessageHandler { public async ValueTask HandleAsync(string message, IWorkflowContext context) { @@ -43,7 +43,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext cont } } -internal sealed class ReverseTextExecutor() : Executor("ReverseTextExecutor"), IMessageHandler +internal sealed class ReverseTextExecutor() : Executor("ReverseTextExecutor"), IMessageHandler { public async ValueTask HandleAsync(string message, IWorkflowContext context) { diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs index c6f52d7166..62365d6beb 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs @@ -46,7 +46,7 @@ public static async ValueTask RunAsync(TextWriter writer, string input = } } -internal sealed class DetectSpamExecutor : Executor, IMessageHandler +internal sealed class DetectSpamExecutor : Executor, IMessageHandler { public string[] SpamKeywords { get; } @@ -68,7 +68,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext contex } } -internal sealed class RespondToMessageExecutor : Executor, IMessageHandler +internal sealed class RespondToMessageExecutor : Executor, IMessageHandler { public const string ActionResult = "Message processed successfully."; @@ -87,7 +87,7 @@ await context.AddEventAsync(new WorkflowCompletedEvent { Data = RespondToMessage } } -internal sealed class RemoveSpamExecutor : Executor, IMessageHandler +internal sealed class RemoveSpamExecutor : Executor, IMessageHandler { public const string ActionResult = "Spam message removed."; diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs index ead24833e4..5bfabb92f2 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs @@ -50,7 +50,7 @@ internal enum NumberSignal Matched } -internal sealed class GuessNumberExecutor : Executor, IMessageHandler +internal sealed class GuessNumberExecutor : Executor, IMessageHandler { public int LowerBound { get; private set; } public int UpperBound { get; private set; } @@ -87,7 +87,7 @@ await context.AddEventAsync(new WorkflowCompletedEvent { Data = $"Guessed the nu } } -internal sealed class JudgeExecutor : Executor, IMessageHandler +internal sealed class JudgeExecutor : Executor, IMessageHandler { private readonly int _targetNumber; From 77919f9a878b9c9b1fc5d2bc4773dac54627fbb8 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Fri, 8 Aug 2025 11:15:08 -0400 Subject: [PATCH 037/232] refactor: Simplify DynamicallyAccessedMembers annotations --- dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs | 7 +++---- .../Core/MessageHandlerInfo.cs | 7 ++++--- .../Core/ReflectionExtensions.cs | 9 +++++++++ .../Core/RouteBuilderExtensions.cs | 10 ++++------ 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs index 284f656a2d..c6a8f7825c 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs @@ -151,10 +151,9 @@ ValueTask IAsyncDisposable.DisposeAsync() /// This is used to reflectively discover handlers for messages without violating ILTrim requirements. /// public class Executor< - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | - DynamicallyAccessedMemberTypes.NonPublicMethods | - DynamicallyAccessedMemberTypes.Interfaces)] -TExecutor + [DynamicallyAccessedMembers( + ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation) + ] TExecutor > : ExecutorBase where TExecutor : Executor { /// diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs index 2b9d55fca9..07a41ef5aa 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs @@ -117,9 +117,10 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext } public Func> Bind< - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | - DynamicallyAccessedMemberTypes.NonPublicMethods | - DynamicallyAccessedMemberTypes.Interfaces)] TExecutor> + [DynamicallyAccessedMembers( + ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation) + ] TExecutor + > (Executor executor, bool checkType = false) where TExecutor : Executor { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs index 8ec77241db..30ceb72f61 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Reflection; #if !NET @@ -10,6 +11,14 @@ namespace Microsoft.Agents.Workflows.Core; +internal static class ReflectionDemands +{ + internal const DynamicallyAccessedMemberTypes ReflectedMethods = DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods; + internal const DynamicallyAccessedMemberTypes ReflectedInterfaces = DynamicallyAccessedMemberTypes.Interfaces; + + internal const DynamicallyAccessedMemberTypes RuntimeInterfaceDiscoveryAndInvocation = ReflectedMethods | ReflectedInterfaces; +} + internal static class ReflectionExtensions { public static object? ReflectionInvoke(this MethodInfo method, object? target, params object?[] arguments) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs index 91d594686e..deebf89599 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs @@ -44,9 +44,7 @@ internal static bool IsMessageHandlerType(this Type type) => internal static class RouteBuilderExtensions { private static IEnumerable GetHandlerInfos( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | - DynamicallyAccessedMemberTypes.NonPublicMethods | - DynamicallyAccessedMemberTypes.Interfaces)] + [DynamicallyAccessedMembers(ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation)] this Type executorType) { // Handlers are defined by implementations of IMessageHandler or IMessageHandler @@ -79,9 +77,9 @@ private static IEnumerable GetHandlerInfos( } public static RouteBuilder ReflectHandlers< - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | - DynamicallyAccessedMemberTypes.NonPublicMethods | - DynamicallyAccessedMemberTypes.Interfaces)] TExecutor> + [DynamicallyAccessedMembers( + ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation) + ] TExecutor> (this RouteBuilder builder, Executor executor) where TExecutor : Executor { From 76191181882fd8a1e216786fab1d2969df365953 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Fri, 8 Aug 2025 11:15:53 -0400 Subject: [PATCH 038/232] sample: Use static-Type construction of InputPort --- .../Sample/05_Simple_Workflow_ExternalRequest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs index 548790cc27..10412f377a 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs @@ -12,7 +12,7 @@ internal static class Step5EntryPoint { public static async ValueTask RunAsync(TextWriter writer, Func userGuessCallback) { - InputPort guessNumber = new("GuessNumber", typeof(NumberSignal), typeof(int)); + InputPort guessNumber = InputPort.Create("GuessNumber"); JudgeExecutor judge = new(42); // Let's say the target number is 42 Workflow workflow = new WorkflowBuilder(guessNumber) From 570147783471df6d15e90472f010ff0ab14cdf19 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Fri, 8 Aug 2025 12:23:18 -0400 Subject: [PATCH 039/232] feat: Support non-Streaming Run Mode --- .../Execution/LocalRunner.cs | 59 +++++-- .../Execution/Run.cs | 155 ++++++++++++++++++ ...mingExecutionHandle.cs => StreamingRun.cs} | 62 +++++-- .../Sample/02_Simple_Workflow_Condition.cs | 2 +- .../Sample/03_Simple_Workflow_Loop.cs | 2 +- .../05_Simple_Workflow_ExternalRequest.cs | 2 +- 6 files changed, 249 insertions(+), 33 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflows/Execution/Run.cs rename dotnet/src/Microsoft.Agents.Workflows/Execution/{StreamingExecutionHandle.cs => StreamingRun.cs} (74%) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs index 729b95bedb..3210169abc 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs @@ -86,24 +86,40 @@ private bool IsResponse(object message) /// /// Initiates an asynchronous streaming execution using the specified input. /// - /// The returned provides methods to observe and control + /// The returned provides methods to observe and control /// the ongoing streaming execution. The operation will continue until the streaming execution is finished or /// cancelled. - /// The input message to be processed as part of the streaming execution. + /// The input message to be processed as part of the streaming run. /// A that can be used to cancel the streaming operation. - /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming execution. - public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) + /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. + public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) { await this.RunContext.AddExternalMessageAsync(input).ConfigureAwait(false); - return new StreamingExecutionHandle(this); + return new StreamingRun(this); + } + + /// + /// Initiates a non-streaming execution of the workflow with the specified input. + /// + /// The workflow will run until its first halt, and the returned will capture + /// all outgoing events. Use the Run instance to resume execution with responses to outgoing events. + /// The input message to be processed as part of the run. + /// A that can be used to cancel the streaming operation. + /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. + public async ValueTask RunAsync(TInput input, CancellationToken cancellation = default) + { + StreamingRun streamingRun = await this.StreamAsync(input, cancellation).ConfigureAwait(false); + cancellation.ThrowIfCancellationRequested(); + + return await Run.CaptureStreamAsync(streamingRun, cancellation).ConfigureAwait(false); } bool ISuperStepRunner.HasUnservicedRequests => this.RunContext.HasUnservicedRequests; bool ISuperStepRunner.HasUnprocessedMessages => this.RunContext.NextStepHasActions; - //private StepContext? _currentStep = null; async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cancellation) { cancellation.ThrowIfCancellationRequested(); @@ -185,18 +201,35 @@ public LocalRunner(Workflow workflow) /// /// Initiates an asynchronous streaming execution for the specified input. /// - /// The returned can be used to retrieve results + /// The returned can be used to retrieve results /// as they become available. If the operation is cancelled via the token, the /// streaming execution will be terminated. - /// The input value to be processed by the streaming execution. + /// The input value to be processed by the streaming run. /// A that can be used to cancel the streaming operation. - /// A that provides access to the results of the streaming - /// execution. - public async ValueTask> StreamAsync(TInput input, CancellationToken cancellation = default) + /// A that provides access to the results of the streaming + /// run. + public async ValueTask> StreamAsync(TInput input, CancellationToken cancellation = default) { await this._innerRunner.EnqueueMessageAsync(input).ConfigureAwait(false); - return new StreamingExecutionHandle(this); + return new StreamingRun(this); + } + + /// + /// Initiates a non-streaming execution of the workflow with the specified input. + /// + /// The workflow will run until its first halt, and the returned will capture + /// all outgoing events. Use the Run instance to resume execution with responses to outgoing events. + /// The input message to be processed as part of the run. + /// A that can be used to cancel the streaming operation. + /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. + public async ValueTask RunAsync(TInput input, CancellationToken cancellation = default) + { + StreamingRun streamingRun = await this.StreamAsync(input, cancellation).ConfigureAwait(false); + cancellation.ThrowIfCancellationRequested(); + + return await Run.CaptureStreamAsync(streamingRun, cancellation).ConfigureAwait(false); } /// diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/Run.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/Run.cs new file mode 100644 index 0000000000..8da8ccceeb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/Run.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +/// +/// Specifies the current operational state of a workflow run. +/// +public enum RunStatus +{ + /// + /// The run has halted, has no outstanding requets, but has not received a . + /// + Idle, + + /// + /// The run has halted, and has at least one outstanding . + /// + PendingRequests, + + /// + /// The run has halted after receiving a . + /// + Completed, + + /// + /// The workflow is currently running, and may receive events or requests. + /// + Running +} + +/// +/// Represents a workflow run that tracks execution status and emitted workflow events, supporting resumption +/// with responses to . +/// +public class Run +{ + internal static async ValueTask CaptureStreamAsync(StreamingRun run, CancellationToken cancellation = default) + { + Run result = new(run); + await result.RunToNextHaltAsync(cancellation).ConfigureAwait(false); + return result; + } + + private readonly List _eventSink = new(); + private readonly StreamingRun _streamingRun; + internal Run(StreamingRun streamingRun) + { + this._streamingRun = streamingRun; + } + + internal async ValueTask RunToNextHaltAsync(CancellationToken cancellation = default) + { + bool hadEvents = false; + bool hadCompletion = false; + this.Status = RunStatus.Running; + await foreach (WorkflowEvent evt in this._streamingRun.WatchStreamAsync(blockOnPendingRequest: false, cancellation).ConfigureAwait(false)) + { + hadEvents = true; + if (evt is WorkflowCompletedEvent) + { + hadCompletion = true; + } + + this._eventSink.Add(evt); + } + + // TODO: bookmark every halt for history visualization? + + this.Status = + hadCompletion + ? RunStatus.Completed + : this._streamingRun.HasUnservicedRequests + ? RunStatus.PendingRequests + : RunStatus.Idle; + + return hadEvents; + } + + /// + /// Gets the current execution status of the workflow run. + /// + public RunStatus Status { get; private set; } + + /// + /// Gets all events emitted by the workflow. + /// + public IEnumerable OutgoingEvents => this._eventSink; + + private int _lastBookmark = 0; + + /// + /// Gets all events emitted by the workflow since the last access to . + /// + public IEnumerable NewEvents + { + get + { + if (this._lastBookmark >= this._eventSink.Count) + { + return []; + } + + int currentBookmark = this._lastBookmark; + this._lastBookmark = this._eventSink.Count; + + return this._eventSink.Skip(currentBookmark); + } + } + + /// + /// Resume execution of the workflow with the provided external responses. + /// + /// A that can be used to cancel the workflow execution. + /// An array of objects to send to the workflow. + /// true if the workflow had any output events, false otherwise. + public async ValueTask ResumeAsync(CancellationToken cancellation = default, params ExternalResponse[] responses) + { + foreach (ExternalResponse response in responses) + { + await this._streamingRun.SendResponseAsync(response).ConfigureAwait(false); + } + + return await this.RunToNextHaltAsync(cancellation).ConfigureAwait(false); + } +} + +/// +/// Represents a workflow run that tracks execution status and emitted workflow events, supporting resumption +/// with responses to , and retrieval of the running output of the workflow. +/// +/// The type of the workflow output. +public sealed class Run : Run +{ + internal static async ValueTask> CaptureStreamAsync(StreamingRun run, CancellationToken cancellation = default) + { + Run result = new(run); + await result.RunToNextHaltAsync(cancellation).ConfigureAwait(false); + return result; + } + + private readonly StreamingRun _streamingRun; + private Run(StreamingRun streamingRun) : base(streamingRun) + { + this._streamingRun = streamingRun; + } + + /// + public TResult? RunningOutput => this._streamingRun.RunningOutput; +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingRun.cs similarity index 74% rename from dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingExecutionHandle.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingRun.cs index 6a93989a85..dfb274d6e9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingRun.cs @@ -11,15 +11,21 @@ namespace Microsoft.Agents.Workflows.Execution; /// -/// Provides a handle for managing and interacting with a streaming workflow execution, enabling asynchronous response -/// delivery and event monitoring. +/// A run instance supporting a streaming form of receiving workflow events, and providing +/// a mechanism to send responses back to the workflow. /// -public class StreamingExecutionHandle +public class StreamingRun { private TaskCompletionSource? _waitForResponseSource = null; private readonly ISuperStepRunner _stepRunner; - internal StreamingExecutionHandle(ISuperStepRunner stepRunner) + /// + /// Gets a value indicating whether there are any outstanding s for which a + /// has not been sent. + /// + public bool HasUnservicedRequests => this._stepRunner.HasUnservicedRequests; + + internal StreamingRun(ISuperStepRunner stepRunner) { this._stepRunner = Throw.IfNull(stepRunner); } @@ -49,7 +55,13 @@ public ValueTask SendResponseAsync(ExternalResponse response) /// requested, the stream will end and no further events will be yielded. /// An asynchronous stream of objects representing significant workflow state changes. /// The stream ends when the workflow completes or when cancellation is requested. - public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancellation] CancellationToken cancellation = default) + public IAsyncEnumerable WatchStreamAsync( + CancellationToken cancellation = default) + => this.WatchStreamAsync(blockOnPendingRequest: true, cancellation); + + internal async IAsyncEnumerable WatchStreamAsync( + bool blockOnPendingRequest, + [EnumeratorCancellation] CancellationToken cancellation = default) { List eventSink = new(); @@ -61,6 +73,10 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell { // Drain SuperSteps while there are steps to run await this._stepRunner.RunSuperStepAsync(cancellation).ConfigureAwait(false); + if (cancellation.IsCancellationRequested) + { + yield break; // Exit if cancellation is requested + } bool hadCompletionEvent = false; List outputEvents = Interlocked.Exchange(ref eventSink, new()); @@ -68,6 +84,11 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell { yield return raisedEvent; + if (cancellation.IsCancellationRequested) + { + yield break; // Exit if cancellation is requested + } + // TODO: Do we actually want to interpret this as a termination request? if (raisedEvent is WorkflowCompletedEvent) { @@ -84,7 +105,8 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell // If we do not have any actions to take on the Workflow, but have unprocessed // requests, wait for the responses to come in before exiting out of the workflow // execution. - if (!this._stepRunner.HasUnprocessedMessages && + if (blockOnPendingRequest && + !this._stepRunner.HasUnprocessedMessages && this._stepRunner.HasUnservicedRequests) { if (this._waitForResponseSource == null) @@ -92,6 +114,11 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell this._waitForResponseSource = new(); } + using CancellationTokenRegistration registration = cancellation.Register(() => + { + this._waitForResponseSource?.SetResult(new()); + }); + await this._waitForResponseSource.Task.ConfigureAwait(false); this._waitForResponseSource = null; } @@ -110,14 +137,15 @@ void OnWorkflowEvent(object? sender, WorkflowEvent e) } /// -/// Represents a handle for managing and retrieving the result of a streaming execution operation. +/// A run instance supporting a streaming form of receiving workflow events, providing +/// a mechanism to send responses back to the workflow, and retrieving the result of workflow execution. /// -/// -public class StreamingExecutionHandle : StreamingExecutionHandle +/// The type of the workflow output. +public class StreamingRun : StreamingRun { private readonly IRunnerWithOutput _resultSource; - internal StreamingExecutionHandle(IRunnerWithOutput runner) + internal StreamingRun(IRunnerWithOutput runner) : base(Throw.IfNull(runner.StepRunner)) { this._resultSource = runner; @@ -128,9 +156,9 @@ internal StreamingExecutionHandle(IRunnerWithOutput runner) } /// -/// Provides extension methods for processing and executing workflows using streaming execution handles. +/// Provides extension methods for processing and executing workflows using streaming runs. /// -public static class ExecutionHandleExtensions +public static class StreamingRunExtensions { /// /// Processes all events from the workflow execution stream until completion. @@ -138,14 +166,14 @@ public static class ExecutionHandleExtensions /// This method continuously monitors the workflow execution stream provided by and invokes the for each event. If the callback returns a /// non- response, the response is sent back to the workflow using the handle. - /// The representing the workflow execution stream to monitor. + /// The representing the workflow execution stream to monitor. /// An optional callback function invoked for each received from the stream. /// The callback can return a response object to be sent back to the workflow, or if no response /// is required. /// A to observe while waiting for events. /// A that represents the asynchronous operation. The task completes when the workflow /// execution stream is fully processed. - public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) + public static async ValueTask RunToCompletionAsync(this StreamingRun handle, Func? eventCallback = null, CancellationToken cancellation = default) { Throw.IfNull(handle); @@ -160,21 +188,21 @@ public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle } /// - /// Executes the workflow associated with the specified until it + /// Executes the workflow associated with the specified until it /// completes and returns the final result. /// /// This method ensures that the workflow runs to completion before returning the result. If an /// is provided, it will be invoked for each event emitted during the workflow's /// execution, allowing for custom event handling. /// The type of the result produced by the workflow. - /// The representing the workflow to execute. + /// The representing the workflow to execute. /// An optional callback function that is invoked for each /// emitted during execution. The callback can process the event and return an object, or /// if no response is required. /// A that can be used to cancel the workflow execution. /// A that represents the asynchronous operation. The task's result is the final /// result of the workflow execution. - public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) + public static async ValueTask RunToCompletionAsync(this StreamingRun handle, Func? eventCallback = null, CancellationToken cancellation = default) { Throw.IfNull(handle); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs index 62365d6beb..8b479fd0c5 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs @@ -26,7 +26,7 @@ public static async ValueTask RunAsync(TextWriter writer, string input = LocalRunner runner = new(workflow); - StreamingExecutionHandle handle = await runner.StreamAsync(input).ConfigureAwait(false); + StreamingRun handle = await runner.StreamAsync(input).ConfigureAwait(false); await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) { switch (evt) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs index 5bfabb92f2..5180bae49b 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs @@ -21,7 +21,7 @@ public static async ValueTask RunAsync(TextWriter writer) .Build(); LocalRunner runner = new(workflow); - StreamingExecutionHandle handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); + StreamingRun handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) { diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs index 10412f377a..d7d24fbbe7 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs @@ -21,7 +21,7 @@ public static async ValueTask RunAsync(TextWriter writer, Func(judge, ComputeStreamingOutput, (NumberSignal s, string? _) => s == NumberSignal.Matched); LocalRunner runner = new(workflow); - StreamingExecutionHandle handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); + StreamingRun handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) { From 16317c089d5017af1d1dab4e6db2c69c8ad97d72 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Fri, 8 Aug 2025 12:33:36 -0400 Subject: [PATCH 040/232] test: Add test for non-streaming execution --- .../Sample/01_Simple_Workflow_Sequential.cs | 1 + .../Sample/01a_Simple_Workflow_Sequential.cs | 37 +++++++++++++++++++ .../SampleSmokeTest.cs | 18 +++++++++ 3 files changed, 56 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs index cf74362684..67c515bdac 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs @@ -52,6 +52,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext cont string result = new(charArray); await context.SendMessageAsync(result).ConfigureAwait(false); + await context.AddEventAsync(new WorkflowCompletedEvent() { Data = result }).ConfigureAwait(false); return result; } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs new file mode 100644 index 0000000000..449b171517 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.IO; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; + +namespace Microsoft.Agents.Workflows.Sample; + +internal static class Step1aEntryPoint +{ + public static async ValueTask RunAsync(TextWriter writer) + { + UppercaseExecutor uppercase = new(); + ReverseTextExecutor reverse = new(); + + WorkflowBuilder builder = new(uppercase); + builder.AddEdge(uppercase, reverse); + + Workflow workflow = builder.Build(); + LocalRunner runner = new(workflow); + + //var handle = await runner.StreamAsync("Hello, World!").ConfigureAwait(false); + + Run run = await runner.RunAsync("Hello, World!").ConfigureAwait(false); + + Assert.Equal(RunStatus.Completed, run.Status); + + foreach (WorkflowEvent evt in run.NewEvents) + { + if (evt is ExecutorCompleteEvent executorComplete) + { + writer.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}"); + } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs index 12fe7f8f37..dc08df598e 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs @@ -28,6 +28,24 @@ public async Task Test_RunSample_Step1Async() ); } + [Fact] + public async Task Test_RunSample_Step1aAsync() + { + using StringWriter writer = new(); + + await Step1aEntryPoint.RunAsync(writer); + + string result = writer.ToString(); + string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); + + const string INPUT = "Hello, World!"; + + Assert.Collection(lines, + line => Assert.Contains($"UppercaseExecutor: {INPUT.ToUpperInvariant()}", line), + line => Assert.Contains($"ReverseTextExecutor: {new string(INPUT.ToUpperInvariant().Reverse().ToArray())}", line) + ); + } + [Fact] public async Task Test_RunSample_Step2Async() { From 1644458a78d111fd712d9a03fe7353aba05ad421 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 11 Aug 2025 09:37:36 -0700 Subject: [PATCH 041/232] Conversion checkpoint --- dotnet/Directory.Packages.props | 3 + dotnet/agent-framework-dotnet.slnx | 8 +- dotnet/nuget.config | 12 +- .../GettingStarted/GettingStarted.csproj | 10 +- .../Workflows/DeepResearchAgent.fdl | 144 +++++ .../Workflows/Workflows_Declarative.cs | 105 ++++ .../Workflows/deepResearch.yaml | 475 +++++++++++++++ .../GettingStarted/Workflows/demo250729.yaml | 57 ++ .../GettingStarted/Workflows/testChat.yaml | 16 + .../Workflows/testCondition.yaml | 72 +++ .../GettingStarted/Workflows/testEnd.yaml | 20 + .../Workflows/testExpression.yaml | 37 ++ .../GettingStarted/Workflows/testGoto.yaml | 45 ++ .../GettingStarted/Workflows/testLoop.yaml | 34 ++ .../Workflows/testLoopBreak.yaml | 36 ++ .../Workflows/testLoopContinue.yaml | 36 ++ .../GettingStarted/Workflows/testTopic.yaml | 26 + .../Actions/AnswerQuestionWithAIAction.cs | 53 ++ .../Actions/AssignmentAction.cs | 40 ++ .../Actions/ClearAllVariablesAction.cs | 54 ++ .../Actions/ConditionGroupAction.cs | 25 + .../Actions/EditTableV2Action.cs | 66 +++ .../Actions/EndConversationAction.cs | 20 + .../Actions/ForeachAction.cs | 77 +++ .../Actions/ParseValueAction.cs | 68 +++ .../Actions/ResetVariableAction.cs | 31 + .../Actions/SendActivityAction.cs | 47 ++ .../Actions/SetTextVariableAction.cs | 27 + .../Actions/SetVariableAction.cs | 35 ++ .../DeclarativeActionExecutor.cs | 20 + .../DeclarativeWorkflowBuilder.cs | 58 ++ .../DeclarativeWorkflowExecutor.cs | 22 + .../Exceptions/InvalidActionException.cs | 35 ++ .../Exceptions/InvalidScopeException.cs | 35 ++ .../Exceptions/InvalidSegmentException.cs | 35 ++ .../Exceptions/ProcessActionException.cs | 35 ++ .../Exceptions/ProcessWorkflowException.cs | 35 ++ .../Exceptions/UnknownActionException.cs | 35 ++ .../Exceptions/UnknownDataTypeException.cs | 35 ++ .../Exceptions/WorkflowBuilderException.cs | 35 ++ .../Extensions/BotElementExtensions.cs | 21 + .../Extensions/DataValueExtensions.cs | 64 +++ .../Extensions/FormulaValueExtensions.cs | 99 ++++ .../Extensions/PropertyPathExtensions.cs | 10 + .../Extensions/RecordDataTypeExtensions.cs | 38 ++ .../Extensions/StringExtensions.cs | 21 + .../Extensions/TemplateExtensions.cs | 49 ++ ...rosoft.Agents.Workflows.Declarative.csproj | 39 ++ .../PowerFx/FoundryExpressionEngine.cs | 329 +++++++++++ .../PowerFx/RecalcEngineExtensions.cs | 56 ++ .../PowerFx/RecalcEngineFactory.cs | 49 ++ .../ProcessAction.cs | 72 +++ .../ProcessActionScopes.cs | 140 +++++ .../ProcessActionStack.cs | 46 ++ .../ProcessActionVisitor.cs | 543 ++++++++++++++++++ .../ProcessActionWalker.cs | 30 + .../ProcessWorkflowBuilder.cs | 152 +++++ .../WorkflowContext.cs | 52 ++ .../src/Shared/Samples/TestConfiguration.cs | 8 +- .../Extensions/FormulaValueExtensionsTests.cs | 146 +++++ .../Extensions/StringExtensionsTests.cs | 162 ++++++ ...nts.Workflows.Declarative.UnitTests.csproj | 16 + .../PowerFx/FoundryExpressionEngineTests.cs | 29 + .../PowerFx/RecalcEngineEvaluationTests.cs | 98 ++++ .../PowerFx/RecalcEngineFactoryTests.cs | 76 +++ .../PowerFx/RecalcEngineTest.cs | 17 + .../PowerFx/TemplateExtensionsTests.cs | 171 ++++++ .../ProcessActionScopesTests.cs | 161 ++++++ .../TestOutputAdapter.cs | 73 +++ .../WorkflowTest.cs | 36 ++ 70 files changed, 4853 insertions(+), 9 deletions(-) create mode 100644 dotnet/samples/GettingStarted/Workflows/DeepResearchAgent.fdl create mode 100644 dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs create mode 100644 dotnet/samples/GettingStarted/Workflows/deepResearch.yaml create mode 100644 dotnet/samples/GettingStarted/Workflows/demo250729.yaml create mode 100644 dotnet/samples/GettingStarted/Workflows/testChat.yaml create mode 100644 dotnet/samples/GettingStarted/Workflows/testCondition.yaml create mode 100644 dotnet/samples/GettingStarted/Workflows/testEnd.yaml create mode 100644 dotnet/samples/GettingStarted/Workflows/testExpression.yaml create mode 100644 dotnet/samples/GettingStarted/Workflows/testGoto.yaml create mode 100644 dotnet/samples/GettingStarted/Workflows/testLoop.yaml create mode 100644 dotnet/samples/GettingStarted/Workflows/testLoopBreak.yaml create mode 100644 dotnet/samples/GettingStarted/Workflows/testLoopContinue.yaml create mode 100644 dotnet/samples/GettingStarted/Workflows/testTopic.yaml create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AnswerQuestionWithAIAction.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AssignmentAction.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ClearAllVariablesAction.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ConditionGroupAction.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EditTableV2Action.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EndConversationAction.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ForeachAction.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ParseValueAction.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ResetVariableAction.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SendActivityAction.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetTextVariableAction.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetVariableAction.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeActionExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidActionException.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidSegmentException.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ProcessActionException.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ProcessWorkflowException.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownActionException.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownDataTypeException.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowBuilderException.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/PropertyPathExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/StringExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/TemplateExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Microsoft.Agents.Workflows.Declarative.csproj create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/FoundryExpressionEngine.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessAction.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionScopes.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionStack.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionVisitor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionWalker.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessWorkflowBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowContext.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/StringExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Microsoft.Agents.Workflows.Declarative.UnitTests.csproj create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/FoundryExpressionEngineTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineEvaluationTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ProcessActionScopesTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/TestOutputAdapter.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowTest.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 8b2bdc776d..9c6efaaa8d 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -65,6 +65,9 @@ + + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 253364f376..077f1a0846 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -30,6 +30,8 @@ + + @@ -116,6 +118,7 @@ + @@ -128,13 +131,14 @@ + + + - - diff --git a/dotnet/nuget.config b/dotnet/nuget.config index 996a924ac0..057d82e383 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -2,6 +2,16 @@ - + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/GettingStarted.csproj b/dotnet/samples/GettingStarted/GettingStarted.csproj index 3722c8294a..ec3745264d 100644 --- a/dotnet/samples/GettingStarted/GettingStarted.csproj +++ b/dotnet/samples/GettingStarted/GettingStarted.csproj @@ -4,7 +4,7 @@ GettingStarted Library 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 - $(NoWarn);CA1707;CA1716;IDE0009;IDE1006; OPENAI001; + $(NoWarn);CA1707;CA1716;CA5399;IDE0009;IDE1006;OPENAI001; enable true true @@ -15,6 +15,10 @@ $(ProjectsTargetFrameworks) $(ProjectsDebugTargetFrameworks) + + + + @@ -42,6 +46,7 @@ + @@ -61,6 +66,9 @@ Always + + Always + diff --git a/dotnet/samples/GettingStarted/Workflows/DeepResearchAgent.fdl b/dotnet/samples/GettingStarted/Workflows/DeepResearchAgent.fdl new file mode 100644 index 0000000000..536342dd32 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/DeepResearchAgent.fdl @@ -0,0 +1,144 @@ +name: deepresearch +states: + - name: GatherFacts + actors: + - agent: LedgerFacts + inputs: + instructions: instructions + outputs: + task: task + facts: facts + thread: Planning + humanInLoopMode: onNoMessage + streamOutput: false + isFinal: false + - name: Plan + actors: + - agent: LedgerPlanner + inputs: + task: task + facts: facts + team: team + instructions: instructions + messagesOut: plannerMessages + thread: Planning + humanInLoopMode: never + streamOutput: true + isFinal: false + - name: ProcessProgress + actors: + - agent: ProgressLedger + inputs: + task: task + team: team + systemAgents: systemAgents + messagesOut: nextStepMessages + messagesIn: + - plannerMessages + thread: Run + humanInLoopMode: never + streamOutput: true + isFinal: false + - name: actionRouter + actors: + - agent: ActionRouterAgent + messagesIn: + - nextStepMessages + inputs: + team: team + systemAgents: systemAgents + outputs: + targetAgent: nextAgent + humanInLoopMode: never + streamOutput: true + - name: dynamicStepAgent + actors: + - agent: nextAgent + thread: Run + humanInLoopMode: never + streamOutput: true + - name: UpdateLedgerFact + actors: + - agent: LedgerFactsUpdate + thread: Run + inputs: + task: task + facts: facts + outputs: + updatedFacts: facts + humanInLoopMode: never + streamOutput: false + isFinal: false + - name: LedgerPlanUpdate + actors: + - agent: LedgerPlanUpdate + inputs: + facts: facts + team: team + messagesOut: plannerMessages + thread: Run + humanInLoopMode: never + streamOutput: true + isFinal: false + - name: Summarizer + actors: + - agent: FinalStepAgent + thread: Run + inputs: + task: task + humanInLoopMode: never + streamOutput: true + isFinal: true +transitions: + - from: GatherFacts + to: Plan + - from: Plan + to: ProcessProgress + - from: LedgerPlanUpdate + to: ProcessProgress + - from: ProcessProgress + to: actionRouter + - from: actionRouter + to: UpdateLedgerFact + condition: nextAgent.Equals(LedgerFactsUpdate) + - from: actionRouter + to: Summarizer + condition: nextAgent.Equals(FinalStepAgent) + - from: actionRouter + to: dynamicStepAgent + condition: nextAgent.NotContains(FinalStepAgent) + - from: dynamicStepAgent + to: ProcessProgress + - from: UpdateLedgerFact + to: LedgerPlanUpdate +variables: + - Type: userDefined + name: team + - Type: userDefined + name: instructions + - Type: userDefined + name: task + - Type: userDefined + name: facts + - Type: userDefined + name: plan + - Type: messages + name: plannerMessages + - Type: thread + name: Planning + - Type: thread + name: Run + - Type: messages + name: nextStepMessages + - Type: userDefined + name: nextAgent + - Type: userDefined + name: systemAgents + value: + - agent: FinalStepAgent + description: >- + Agent which summarizes the output after task is complete. When next speaker is none. + - agent: LedgerFactsUpdate + description: >- + Agent which can update the plan if we are looping without making progress or stall is detected. +startstate: GatherFacts diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs new file mode 100644 index 0000000000..0fb88652c2 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft. All rights reserved. + +#if NET + +using System.Text.Json; +using Azure.Identity; +using Microsoft.Agents.Orchestration; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Declarative; +using Microsoft.Agents.Workflows.Execution; +using Microsoft.Shared.Diagnostics; +using Microsoft.Shared.Samples; + +namespace Workflows; + +/// +/// Demonstrates how to use the +/// for executing multiple agents on the same task in parallel. +/// +public class Workflows_Declarative(ITestOutputHelper output) : OrchestrationSample(output) +{ + [Theory] + [InlineData("deepResearch")] + [InlineData("demo250729")] + [InlineData("testChat")] + [InlineData("testCondition")] + [InlineData("testEnd")] + [InlineData("testExpression")] + [InlineData("testGoto")] + [InlineData("testLoop")] + [InlineData("testLoopBreak")] + [InlineData("testLoopContinue")] + [InlineData("testTopic")] + public async Task RunWorkflow(string fileName) + { + //using InterceptHandler customHandler = new(); + //using HttpClient customClient = new(customHandler, disposeHandler: false); + + //const string InputEventId = "question"; + + Console.WriteLine("WORKFLOW INIT\n"); + + using StreamReader yamlReader = File.OpenText(@$"{nameof(Workflows)}\{fileName}.yaml"); + WorkflowContext workflowContext = + new() + { + //HttpClient = customClient, + LoggerFactory = this.LoggerFactory, + ActivityChannel = this.Console, + ProjectEndpoint = Throw.IfNull(TestConfiguration.AzureAI.Endpoint), + ProjectCredentials = new AzureCliCredential(), + }; + Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, "input", workflowContext); + + Console.WriteLine("\nWORKFLOW INVOKE\n"); + + LocalRunner runner = new(workflow); + StreamingRun handle = await runner.StreamAsync(""); + await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + { + if (evt is ExecutorCompleteEvent executorComplete) + { + Console.WriteLine($"WORKFLOW EVENT: {executorComplete.Data}"); + } + } + Console.WriteLine("\nWORKFLOW DONE"); + } +} + +//internal sealed class InterceptHandler : HttpClientHandler +//{ +// private static readonly JsonSerializerOptions s_options = new() { WriteIndented = true }; + +// protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) +// { +// // Call the inner handler to process the request and get the response +// HttpResponseMessage response = await base.SendAsync(request, cancellationToken); + +// // Intercept and modify the response +// Console.WriteLine($"{request.Method} {request.RequestUri}"); +// if (response.Content != null) +// { +// string responseContent; +// try +// { +// JsonDocument responseDocument = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); +// responseContent = JsonSerializer.Serialize(responseDocument, s_options); +// } +// catch (ArgumentException) +// { +// responseContent = await response.Content.ReadAsStringAsync(cancellationToken); +// } +// catch (JsonException) +// { +// responseContent = await response.Content.ReadAsStringAsync(cancellationToken); +// } +// response.Content = new StringContent(responseContent); +// //Console.WriteLine(responseContent); // %%% RAISE EVENT +// } + +// return response; +// } +//} + +#endif diff --git a/dotnet/samples/GettingStarted/Workflows/deepResearch.yaml b/dotnet/samples/GettingStarted/Workflows/deepResearch.yaml new file mode 100644 index 0000000000..4763cb1751 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/deepResearch.yaml @@ -0,0 +1,475 @@ +# TaskDialog +# AgentDialog +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + condition: =Global.OrchestratorRunning <> true + type: Message + actions: + - kind: SetVariable + id: setVariable_aASlmF + displayName: List all available agents for this orchestrator + variable: Topic.AgentToSchemaMapping + value: |- + =[ + { + name: "WeatherAgent", + description: "Able to retrieve weather information", + schema: "cr36e_agentRd2yAT.topic.Deterministic" + }, + { + name: "WebAgent", + description: "Able to perform generic websearches", + schema: "cr36e_agentRd2yAT.topic.WebSearch" + } + ] + + - kind: SetVariable + id: setVariable_u4cBtN + displayName: Get all names + variable: Topic.AvailableAgents + value: =DropColumns(Topic.AgentToSchemaMapping, description, schema) + + - kind: SetVariable + id: setVariable_V6yEbo + displayName: Get a summary of all the agents for use in prompts + variable: Topic.TeamDescription + value: "=Concat(ForAll(Topic.AgentToSchemaMapping, name & $\": \" & description), Value, \".\\n\\n\")" + + - kind: SetVariable + id: setVariable_eLmgKQ + displayName: Toggle Orchestration Enabled Flag + variable: Global.OrchestratorRunning + value: =true + + - kind: SetVariable + id: setVariable_NZ2u0l + displayName: Set Task + variable: Topic.NewTask + value: =System.LastMessage.Text + + - kind: SetVariable + id: setVariable_PKmRsz + variable: Topic.ReTaskCount + value: 0 + + - kind: SetVariable + id: setVariable_EpFEKQ + displayName: Initialize Stall Count + variable: Topic.StallCount + value: =0 + + - kind: SendActivity + id: sendActivity_yFsbRz + activity: |- + Creating a Task Ledger, defining a plan, based on the following ask: + + {Topic.NewTask} + + - kind: SetVariable + id: setVariable_s8hR6q + variable: Topic.ContextHistory + value: |- + =["Below I will present you a request. Before we begin addressing the request, please answer the following pre-survey to the best of your ability. Keep in mind that you are Ken Jennings-level with trivia, and Mensa-level with puzzles, so there should be a deep well to draw from. + + Here is the request: + + " & Topic.NewTask & " + + Here is the pre-survey: + + 1. Please list any specific facts or figures that are GIVEN in the request itself. It is possible that there are none. + 2. Please list any facts that may need to be looked up, and WHERE SPECIFICALLY they might be found. In some cases, authoritative sources are mentioned in the request itself. + 3. Please list any facts that may need to be derived (e.g., via logical deduction, simulation, or computation) + 4. Please list any facts that are recalled from memory, hunches, well-reasoned guesses, etc. + + When answering this survey, keep in mind that 'facts' will typically be specific names, dates, statistics, etc. Your answer should use headings: + + 1. GIVEN OR VERIFIED FACTS + 2. FACTS TO LOOK UP + 3. FACTS TO DERIVE + 4. EDUCATED GUESSES + + DO NOT include any other headings or sections in your response. DO NOT list next steps or plans until asked to do so."] + + - kind: AnswerQuestionWithAI + id: question_wEJ123 + displayName: Get Facts Prompt + autoSend: false + variable: Topic.TaskFacts + userInput: =First(Topic.ContextHistory).Value + + - kind: EditTableV2 + id: editTableV2_Pry8em + displayName: Add Fact Response to Context + itemsVariable: Topic.ContextHistory + changeType: + kind: AddItemOperation + value: =Topic.TaskFacts + + - kind: AnswerQuestionWithAI + id: question_wEJ456 + displayName: Create a Plan Prompt + autoSend: false + variable: Topic.Plan + userInput: |- + ="Fantastic. To address this request we have assembled the following team: + + " & Topic.TeamDescription & " + + Based on the team composition, and known and unknown facts, please devise a short bullet-point plan for addressing the original request. Remember, there is no requirement to involve all team members -- a team member's particular expertise may not be needed for this task." + additionalInstructions: =Concat(Topic.ContextHistory, Value, \".\\n\\n\") + + - kind: SetVariable + id: setVariable_Kk2LDL + displayName: Set Plan as Context + variable: Topic.ContextHistory + value: |- + =[" + + We are working to address the following user request: + + " & Topic.NewTask &" + + + To answer this request we have assembled the following team: + + " & Topic.TeamDescription &" + + + Here is an initial fact sheet to consider: + + " & Topic.TaskFacts &" + + Here is the plan to follow as best as possible: + + " + & Topic.Plan + + ] + + - kind: SendActivity + id: sendActivity_bwNZiM + activity: "{First(Topic.ContextHistory).Value}" + + - kind: AnswerQuestionWithAI + id: sendActivity_YhpNE8 + displayName: Progress Ledger Prompt + autoSend: false + variable: Topic.ProgressLedgerUpdateString + userInput: |- + =" + Recall we are working on the following request: + + " & Topic.NewTask & " + + And we have assembled the following team: + + " & Topic.TeamDescription & " + + To make progress on the request, please answer the following questions, including necessary reasoning: + + - Is the request fully satisfied? (True if complete, or False if the original request has yet to be SUCCESSFULLY and FULLY addressed) + - Are we in a loop where we are repeating the same requests and / or getting the same responses from an agent multiple times? Loops can span multiple turns, and can include repeated actions like scrolling up or down more than a handful of times. + - Are we making forward progress? (True if just starting, or recent messages are adding value. False if recent messages show evidence of being stuck in a loop or if there is evidence of significant barriers to success such as the inability to read from a required file) + - Who should speak next? (select from: " & Concat(Topic.AvailableAgents, name, ",") & ") + - What instruction or question would you give this team member? (Phrase as if speaking directly to them, and include any specific information they may need) + + Please output an answer in pure JSON format according to the following schema. The JSON object must be parsable as-is. DO NOT OUTPUT ANYTHING OTHER THAN JSON, AND DO NOT DEVIATE FROM THIS SCHEMA: + + {{ + ""is_request_satisfied"": {{ + ""reason"": string, + ""answer"": boolean + }}, + ""is_in_loop"": {{ + ""reason"": string, + ""answer"": boolean + }}, + ""is_progress_being_made"": {{ + ""reason"": string, + ""answer"": boolean + }}, + ""next_speaker"": {{ + ""reason"": string, + ""answer"": string (select from: " & Concat(Topic.AvailableAgents, name, ",") & ") + }}, + ""instruction_or_question"": {{ + ""reason"": string, + ""answer"": string + }} + }} + " + additionalInstructions: =Concat(Topic.ContextHistory, Value, ".\\n\\n") + + - kind: ParseValue + id: rNZtlV + displayName: Parse ledger response + variable: Topic.TypedProgressLedger + valueType: + kind: Record + properties: + instruction_or_question: + type: + kind: Record + properties: + answer: String + reason: String + + is_in_loop: + type: + kind: Record + properties: + answer: Boolean + reason: String + + is_progress_being_made: + type: + kind: Record + properties: + answer: Boolean + reason: String + + is_request_satisfied: + type: + kind: Record + properties: + answer: Boolean + reason: String + + next_speaker: + type: + kind: Record + properties: + answer: String + reason: String + + value: =Topic.ProgressLedgerUpdateString + + - kind: SendActivity + id: sendActivity_1GMmNq + activity: |- + Progress Ledger response: + {Topic.ProgressLedgerUpdateString} + + - kind: ConditionGroup + id: conditionGroup_mVIecC + conditions: + - id: conditionItem_fj432c + condition: =Topic.TypedProgressLedger.is_request_satisfied.answer + displayName: If Done + actions: + - kind: AnswerQuestionWithAI + id: sendActivity_Pkkmpq + displayName: Generate Response + variable: Topic.FinalResponse + userInput: |- + =" + We are working on the following task: + " & Topic.NewTask & " + + We have completed the task. + + The above messages contain the conversation that took place to complete the task. + + Based on the information gathered, provide the final answer to the original request. + The answer should be phrased as if you were speaking to the user. + " + additionalInstructions: =Concat(Topic.ContextHistory, Value, \".\\n\\n\") + + - kind: SendActivity + id: sendActivity_fpaNL9 + activity: Done with Task! + + - kind: SetVariable + id: setVariable_H2GWZ4 + variable: Global.OrchestratorRunning + value: =false + + - kind: EndConversation + id: SVoNSV + + - id: conditionItem_yiqund + condition: =Topic.TypedProgressLedger.is_in_loop.answer || Not(Topic.TypedProgressLedger.is_progress_being_made.answer) + displayName: If Stalling + actions: + - kind: SetVariable + id: setVariable_H5lXdD + displayName: Increase stall count + variable: Topic.StallCount + value: =Topic.StallCount + 1 + + - kind: ConditionGroup + id: conditionGroup_xzNrdM + conditions: + - id: conditionItem_NlQTBv + condition: =Topic.StallCount > 2 + displayName: Stall Count Exceeded + actions: + - kind: ConditionGroup + id: conditionGroup_4s1Z27 + conditions: + - id: conditionItem_EXAlhZ + condition: =Topic.ReTaskCount > 2 + actions: + - kind: SendActivity + id: sendActivity_xKxFUU + activity: We tried to re-task 3 times. Short-Circuiting + + - kind: EndConversation + id: GHVrFh + + - kind: AnswerQuestionWithAI + id: question_wFJ123 + displayName: Get New Facts Prompt + autoSend: false + variable: Topic.TaskFacts + userInput: |- + ="As a reminder, we are working to solve the following task: + + " & Topic.NewTask & " + + It's clear we aren't making as much progress as we would like, but we may have learned something new. Please rewrite the following fact sheet, updating it to include anything new we have learned that may be helpful. Example edits can include (but are not limited to) adding new guesses, moving educated guesses to verified facts if appropriate, etc. Updates may be made to any section of the fact sheet, and more than one section of the fact sheet can be edited. This is an especially good time to update educated guesses, so please at least add or update one educated guess or hunch, and explain your reasoning. + + Here is the old fact sheet: + + " & Topic.TaskFacts + additionalInstructions: =Concat(Topic.ContextHistory, Value, ".\\n\\n") + + - kind: AnswerQuestionWithAI + id: question_uEJ456 + displayName: Create new Plan Prompt + autoSend: false + variable: Topic.Plan + userInput: |- + ="Please briefly explain what went wrong on this last run (the root cause of the failure), and then come up with a new plan that takes steps and/or includes hints to overcome prior challenges and especially avoids repeating the same mistakes. As before, the new plan should be concise, be expressed in bullet-point form, and consider the following team composition (do not involve any other outside people since we cannot contact anyone else): + + " & Topic.TeamDescription + additionalInstructions: =Concat(Topic.ContextHistory, Value, ".\\n\\n") + + - kind: EditTableV2 + id: editTableV2_jW7tmM + displayName: Add new plan to history + itemsVariable: Topic.ContextHistory + changeType: + kind: AddItemOperation + value: | + =" + + We are working to address the following user request: + + " & Topic.NewTask & " + + + To answer this request we have assembled the following team: + + " & Topic.TeamDescription & " + + + Here is an initial fact sheet to consider: + + " & Topic.TaskFacts & " + + Here is the plan to follow as best as possible: + + " + & Topic.Plan + + - kind: SetVariable + id: setVariable_6J2snP + displayName: Reset Stall count + variable: Topic.StallCount + value: 0 + + - kind: SetVariable + id: setVariable_S6HCgh + displayName: Increase ReTask count + variable: Topic.ReTaskCount + value: =Topic.ReTaskCount + 1 + + - kind: SendActivity + id: sendActivity_cwNZiM + activity: |- + We have Stalled - Adjusting plan: + + {Last(Topic.ContextHistory).Value} + + - kind: GotoAction + id: LzfJ8u + actionId: sendActivity_YhpNE8 + + elseActions: + - kind: SetVariable + id: setVariable_L7ooQO + variable: Topic.StallCount + value: 0 + + - kind: ConditionGroup + id: conditionGroup_QFPiF5 + conditions: + - id: conditionItem_GmigcU + condition: =CountRows(Search(Topic.AvailableAgents, Topic.TypedProgressLedger.next_speaker.answer, name)) > 0 + displayName: If next Agent tool Exists + actions: + - kind: SetVariable + id: setVariable_TdKfOn + displayName: Set Current Goal for Sub-Agent + variable: Global.AgentGoal + value: =Topic.TypedProgressLedger.instruction_or_question.answer + + - kind: SetVariable + id: setVariable_C2AoCu + displayName: Reset Output + variable: Global.AgentResponse + value: "\"\"" + + - kind: BeginDialog + id: fqLWPt + displayName: Invoke Agent + input: {} + dialog: =First(Search(Topic.AgentToSchemaMapping, Topic.TypedProgressLedger.next_speaker.answer, name)).schema + output: {} + + - kind: EditTableV2 + id: editTableV2_fhfYJi + displayName: Add agent response to context + itemsVariable: Topic.ContextHistory + changeType: + kind: AddItemOperation + value: |- + ="Agent: " & Topic.TypedProgressLedger.next_speaker.answer & " + + Question or Instruction: + + " + & Topic.TypedProgressLedger.instruction_or_question.answer & + " + + Agent Response: + + " + & Global.AgentResponse + + - kind: SendActivity + id: sendActivity_MjWETC + activity: |- + Agent invoked: + {Last(Topic.ContextHistory).Value} + + - kind: GotoAction + id: 76Hne8 + actionId: sendActivity_YhpNE8 + + elseActions: + - kind: SendActivity + id: sendActivity_BhcsI7 + activity: Redirecting to unknown agent + + - kind: SetVariable + id: setVariable_H2GW44 + variable: Global.OrchestratorRunning + value: =false + + - kind: EndConversation + id: 8nXE8H diff --git a/dotnet/samples/GettingStarted/Workflows/demo250729.yaml b/dotnet/samples/GettingStarted/Workflows/demo250729.yaml new file mode 100644 index 0000000000..611ec7912b --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/demo250729.yaml @@ -0,0 +1,57 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + actions: + + # Capture optional agent instructions + - kind: SetVariable + id: setVariable_NZ2u0l + variable: Topic.Instructions + value: =System.LastMessage.Text + + # Assign a list of inputs in JSON format to a variable + - kind: SetVariable + id: setVariable_aASlmF + displayName: List all of questions for LLM + variable: Topic.Questions + value: |- + =[ + "Why is the sky blue?", + "What is the capital of France?", + "Where do rainbows come from?", + ] + + # Loop over each question in the list + - kind: Foreach + id: foreach_mVIecC + items: =Topic.Questions + index: Topic.LoopIndex + value: Topic.Question + actions: + + # Display the current question + - kind: SendActivity + id: sendActivity_lMn07p + activity: "Question {Topic.LoopIndex + 1} - {Topic.Question}" + + # Use AI to answer the question + - kind: AnswerQuestionWithAI + id: question_wEJ456 + variable: Topic.Answer + userInput: =Topic.Question + additionalInstructions: "{Topic.Instructions}" + + # Display the AI's answer + - kind: SendActivity + id: sendActivity_zA3f0p + activity: "AI - {Topic.Answer}" + + # After processing all questions, display a completion message + - kind: SendActivity + id: sendActivity_SVoNSV + activity: Complete! + + # End the conversation + - kind: EndConversation + id: end_8nXE8H diff --git a/dotnet/samples/GettingStarted/Workflows/testChat.yaml b/dotnet/samples/GettingStarted/Workflows/testChat.yaml new file mode 100644 index 0000000000..fbf8af2bc5 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/testChat.yaml @@ -0,0 +1,16 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + actions: + + # Use AI to answer the question + - kind: AnswerQuestionWithAI + id: question_wEJ456 + variable: Topic.Answer + userInput: Why is the sky blue? + + # Display the AI's answer + - kind: SendActivity + id: sendActivity_zA3f0p + activity: "AI - {Topic.Answer}" diff --git a/dotnet/samples/GettingStarted/Workflows/testCondition.yaml b/dotnet/samples/GettingStarted/Workflows/testCondition.yaml new file mode 100644 index 0000000000..8fbb3d703d --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/testCondition.yaml @@ -0,0 +1,72 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + - kind: SetVariable + id: setVariable_u4cBtN + displayName: Invocation count + variable: Topic.Count + value: =0 + + - kind: GotoAction + id: goto_skJ8u + actionId: setVariable_a9f4o2 + + - kind: SendActivity + id: sendActivity_skJ8u + activity: NEVER A! + + - kind: SetVariable + id: setVariable_a9f4o2 + displayName: Invocation count + variable: Topic.Count + value: =Topic.Count + 1 + + - kind: SendActivity + id: sendActivity_aGsbRo + activity: Looping (x{Topic.Count}) + + - kind: ConditionGroup + id: conditionGroup_mVIecC + conditions: + - id: conditionItem_fj432c + condition: =Topic.Count < 5 + displayName: Just started + actions: + - kind: SendActivity + id: sendActivity_Pkkmpq + activity: Just started (x{Topic.Count}) + + - id: conditionItem_yiqund + condition: =Topic.Count > 5 && Topic.Count < 10 + displayName: Making progress + actions: + - kind: SendActivity + id: sendActivity_aLM1o3 + activity: Making progress (x{Topic.Count}) + + - kind: GotoAction + id: goto_LzfJ8u + actionId: setVariable_a9f4o2 + + elseActions: + - kind: SendActivity + id: sendActivity_rOk31p + activity: All done (x{Topic.Count}) + + - kind: EndConversation + id: end_SVoNSV + + - kind: SendActivity + id: sendActivity_fJsbRz + activity: Fallthrough (x{Topic.Count}) + + - kind: GotoAction + id: goto_fTJ8u + actionId: setVariable_a9f4o2 + + - kind: SendActivity + id: sendActivity_ohn03s + activity: NEVER B! diff --git a/dotnet/samples/GettingStarted/Workflows/testEnd.yaml b/dotnet/samples/GettingStarted/Workflows/testEnd.yaml new file mode 100644 index 0000000000..11f789cf94 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/testEnd.yaml @@ -0,0 +1,20 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + - kind: SendActivity + id: sendActivity_Start + activity: Starting + + - kind: SendActivity + id: sendActivity_Done + activity: Done! + + - kind: EndConversation + id: end_skJ8u + + - kind: SendActivity + id: sendActivity_Nevah + activity: NEVER! diff --git a/dotnet/samples/GettingStarted/Workflows/testExpression.yaml b/dotnet/samples/GettingStarted/Workflows/testExpression.yaml new file mode 100644 index 0000000000..2db7bc7a19 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/testExpression.yaml @@ -0,0 +1,37 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + - kind: SetVariable + id: setVariable1 + variable: Topic.TestList + value: =["zaz", "zbz", "zcz", "zdz", "zez", "zfz"] + + - kind: SetVariable + id: setVariable2 + variable: Topic.TestResult + value: |- + =CountIf(Topic.TestList, !IsBlank(Find("e", Value))) > 0 && + !IsBlank(3) + +# value: =CountIf(Topic.TestList, !IsBlank(Find("e", Value))) > 0 + #value: =CountIf(Topic.TestList, 1) +# value: =!IsBlank(Topic.TestList) + + - kind: SetVariable + id: setVariable3 + variable: Topic.TestFind + value: =Find("e", "abcdefg") + #value: =CountIf(Topic.TestList, 1) +# value: =!IsBlank(Topic.TestList) + + - kind: SendActivity + id: sendActivity2 + activity: "Result (CountIf): {Topic.TestResult}" + + + - kind: SendActivity + id: sendActivity3 + activity: "Result (Find): {Topic.TestFind}" diff --git a/dotnet/samples/GettingStarted/Workflows/testGoto.yaml b/dotnet/samples/GettingStarted/Workflows/testGoto.yaml new file mode 100644 index 0000000000..99caa8e7f2 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/testGoto.yaml @@ -0,0 +1,45 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + + - kind: SendActivity + id: sendActivity_aGsbRo + activity: First + + - kind: GotoAction + id: goto_skJ8u + actionId: sendActivity_fJsbRz + + - kind: SendActivity + id: sendActivity_nev1 + activity: NEVER! + + - kind: SendActivity + id: sendActivity_SVoNSV + activity: Last + + - kind: GotoAction + id: goto_SVoNSV + actionId: end_SVoNSV + + - kind: SendActivity + id: sendActivity_nev2 + activity: NEVER! + + - kind: SendActivity + id: sendActivity_fJsbRz + activity: Next + + - kind: GotoAction + id: goto_ajd01z + actionId: sendActivity_SVoNSV + + - kind: SendActivity + id: sendActivity_nev3 + activity: NEVER! + + - kind: EndConversation + id: end_SVoNSV diff --git a/dotnet/samples/GettingStarted/Workflows/testLoop.yaml b/dotnet/samples/GettingStarted/Workflows/testLoop.yaml new file mode 100644 index 0000000000..6be5bba164 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/testLoop.yaml @@ -0,0 +1,34 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + - kind: SetVariable + id: setVariable_u4cBtN + displayName: Invocation count + variable: Topic.Count + value: =0 + + - kind: SendActivity + id: sendActivity_aGsbRo + activity: Starting + + - kind: Foreach + id: foreach_mVIecC + items: =["a", "b", "c", "d", "e", "f"] + index: Topic.LoopIndex + value: Topic.LoopValue + actions: + - kind: SetVariable + id: setVariable_A4iBtN + displayName: Invocation count + variable: Topic.Count + value: =Topic.Count + 1 + - kind: SendActivity + id: sendActivity_Pkkmpq + activity: Looping (x{Topic.Count}) - {Topic.LoopValue} [{Topic.LoopIndex}] + + - kind: SendActivity + id: sendActivity_fJsbRz + activity: Complete! (x{Topic.Count}) diff --git a/dotnet/samples/GettingStarted/Workflows/testLoopBreak.yaml b/dotnet/samples/GettingStarted/Workflows/testLoopBreak.yaml new file mode 100644 index 0000000000..072f1c58e6 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/testLoopBreak.yaml @@ -0,0 +1,36 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + - kind: SetVariable + id: setVariable_u4cBtN + displayName: Invocation count + variable: Topic.Count + value: =0 + + - kind: SendActivity + id: sendActivity_aGsbRo + activity: Starting + + - kind: Foreach + id: foreach_mVIecC + items: =["a", "b", "c", "d", "e", "f"] + index: Topic.LoopIndex + value: Topic.LoopValue + actions: + - kind: SetVariable + id: setVariable_A4iBtN + displayName: Invocation count + variable: Topic.Count + value: =Topic.Count + 1 + - kind: BreakLoop + id: breakLoop_9JsbRz + - kind: SendActivity + id: sendActivity_nev1 + activity: NEVER! + + - kind: SendActivity + id: sendActivity_fJsbRz + activity: Complete! (x{Topic.Count}) diff --git a/dotnet/samples/GettingStarted/Workflows/testLoopContinue.yaml b/dotnet/samples/GettingStarted/Workflows/testLoopContinue.yaml new file mode 100644 index 0000000000..fdb800187a --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/testLoopContinue.yaml @@ -0,0 +1,36 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + - kind: SetVariable + id: setVariable_u4cBtN + displayName: Invocation count + variable: Topic.Count + value: =0 + + - kind: SendActivity + id: sendActivity_aGsbRo + activity: Starting + + - kind: Foreach + id: foreach_mVIecC + items: =["a", "b", "c", "d", "e", "f"] + index: Topic.LoopIndex + value: Topic.LoopValue + actions: + - kind: SetVariable + id: setVariable_A4iBtN + displayName: Invocation count + variable: Topic.Count + value: =Topic.Count + 1 + - kind: ContinueLoop + id: continueLoop_9JsbRz + - kind: SendActivity + id: sendActivity_nev1 + activity: NEVER! + + - kind: SendActivity + id: sendActivity_fJsbRz + activity: Complete! (x{Topic.Count}) diff --git a/dotnet/samples/GettingStarted/Workflows/testTopic.yaml b/dotnet/samples/GettingStarted/Workflows/testTopic.yaml new file mode 100644 index 0000000000..a4380697d9 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/testTopic.yaml @@ -0,0 +1,26 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + + - kind: SetVariable + id: setVariable_u4cBtN + displayName: Invocation count + variable: Topic.FirstValue + value: ABC + + - kind: SetVariable + id: setVariable_a8ybTn + displayName: Invocation count + variable: Workflow.SecondValue + value: 123 + + - kind: SendActivity + id: sendActivity_SVoNSV + activity: {Workflow.FirstValue} + + - kind: SendActivity + id: sendActivity_fJsbRz + activity: {Topic.SecondValue} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AnswerQuestionWithAIAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AnswerQuestionWithAIAction.cs new file mode 100644 index 0000000000..944220ecf9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AnswerQuestionWithAIAction.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Agents.Persistent; +using Microsoft.Agents.Workflows.Declarative; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Handlers; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.SemanticKernel.Process.Workflows.Actions; + +internal sealed class AnswerQuestionWithAIAction : AssignmentAction +{ + public AnswerQuestionWithAIAction(AnswerQuestionWithAI model) + : base(model, Throw.IfNull(model.Variable?.Path, $"{nameof(model)}.{nameof(model.Variable)}.{nameof(InitializablePropertyPath.Path)}")) + { + } + + protected override async Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + PersistentAgentsClient client = context.ClientFactory.Invoke(); + using NewPersistentAgentsChatClient chatClient = new(client, "asst_ueIjfGxAjsnZ4A61LlbjG9vJ"); + ChatClientAgent agent = new(chatClient); + + string? userInput = null; + if (this.Model.UserInput is not null) + { + EvaluationResult result = context.ExpressionEngine.GetValue(this.Model.UserInput!, context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE + userInput = result.Value; + } + + ChatClientAgentRunOptions options = + new( + new ChatOptions() + { + Instructions = context.Engine.Format(this.Model.AdditionalInstructions) ?? string.Empty, + }); + AgentRunResponse response = + userInput != null ? + await agent.RunAsync(userInput, thread: null, options, cancellationToken).ConfigureAwait(false) : + await agent.RunAsync(thread: null, options, cancellationToken).ConfigureAwait(false); + StringValue responseValue = FormulaValue.New(response.Messages.Last().ToString()); + + this.AssignTarget(context, responseValue); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AssignmentAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AssignmentAction.cs new file mode 100644 index 0000000000..e8921f4b26 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AssignmentAction.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.Logging; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.Handlers; + +internal abstract class AssignmentAction : ProcessAction where TAction : DialogAction +{ + protected AssignmentAction(TAction model, PropertyPath assignmentTarget) + : base(model) + { + this.Target = assignmentTarget; + } + + public PropertyPath Target { get; } + + protected void AssignTarget(ProcessActionContext context, FormulaValue result) + { + context.Engine.SetScopedVariable(context.Scopes, this.Target, result); + string? resultValue = result.Format(); + string valuePosition = (resultValue?.IndexOf('\n') ?? -1) >= 0 ? Environment.NewLine : " "; + context.Logger.LogDebug( + """ + !!! ASSIGN {ActionName} [{ActionId}] + NAME: {TargetName} + VALUE:{ValuePosition}{Result} ({ResultType}) + """, + this.GetType().Name, + this.Id, + this.Target.Format(), + valuePosition, + result.Format(), + result.GetType().Name); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ClearAllVariablesAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ClearAllVariablesAction.cs new file mode 100644 index 0000000000..d76d146d47 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ClearAllVariablesAction.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.Handlers; + +internal sealed class ClearAllVariablesAction : ProcessAction +{ + public ClearAllVariablesAction(ClearAllVariables source) + : base(source) + { + } + + protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + EvaluationResult result = context.ExpressionEngine.GetValue(this.Model.Variables, context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE + + result.Value.Handle(new ScopeHandler(context)); + + return Task.CompletedTask; + } + + private sealed class ScopeHandler(ProcessActionContext context) : IEnumVariablesToClearHandler + { + public void HandleAllGlobalVariables() + { + context.Engine.ClearScope(context.Scopes, ActionScopeType.Global); + } + + public void HandleConversationHistory() + { + throw new System.NotImplementedException(); // %%% LOG / NO EXCEPTION - Is this to be supported ??? + } + + public void HandleConversationScopedVariables() + { + context.Engine.ClearScope(context.Scopes, ActionScopeType.Topic); + } + + public void HandleUnknownValue() + { + // No scope to clear for unknown values. + } + + public void HandleUserScopedVariables() + { + context.Engine.ClearScope(context.Scopes, ActionScopeType.Env); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ConditionGroupAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ConditionGroupAction.cs new file mode 100644 index 0000000000..2e22e35737 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ConditionGroupAction.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative.Handlers; + +internal sealed class ConditionGroupAction : ProcessAction +{ + public static class Steps + { + public static string End(string id) => $"{id}_{nameof(End)}"; + } + + public ConditionGroupAction(ConditionGroup model) + : base(model) + { + } + + protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EditTableV2Action.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EditTableV2Action.cs new file mode 100644 index 0000000000..3b3d2505fa --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EditTableV2Action.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.Handlers; + +internal sealed class EditTableV2Action : AssignmentAction +{ + public EditTableV2Action(EditTableV2 model) + : base(model, Throw.IfNull(model.ItemsVariable?.Path, $"{nameof(model)}.{nameof(model.ItemsVariable)}.{nameof(InitializablePropertyPath.Path)}")) + { + } + + protected override async Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + FormulaValue table = context.Scopes.Get(this.Target.VariableName!, ActionScopeType.Parse(this.Target.VariableScopeName)); + TableValue tableValue = (TableValue)table; + + EditTableOperation? changeType = this.Model.ChangeType; + if (changeType is AddItemOperation addItemOperation) + { + EvaluationResult result = context.ExpressionEngine.GetValue(addItemOperation.Value!, context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE + RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), result.Value.ToFormulaValue()); + await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); + this.AssignTarget(context, tableValue); + } + else if (changeType is ClearItemsOperation) + { + await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false); + } + else if (changeType is RemoveItemOperation) // %%% SUPPORT + { + } + else if (changeType is TakeFirstItemOperation) // %%% SUPPORT + { + } + + static RecordValue BuildRecord(RecordType recordType, FormulaValue value) + { + return FormulaValue.NewRecordFromFields(recordType, GetValues()); + + IEnumerable GetValues() + { + // %%% TODO: expression.StructuredRecordExpression.Properties ??? + foreach (NamedFormulaType fieldType in recordType.GetFieldTypes()) + { + if (value is RecordValue recordValue) + { + yield return new NamedValue(fieldType.Name, recordValue.GetField(fieldType.Name)); + } + else + { + yield return new NamedValue(fieldType.Name, value); + } + } + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EndConversationAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EndConversationAction.cs new file mode 100644 index 0000000000..7770820088 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EndConversationAction.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative.Handlers; + +internal sealed class EndConversationAction : ProcessAction // %%% REMOVE ??? +{ + public EndConversationAction(EndConversation model) + : base(model) + { + } + + protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ForeachAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ForeachAction.cs new file mode 100644 index 0000000000..457361a2d3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ForeachAction.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.Handlers; + +internal sealed class ForeachAction : ProcessAction +{ + public static class Steps + { + public static string Start(string id) => $"{id}_{nameof(Start)}"; + public static string Next(string id) => $"{id}_{nameof(Next)}"; + public static string End(string id) => $"{id}_{nameof(End)}"; + } + + private int _index; + private FormulaValue[] _values; + + public ForeachAction(Foreach model) + : base(model) + { + this._values = []; + } + + public bool HasValue { get; private set; } + + protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + this._index = 0; + + if (this.Model.Items is null) + { + this._values = []; + this.HasValue = false; + return Task.CompletedTask; + } + + EvaluationResult result = context.ExpressionEngine.GetValue(this.Model.Items, context.Scopes); + TableDataValue tableValue = (TableDataValue)result.Value; // %%% CAST - TYPE ASSUMPTION (TableDataValue) + this._values = [.. tableValue.Values.Select(value => value.Properties.Values.First().ToFormulaValue())]; + return Task.CompletedTask; + } + + public void TakeNext(ProcessActionContext context) + { + if (this.HasValue = (this._index < this._values.Length)) + { + FormulaValue value = this._values[this._index]; + + context.Engine.SetScopedVariable(context.Scopes, Throw.IfNull(this.Model.Value), value); + + if (this.Model.Index is not null) + { + context.Engine.SetScopedVariable(context.Scopes, this.Model.Index.Path, FormulaValue.New(this._index)); + } + + this._index++; + } + } + + public void Reset(ProcessActionContext context) + { + context.Engine.ClearScopedVariable(context.Scopes, Throw.IfNull(this.Model.Value)); + if (this.Model.Index is not null) + { + context.Engine.ClearScopedVariable(context.Scopes, this.Model.Index); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ParseValueAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ParseValueAction.cs new file mode 100644 index 0000000000..2d73a84e15 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ParseValueAction.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.Handlers; + +internal sealed class ParseValueAction : AssignmentAction +{ + public ParseValueAction(ParseValue model) + : base(model, Throw.IfNull(model.Variable?.Path, $"{nameof(model)}.{nameof(model.Variable)}.{nameof(InitializablePropertyPath.Path)}")) + { + if (this.Model.Value is null) + { + throw new InvalidActionException($"{nameof(ParseValue)} must define {nameof(ParseValue.Value)}"); + } + } + + protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + EvaluationResult result = context.ExpressionEngine.GetValue(this.Model.Value!, context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE + + FormulaValue? parsedResult = null; + + if (result.Value is StringDataValue stringValue) + { + if (string.IsNullOrWhiteSpace(stringValue.Value)) + { + parsedResult = FormulaValue.NewBlank(); + } + else + { + parsedResult = + this.Model.ValueType switch + { + StringDataType => StringValue.New(stringValue.Value), + NumberDataType => NumberValue.New(stringValue.Value), + BooleanDataType => BooleanValue.New(stringValue.Value), + RecordDataType recordType => ParseRecord(recordType, stringValue.Value), + _ => null + }; + } + } + + if (parsedResult is null) + { + throw new ProcessActionException($"Unable to parse {result.Value.GetType().Name}"); + } + + this.AssignTarget(context, parsedResult); + + return Task.CompletedTask; + } + + private static RecordValue ParseRecord(RecordDataType recordType, string rawText) + { + string jsonText = rawText.TrimJsonDelimiter(); + JsonDocument json = JsonDocument.Parse(jsonText); + JsonElement currentElement = json.RootElement; + return recordType.ParseRecord(currentElement); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ResetVariableAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ResetVariableAction.cs new file mode 100644 index 0000000000..2131893076 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ResetVariableAction.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.Handlers; + +internal sealed class ResetVariableAction : AssignmentAction +{ + public ResetVariableAction(ResetVariable model) + : base(model, Throw.IfNull(model.Variable, $"{nameof(model)}.{nameof(model.Variable)}")) + { + } + + protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + context.Engine.ClearScopedVariable(context.Scopes, this.Target); + Console.WriteLine( // %%% LOGGER + $""" + !!! CLEAR {this.GetType().Name} [{this.Id}] + NAME: {this.Model.Variable!.Format()} + """); + + return Task.CompletedTask; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SendActivityAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SendActivityAction.cs new file mode 100644 index 0000000000..c62a82a855 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SendActivityAction.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative.Handlers; + +internal sealed class SendActivityAction : ProcessAction +{ + private readonly TextWriter _activityWriter; + + public SendActivityAction(SendActivity source, TextWriter activityWriter) + : base(source) + { + this._activityWriter = activityWriter; + } + + protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + Console.WriteLine($"\nACTIVITY: {this.Model.Activity?.GetType().Name ?? "Unknown"}"); // %%% LOGGER + + if (this.Model.Activity is MessageActivityTemplate messageActivity) + { + Console.ForegroundColor = ConsoleColor.Yellow; + try + { + if (!string.IsNullOrEmpty(messageActivity.Summary)) + { + this._activityWriter.WriteLine($"\t{messageActivity.Summary}"); + } + + string? activityText = context.Engine.Format(messageActivity.Text); + this._activityWriter.WriteLine(activityText + Environment.NewLine); + } + finally + { + Console.ResetColor(); + } + } + + return Task.CompletedTask; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetTextVariableAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetTextVariableAction.cs new file mode 100644 index 0000000000..b16cd6a81a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetTextVariableAction.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.Handlers; + +internal sealed class SetTextVariableAction : AssignmentAction +{ + public SetTextVariableAction(SetTextVariable model) + : base(model, Throw.IfNull(model.Variable?.Path, $"{nameof(model)}.{nameof(model.Variable)}.{nameof(InitializablePropertyPath.Path)}")) + { + } + + protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + FormulaValue result = FormulaValue.New(context.Engine.Format(this.Model.Value)); + + this.AssignTarget(context, result); + + return Task.CompletedTask; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetVariableAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetVariableAction.cs new file mode 100644 index 0000000000..851b33be74 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetVariableAction.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.Handlers; + +internal sealed class SetVariableAction : AssignmentAction +{ + public SetVariableAction(SetVariable model) + : base(model, Throw.IfNull(model.Variable?.Path, $"{nameof(model)}.{nameof(model.Variable)}.{nameof(InitializablePropertyPath.Path)}")) + { + } + + protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + if (this.Model.Value is null) + { + this.AssignTarget(context, FormulaValue.NewBlank()); + } + else + { + EvaluationResult result = context.ExpressionEngine.GetValue(this.Model.Value, context.Scopes); // %%% FAILURE CASE (CATCH) + + this.AssignTarget(context, result.Value.ToFormulaValue()); + } + + return Task.CompletedTask; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeActionExecutor.cs new file mode 100644 index 0000000000..ac25996418 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeActionExecutor.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Declarative; + +internal sealed class DeclarativeActionExecutor(string actionId, Func action) : + Executor(actionId), + IMessageHandler +{ + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + await action.Invoke().ConfigureAwait(false); + + //await context.AddEventAsync(new ExecutorInvokeEvent(this.Id, $"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}")).ConfigureAwait(false); + await context.SendMessageAsync($"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}").ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs new file mode 100644 index 0000000000..71c52fdeac --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Yaml; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Builder for converting a Foundry workflow object-model YAML definition into a process. +/// +public static class DeclarativeWorkflowBuilder +{ + /// + /// Builds a process from the provided YAML definition of a CPS Topic ObjectModel. + /// + /// The reader that provides the workflow object model YAML. + /// The identifier for the message. + /// The hosting context for the workflow. + /// The that corresponds with the YAML object model. + public static Workflow Build(TextReader yamlReader, string messageId, WorkflowContext? context = null) + { + Console.WriteLine("@ PARSING YAML"); + BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new InvalidOperationException("Unable to parse YAML content."); // %%% EXCEPTION TYPE + string rootId = $"root_{GetRootId(rootElement)}"; + + Console.WriteLine("@ INITIALIZING BUILDER"); + ProcessActionScopes scopes = new(); + DeclarativeWorkflowExecutor rootExecutor = new(scopes, rootId); + + Console.WriteLine("@ INTERPRETING WORKFLOW"); + ProcessActionVisitor visitor = new(rootExecutor, context ?? new WorkflowContext(), scopes); // %%% DEFAULT CONTEXT (IMMUTABLE) + ProcessActionWalker walker = new(rootElement, visitor); + + Console.WriteLine("@ FINALIZING WORKFLOW"); + //ProcessStepBuilder errorHandler = // %%% DYNAMIC/CONTEXT ??? + // processBuilder.AddStepFromFunction( + // $"{processBuilder.Name}_unhandled_error", + // (kernel, context) => + // { + // // Handle unhandled errors here + // Console.WriteLine("*** PROCESS ERROR - Unhandled error"); // %%% EXTERNAL + // return Task.CompletedTask; + // }); + //processBuilder.OnError().SendEventTo(new ProcessFunctionTargetBuilder(errorHandler)); + + return walker.Workflow; + } + + private static string GetRootId(BotElement element) => + element switch + { + AdaptiveDialog adaptiveDialog => adaptiveDialog.BeginDialog?.Id.Value ?? throw new InvalidOperationException("Undefined dialog"), // %%% EXCEPTION TYPE / WORKFLOW TYPE + _ => throw new InvalidOperationException($"Unsupported root element: {element.GetType().Name}."), // %%% EXCEPTION TYPE + }; +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowExecutor.cs new file mode 100644 index 0000000000..ef10548808 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowExecutor.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative; + +internal sealed class DeclarativeWorkflowExecutor(ProcessActionScopes scopes, string workflowId) : + Executor(workflowId), + IMessageHandler +{ + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + Console.WriteLine("!!! INIT WORKFLOW"); // %%% REMOVE + scopes.Set("LastMessage", ActionScopeType.System, StringValue.New(message)); // %%% MAGIC CONST "LastMessage" + + //await context.AddEventAsync(new ExecutorInvokeEvent(this.Id, $"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}")).ConfigureAwait(false); + await context.SendMessageAsync($"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}").ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidActionException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidActionException.cs new file mode 100644 index 0000000000..d5359eef5f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidActionException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Represents an exception that occurs when an action is invalid or cannot be processed. +/// +public sealed class InvalidActionException : ProcessWorkflowException +{ + /// + /// Initializes a new instance of the class. + /// + public InvalidActionException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public InvalidActionException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public InvalidActionException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs new file mode 100644 index 0000000000..a11dc6fdd6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Represents an exception that occurs when the specific scope is invalid. +/// +public sealed class InvalidScopeException : ProcessWorkflowException +{ + /// + /// Initializes a new instance of the class. + /// + public InvalidScopeException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public InvalidScopeException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public InvalidScopeException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidSegmentException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidSegmentException.cs new file mode 100644 index 0000000000..1b58a2b14a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidSegmentException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Represents an exception that occurs when an action is invalid or cannot be processed. +/// +public sealed class InvalidSegmentException : ProcessWorkflowException +{ + /// + /// Initializes a new instance of the class. + /// + public InvalidSegmentException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public InvalidSegmentException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public InvalidSegmentException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ProcessActionException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ProcessActionException.cs new file mode 100644 index 0000000000..47166b15b1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ProcessActionException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Represents an exception that occurs during the execution of a process action. +/// +public class ProcessActionException : ProcessWorkflowException +{ + /// + /// Initializes a new instance of the class. + /// + public ProcessActionException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public ProcessActionException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public ProcessActionException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ProcessWorkflowException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ProcessWorkflowException.cs new file mode 100644 index 0000000000..0f1847346b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ProcessWorkflowException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Represents any exception that occurs during the execution of a process workflow. +/// +public class ProcessWorkflowException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public ProcessWorkflowException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public ProcessWorkflowException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public ProcessWorkflowException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownActionException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownActionException.cs new file mode 100644 index 0000000000..dd41ecbeaf --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownActionException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Represents an exception that occurs when an action is invalid or cannot be processed. +/// +public sealed class UnknownActionException : ProcessWorkflowException +{ + /// + /// Initializes a new instance of the class. + /// + public UnknownActionException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public UnknownActionException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public UnknownActionException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownDataTypeException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownDataTypeException.cs new file mode 100644 index 0000000000..9e54d410c4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownDataTypeException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Represents an exception that occurs when an unknown data type is encountered. +/// +public sealed class UnknownDataTypeException : ProcessWorkflowException +{ + /// + /// Initializes a new instance of the class. + /// + public UnknownDataTypeException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public UnknownDataTypeException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public UnknownDataTypeException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowBuilderException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowBuilderException.cs new file mode 100644 index 0000000000..a78278c793 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowBuilderException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Represents an exception that occurs when building the process workflow. +/// +public class WorkflowBuilderException : ProcessWorkflowException +{ + /// + /// Initializes a new instance of the class. + /// + public WorkflowBuilderException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public WorkflowBuilderException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public WorkflowBuilderException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs new file mode 100644 index 0000000000..81b9527884 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative.Extensions; + +internal static class DataValueExtensions +{ + public static string? GetParentId(this BotElement element) => element.Parent?.GetId(); + + public static string GetId(this BotElement element) + { + return element switch + { + DialogAction action => action.Id.Value, + ConditionItem conditionItem => conditionItem.Id ?? throw new InvalidActionException($"Undefined identifier for {nameof(ConditionItem)} that is member of {conditionItem.GetParentId() ?? "(root)"}."), + OnActivity activity => activity.Id.Value, + _ => throw new InvalidActionException($"Unknown element type: {element.GetType().Name}"), + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs new file mode 100644 index 0000000000..1f7acc7dfc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.Extensions; + +internal static class BotElementExtensions +{ + public static FormulaValue ToFormulaValue(this DataValue? value) => + value switch + { + null => FormulaValue.NewBlank(), + BlankDataValue blankValue => BlankValue.NewBlank(), + BooleanDataValue boolValue => FormulaValue.New(boolValue.Value), + NumberDataValue numberValue => FormulaValue.New(numberValue.Value), + FloatDataValue floatValue => FormulaValue.New(floatValue.Value), + StringDataValue stringValue => FormulaValue.New(stringValue.Value), + DateTimeDataValue dateTimeValue => FormulaValue.New(dateTimeValue.Value.DateTime), + DateDataValue dateValue => FormulaValue.NewDateOnly(dateValue.Value), + TimeDataValue timeValue => FormulaValue.New(timeValue.Value), + TableDataValue tableValue => FormulaValue.NewTable(ParseRecordType(tableValue.Values.First()), tableValue.Values.Select(value => value.ToRecordValue())), // %%% TODO: RecordType + RecordDataValue recordValue => recordValue.ToRecordValue(), + //FileDataValue // %%% SUPPORT ??? + //OptionDataValue // %%% SUPPORT - Enum ??? + _ => FormulaValue.NewError(new Microsoft.PowerFx.ExpressionError { Message = $"Unknown literal type: {value.GetType().Name}" }), + }; + + public static FormulaType ToFormulaType(this DataType? type) => + type switch + { + null => FormulaType.Blank, + BooleanDataType => FormulaType.Boolean, + NumberDataType => FormulaType.Number, + FloatDataType => FormulaType.Decimal, + StringDataType => FormulaType.String, + DateTimeDataType => FormulaType.DateTime, + DateDataType => FormulaType.Date, + TimeDataType => FormulaType.Time, + //TableDataType => new TableType(), %%% ELEMENT TYPE + RecordDataType => RecordType.Empty(), + //FileDataType // %%% SUPPORT ??? + //OptionDataType // %%% SUPPORT - Enum ??? + DataType dataType => FormulaType.Blank, // %%% HANDLE ??? (FALLTHROUGH???) + //_ => FormulaType.Unknown, + }; + + public static RecordValue ToRecordValue(this RecordDataValue recordDataValue) => + FormulaValue.NewRecordFromFields( + recordDataValue.Properties.Select( + property => new NamedValue(property.Key, property.Value.ToFormulaValue()))); + + private static RecordType ParseRecordType(RecordDataValue record) + { + RecordType recordType = RecordType.Empty(); + foreach (KeyValuePair property in record.Properties) + { + recordType.Add(property.Key, property.Value.GetDataType().ToFormulaType()); + } + return recordType; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs new file mode 100644 index 0000000000..b1cc7440aa --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Drawing; +using System.Linq; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using BlankType = Microsoft.PowerFx.Types.BlankType; + +namespace Microsoft.Agents.Workflows.Declarative.Extensions; + +internal delegate object? GetFormulaValue(FormulaValue value); + +internal static class FormulaValueExtensions +{ + public static DataValue GetDataValue(this FormulaValue value) => + value switch + { + BooleanValue booleanValue => booleanValue.ToDataValue(), + DecimalValue decimalValue => decimalValue.ToDataValue(), + NumberValue numberValue => numberValue.ToDataValue(), + DateValue dateValue => dateValue.ToDataValue(), + DateTimeValue datetimeValue => datetimeValue.ToDataValue(), + TimeValue timeValue => timeValue.ToDataValue(), + StringValue stringValue => stringValue.ToDataValue(), + GuidValue guidValue => guidValue.ToDataValue(), // %%% CORRECT ??? + BlankValue blankValue => blankValue.ToDataValue(), + VoidValue voidValue => voidValue.ToDataValue(), + TableValue tableValue => tableValue.ToDataValue(), + RecordValue recordValue => recordValue.ToDataValue(), + //BlobValue // %%% DataValue ??? + //ErrorValue // %%% DataValue ??? + _ => throw new NotSupportedException($"Unsupported FormulaValue type: {value.GetType().Name}"), + }; + + public static DataType GetDataType(this FormulaValue value) => + value.Type switch + { + null => DataType.Blank, + BooleanType => DataType.Boolean, + DecimalType => DataType.Number, + NumberType => DataType.Float, + DateType => DataType.Date, + DateTimeType => DataType.DateTime, + TimeType => DataType.Time, + StringType => DataType.String, + GuidType => DataType.String, + BlankType => DataType.String, + RecordType => DataType.EmptyRecord, + //BlobValue // %%% DataType ??? + //ErrorValue // %%% DataType ??? + UnknownType => DataType.Unspecified, + _ => DataType.Unspecified, + }; + + public static string? Format(this FormulaValue value) => + value switch + { + BooleanValue booleanValue => $"{booleanValue.Value}", + DecimalValue decimalValue => $"{decimalValue.Value}", + NumberValue numberValue => $"{numberValue.Value}", + DateValue dateValue => $"{dateValue.GetConvertedValue(TimeZoneInfo.Utc)}", + DateTimeValue datetimeValue => $"{datetimeValue.GetConvertedValue(TimeZoneInfo.Utc)}", + TimeValue timeValue => $"{timeValue.Value}", + StringValue stringValue => $"{stringValue.Value}", + GuidValue guidValue => $"{guidValue.Value}", + BlankValue blankValue => string.Empty, + VoidValue voidValue => string.Empty, + TableValue tableValue => tableValue.ToString(), // %%% WORK ??? + RecordValue recordValue => recordValue.ToString(), + //BlobValue // %%% DataValue ??? + //ErrorValue // %%% DataValue ??? + _ => throw new NotSupportedException($"Unsupported FormulaValue type: {value.GetType().Name}"), + }; + + // %%% TODO: Type conversion + + public static BooleanDataValue ToDataValue(this BooleanValue value) => BooleanDataValue.Create(value.Value); + public static NumberDataValue ToDataValue(this DecimalValue value) => NumberDataValue.Create(value.Value); + public static FloatDataValue ToDataValue(this NumberValue value) => FloatDataValue.Create(value.Value); + public static DateTimeDataValue ToDataValue(this DateTimeValue value) => DateTimeDataValue.Create(value.GetConvertedValue(TimeZoneInfo.Utc)); + public static DateDataValue ToDataValue(this DateValue value) => DateDataValue.Create(value.GetConvertedValue(TimeZoneInfo.Utc)); + public static TimeDataValue ToDataValue(this TimeValue value) => TimeDataValue.Create(value.Value); + public static StringDataValue ToDataValue(this StringValue value) => StringDataValue.Create(value.Value); + public static StringDataValue ToDataValue(this GuidValue value) => StringDataValue.Create(value.Value.ToString("N")); // %%% FORMAT ??? + public static DataValue ToDataValue(this BlankValue _) => BlankDataValue.Blank(); + public static DataValue ToDataValue(this VoidValue _) => BlankDataValue.Blank(); // %%% CORRECT ??? + public static StringDataValue ToDataValue(this ColorValue value) => StringDataValue.Create(Enum.GetName(typeof(Color), value.Value)!); // %%% CORRECT ??? + + public static TableDataValue ToDataValue(this TableValue value) => + TableDataValue.TableFromRecords(value.Rows.Select(row => row.Value.ToDataValue()).ToImmutableArray()); + + public static RecordDataValue ToDataValue(this RecordValue value) => + RecordDataValue.RecordFromFields(value.OriginalFields.Select(field => field.GetKeyValuePair()).ToImmutableArray()); + + private static KeyValuePair GetKeyValuePair(this NamedValue value) => new(value.Name, value.Value.GetDataValue()); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/PropertyPathExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/PropertyPathExtensions.cs new file mode 100644 index 0000000000..634b367d64 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/PropertyPathExtensions.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative.Extensions; + +internal static class PropertyPathExtensions +{ + public static string Format(this PropertyPath path) => $"{path.VariableScopeName}.{path.VariableName}"; +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs new file mode 100644 index 0000000000..38ca8ad053 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.Extensions; + +internal static class RecordDataTypeExtensions +{ + public static RecordValue ParseRecord(this RecordDataType recordType, JsonElement currentElement) + { + return FormulaValue.NewRecordFromFields(ParseValues()); + + IEnumerable ParseValues() + { + foreach (KeyValuePair property in recordType.Properties) + { + JsonElement propertyElement = currentElement.GetProperty(property.Key); + FormulaValue? parsedValue = + property.Value.Type switch + { + StringDataType => StringValue.New(propertyElement.GetString()), + NumberDataType => NumberValue.New(propertyElement.GetDecimal()), + BooleanDataType => BooleanValue.New(propertyElement.GetBoolean()), + DateTimeDataType dateTimeType => DateTimeValue.New(propertyElement.GetDateTime()), + DateDataType dateType => DateValue.New(propertyElement.GetDateTime()), + TimeDataType timeType => TimeValue.New(propertyElement.GetDateTimeOffset().TimeOfDay), + RecordDataType recordType => recordType.ParseRecord(propertyElement), + //TableDataValue tableValue => // %%% SUPPORT + _ => throw new UnknownDataTypeException($"Unsupported data type '{property.Value.Type}' for property '{property.Key}'") + }; + yield return new NamedValue(property.Key, parsedValue); + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/StringExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/StringExtensions.cs new file mode 100644 index 0000000000..dba1c54ac6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/StringExtensions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.RegularExpressions; + +namespace Microsoft.Agents.Workflows.Declarative.Extensions; + +internal static class StringExtensions +{ + private static readonly Regex s_regex = new(@"^```(?:\w*)\s*([\s\S]*?)\s*```$", RegexOptions.Compiled | RegexOptions.Multiline); + + public static string TrimJsonDelimiter(this string value) + { + Match match = s_regex.Match(value.Trim()); + if (match.Success) + { + return match.Groups[1].Value.Trim(); + } + + return value.Trim(); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/TemplateExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/TemplateExtensions.cs new file mode 100644 index 0000000000..82cd0cdcdf --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/TemplateExtensions.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.Extensions; + +internal static class TemplateExtensions +{ + public static string? Format(this RecalcEngine engine, IEnumerable template) + { + return string.Concat(template.Select(line => engine.Format(line))); + } + + public static string? Format(this RecalcEngine engine, TemplateLine? line) + { + return string.Concat(line?.Segments.Select(segment => engine.Format(segment)) ?? [string.Empty]); + } + + private static string? Format(this RecalcEngine engine, TemplateSegment segment) + { + if (segment is TextSegment textSegment) + { + return textSegment.Value; + } + + if (segment is ExpressionSegment expressionSegment) + { + if (expressionSegment.Expression is not null) + { + if (expressionSegment.Expression.ExpressionText is not null) + { + FormulaValue expressionValue = engine.Eval(expressionSegment.Expression.ExpressionText); + return expressionValue.Format(); + } + if (expressionSegment.Expression.VariableReference is not null) + { + FormulaValue expressionValue = engine.Eval(expressionSegment.Expression.VariableReference.ToString()); + return expressionValue.Format(); + } + } + } + + throw new InvalidSegmentException($"Unsupported segment type: {segment.GetType().Name}"); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Microsoft.Agents.Workflows.Declarative.csproj b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Microsoft.Agents.Workflows.Declarative.csproj new file mode 100644 index 0000000000..91e72055aa --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Microsoft.Agents.Workflows.Declarative.csproj @@ -0,0 +1,39 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + alpha + + + + true + true + true + + + + + + + Microsoft Agent Workflow Framework + Contains the Microsoft Agent Declarative Workflow Framework. + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/FoundryExpressionEngine.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/FoundryExpressionEngine.cs new file mode 100644 index 0000000000..eb8ce1cb32 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/FoundryExpressionEngine.cs @@ -0,0 +1,329 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.Bot.ObjectModel.Exceptions; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.PowerFx; + +internal class FoundryExpressionEngine : IExpressionEngine +{ + //private static readonly JsonSerializerOptions s_options = new(); // %%% INVESTIGATE: ElementSerializer.CreateOptions(); + + private readonly RecalcEngine _engine; + + public FoundryExpressionEngine(RecalcEngine engine) + { + this._engine = engine; + } + + public EvaluationResult GetValue(BoolExpression boolean, ProcessActionScopes state) => this.GetValue(boolean, state, this.EvaluateScope); + + public EvaluationResult GetValue(BoolExpression boolean, RecordDataValue state) => this.GetValue(boolean, state, this.EvaluateState); + + public EvaluationResult GetValue(StringExpression expression, ProcessActionScopes state) => this.GetValue(expression, state, this.EvaluateScope); + + public EvaluationResult GetValue(StringExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); + + public EvaluationResult GetValue(ValueExpression expression, ProcessActionScopes state) => this.GetValue(expression, state, this.EvaluateScope); + + public EvaluationResult GetValue(ValueExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); + + public EvaluationResult GetValue(IntExpression expression, ProcessActionScopes state) => this.GetValue(expression, state, this.EvaluateScope); + + public EvaluationResult GetValue(IntExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); + + public EvaluationResult GetValue(NumberExpression expression, ProcessActionScopes state) => this.GetValue(expression, state, this.EvaluateScope); + + public EvaluationResult GetValue(NumberExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); + + public EvaluationResult GetValue(ObjectExpression expression, ProcessActionScopes state) where TValue : BotElement => this.GetValue(expression, state, this.EvaluateScope); + + public EvaluationResult GetValue(ObjectExpression expression, RecordDataValue state) where TValue : BotElement => this.GetValue(expression, state, this.EvaluateState); + + public ImmutableArray GetValue(ArrayExpression expression, ProcessActionScopes state) => this.GetValue(expression, state, this.EvaluateScope).Value; + + public ImmutableArray GetValue(ArrayExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState).Value; + + public ImmutableArray GetValue(ArrayExpressionOnly expression, ProcessActionScopes state) => this.GetValue(expression, state, this.EvaluateScope).Value; + + public ImmutableArray GetValue(ArrayExpressionOnly expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState).Value; + + public EvaluationResult GetValue(EnumExpression expression, ProcessActionScopes state) where TValue : EnumWrapper => + this.GetValue(expression, state, this.EvaluateScope); + + public EvaluationResult GetValue(EnumExpression expression, RecordDataValue state) where TValue : EnumWrapper => + this.GetValue(expression, state, this.EvaluateState); + + public DialogSchemaName GetValue(DialogExpression expression, RecordDataValue state) + { + throw new NotSupportedException(); + } + + public EvaluationResult GetValue(AdaptiveCardExpression expression, RecordDataValue state) + { + throw new NotSupportedException(); + } + + public EvaluationResult GetValue(FileExpression expression, RecordDataValue state) + { + throw new NotSupportedException(); + } + + private EvaluationResult GetValue(BoolExpression expression, TState state, Func> evaluator) + { + Throw.IfNull(expression, nameof(expression)); + + if (expression.IsLiteral) + { + return new EvaluationResult(expression.LiteralValue, SensitivityLevel.None); + } + + EvaluationResult expressionResult = evaluator.Invoke(expression, state); + + if (expressionResult.Value is BlankValue) + { + return new EvaluationResult(default, SensitivityLevel.None); + } + + if (expressionResult.Value is not BooleanValue formulaValue) + { + throw new InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.Boolean); + } + + return new EvaluationResult(formulaValue.Value, expressionResult.Sensitivity); + } + + private EvaluationResult GetValue(StringExpression expression, TState state, Func> evaluator) + { + Throw.IfNull(expression, nameof(expression)); + + if (expression.IsLiteral) + { + return new EvaluationResult(expression.LiteralValue, SensitivityLevel.None); + } + + EvaluationResult expressionResult = evaluator.Invoke(expression, state); + + if (expressionResult.Value is BlankValue) + { + return new EvaluationResult(string.Empty, expressionResult.Sensitivity); + } + + if (expressionResult.Value is RecordValue recordValue) + { + JsonSerializerContext context = null!; // %%% HACK + //context.Options = s_options; + return new EvaluationResult(JsonSerializer.Serialize(recordValue, typeof(RecordValue), context), expressionResult.Sensitivity); + } + + if (expressionResult.Value is not StringValue formulaValue) + { + throw new InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.String); + } + + return new EvaluationResult(formulaValue.Value, expressionResult.Sensitivity); + } + + private EvaluationResult GetValue(IntExpression expression, TState state, Func> evaluator) + { + Throw.IfNull(expression, nameof(expression)); + + if (expression.IsLiteral) + { + return new EvaluationResult(expression.LiteralValue, SensitivityLevel.None); + } + + EvaluationResult expressionResult = evaluator.Invoke(expression, state); + + if (expressionResult.Value is not PrimitiveValue formulaValue) // %%% CORRECT ??? + { + throw new InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.Number); + } + + return new EvaluationResult(formulaValue.Value, expressionResult.Sensitivity); + } + + private EvaluationResult GetValue(NumberExpression expression, TState state, Func> evaluator) + { + Throw.IfNull(expression, nameof(expression)); + + if (expression.IsLiteral) + { + return new EvaluationResult(expression.LiteralValue, SensitivityLevel.None); + } + + EvaluationResult expressionResult = evaluator.Invoke(expression, state); + + if (expressionResult.Value is not NumberValue formulaValue) + { + throw new InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.Number); + } + + return new EvaluationResult(formulaValue.Value, expressionResult.Sensitivity); + } + + private EvaluationResult GetValue(ValueExpression expression, TState state, Func> evaluator) + { + Throw.IfNull(expression, nameof(expression)); + + if (expression.IsLiteral) + { + return new EvaluationResult(expression.LiteralValue ?? BlankDataValue.Instance, SensitivityLevel.None); + } + + EvaluationResult expressionResult = evaluator.Invoke(expression, state); + + return new EvaluationResult(expressionResult.Value.GetDataValue(), expressionResult.Sensitivity); + } + + private EvaluationResult GetValue(EnumExpression expression, TState state, Func> evaluator) where TValue : EnumWrapper + { + Throw.IfNull(expression, nameof(expression)); + + if (expression.IsLiteral) + { + return new EvaluationResult(expression.LiteralValue, SensitivityLevel.None); + } + + EvaluationResult expressionResult = evaluator.Invoke(expression, state); + + return expressionResult.Value switch + { + BlankValue => new EvaluationResult(EnumWrapper.Create(0), expressionResult.Sensitivity), + StringValue s when s.Value is not null => new EvaluationResult(EnumWrapper.Create(s.Value), expressionResult.Sensitivity), + StringValue => new EvaluationResult(EnumWrapper.Create(0), expressionResult.Sensitivity), + NumberValue number => new EvaluationResult(EnumWrapper.Create((int)number.Value), expressionResult.Sensitivity), + //OptionDataValue option => new EvaluationResult(EnumWrapper.Create(option.Value.Value), expressionResult.Sensitivity), // %%% SUPPORT + _ => throw new InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.String), + }; + } + + private EvaluationResult GetValue(ObjectExpression expression, TState state, Func> evaluator) where TValue : BotElement + { + Throw.IfNull(expression, nameof(expression)); + + if (expression.LiteralValue != null) + { + return new EvaluationResult(expression.LiteralValue, SensitivityLevel.None); + } + + EvaluationResult expressionResult = evaluator.Invoke(expression, state); + + if (expressionResult.Value is BlankValue) + { + return new EvaluationResult(null, expressionResult.Sensitivity); + } + + if (expressionResult.Value is not RecordValue formulaValue) + { + throw new CannotParseObjectExpressionOutputException(typeof(TValue), expressionResult.Value.GetDataType()); + } + + try + { + return new EvaluationResult(ObjectExpressionParser.Parse(formulaValue.ToDataValue()), expressionResult.Sensitivity); + } + catch (Exception exception) + { + throw new CannotParseObjectExpressionOutputException(typeof(TValue), exception); + } + } + + private EvaluationResult> GetValue(ArrayExpression expression, TState state, Func> evaluator) + { + Throw.IfNull(expression, nameof(expression)); + + if (expression.IsLiteral) + { + return new EvaluationResult>(expression.LiteralValue, SensitivityLevel.None); + } + + EvaluationResult expressionResult = evaluator.Invoke(expression, state); + + return new EvaluationResult>(ParseArrayResults(expressionResult.Value), expressionResult.Sensitivity); + } + + private EvaluationResult> GetValue(ArrayExpressionOnly expression, TState state, Func> evaluator) + { + Throw.IfNull(expression, nameof(expression)); + + EvaluationResult expressionResult = evaluator.Invoke(expression, state); + + return new EvaluationResult>(ParseArrayResults(expressionResult.Value), expressionResult.Sensitivity); + } + + private static ImmutableArray ParseArrayResults(FormulaValue value) + { + if (value is BlankValue) + { + return ImmutableArray.Create(); + } + + if (value is not TableValue tableValue) + { + throw new CannotParseObjectExpressionOutputException(typeof(ImmutableArray), value.GetDataType()); + } + + TableDataValue tableDataValue = tableValue.ToDataValue(); + try + { + List list = []; + foreach (RecordDataValue row in tableDataValue.Values) + { + TValue? s = TableItemParser.Parse(row); + if (s != null) + { + list.Add(s); + } + } + return list.ToImmutableArray(); + } + catch (Exception exception) + { + throw new CannotParseObjectExpressionOutputException(typeof(TValue), exception); + } + } + + private EvaluationResult EvaluateState(ExpressionBase expression, RecordDataValue state) + { + foreach (KeyValuePair kvp in state.Properties) + { + if (kvp.Value is RecordDataValue scopeRecord) + { + this._engine.SetScope(kvp.Key, scopeRecord.ToRecordValue()); + } + } + + return this.Evaluate(expression); + } + + private EvaluationResult EvaluateScope(ExpressionBase expression, ProcessActionScopes state) + { + this._engine.SetScope(ActionScopeType.System.Name, state.BuildRecord(ActionScopeType.System)); + this._engine.SetScope(ActionScopeType.Env.Name, state.BuildRecord(ActionScopeType.Env)); + this._engine.SetScope(ActionScopeType.Global.Name, state.BuildRecord(ActionScopeType.Global)); + this._engine.SetScope(ActionScopeType.Topic.Name, state.BuildRecord(ActionScopeType.Topic)); + + return this.Evaluate(expression); + } + + private EvaluationResult Evaluate(ExpressionBase expression) + { + string? expressionText = + expression.IsVariableReference ? + expression.VariableReference?.Format() : + expression.ExpressionText; + + return new(this._engine.Eval(expressionText), SensitivityLevel.None); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineExtensions.cs new file mode 100644 index 0000000000..298fc96dc3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineExtensions.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.PowerFx; + +internal static class RecalcEngineExtensions +{ + public static void ClearScope(this RecalcEngine engine, ProcessActionScopes scopes, ActionScopeType scope) + { + // Clear all scope values. + scopes.Clear(scope); + + // Rebuild scope record and update engine + engine.UpdateScope(scopes, scope); + } + + public static void ClearScopedVariable(this RecalcEngine engine, ProcessActionScopes scopes, PropertyPath variablePath) => + engine.ClearScopedVariable(scopes, ActionScopeType.Parse(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName)); + + public static void ClearScopedVariable(this RecalcEngine engine, ProcessActionScopes scopes, ActionScopeType scope, string varName) + { + // Clear value. + scopes.Remove(varName, scope); + + // Rebuild scope record and update engine + engine.UpdateScope(scopes, scope); + } + + public static void SetScopedVariable(this RecalcEngine engine, ProcessActionScopes scopes, PropertyPath variablePath, FormulaValue value) => + engine.SetScopedVariable(scopes, ActionScopeType.Parse(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName), value); + + public static void SetScopedVariable(this RecalcEngine engine, ProcessActionScopes scopes, ActionScopeType scope, string varName, FormulaValue value) + { + // Assign value. + scopes.Set(varName, scope, value); + + // Rebuild scope record and update engine + engine.UpdateScope(scopes, scope); + } + + public static void SetScope(this RecalcEngine engine, string scopeName, RecordValue scopeRecord) + { + engine.DeleteFormula(scopeName); + engine.UpdateVariable(scopeName, scopeRecord); + } + + private static void UpdateScope(this RecalcEngine engine, ProcessActionScopes scopes, ActionScopeType scope) + { + RecordValue scopeRecord = scopes.BuildRecord(scope); + engine.SetScope(scope.Name, scopeRecord); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs new file mode 100644 index 0000000000..978895f54a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.PowerFx; + +internal static class RecalcEngineFactory +{ + public static RecalcEngine Create( + ProcessActionScopes scopes, + int? maximumExpressionLength = null, + int? maximumCallDepth = null) + { + RecalcEngine engine = new(CreateConfig()); + + SetScope(ActionScopeType.Topic); + SetScope(ActionScopeType.Global); + SetScope(ActionScopeType.Env); + SetScope(ActionScopeType.System); + + return engine; + + void SetScope(ActionScopeType scope) + { + RecordValue record = scopes.BuildRecord(scope); + engine.UpdateVariable(scope.Name, record); + } + + PowerFxConfig CreateConfig() + { + PowerFxConfig config = new(Features.PowerFxV1); + + if (maximumExpressionLength is not null) + { + config.MaximumExpressionLength = maximumExpressionLength.Value; + } + + if (maximumCallDepth is not null) + { + config.MaxCallDepth = maximumCallDepth.Value; + } + + config.EnableSetFunction(); + + return config; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessAction.cs new file mode 100644 index 0000000000..766236917f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessAction.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Agents.Persistent; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.Logging; +using Microsoft.PowerFx; + +namespace Microsoft.Agents.Workflows.Declarative; + +internal sealed record class ProcessActionContext(RecalcEngine Engine, ProcessActionScopes Scopes, Func ClientFactory, ILogger Logger) +{ + private FoundryExpressionEngine? _expressionEngine; + + public FoundryExpressionEngine ExpressionEngine => this._expressionEngine ??= new FoundryExpressionEngine(this.Engine); +} + +internal abstract class ProcessAction(TAction model) : ProcessAction(model) + where TAction : DialogAction +{ + public new TAction Model => (TAction)base.Model; +} + +internal abstract class ProcessAction +{ + public const string RootActionId = "(root)"; + + private string? _parentId; + + public string Id => this.Model.Id.Value; + + public string ParentId => this._parentId ??= this.Model.GetParentId() ?? RootActionId; + + public DialogAction Model { get; } + + protected ProcessAction(DialogAction model) + { + if (!model.HasRequiredProperties) + { + throw new InvalidActionException($"Action {this.GetType().Name} [{model.Id}]"); + } + + this.Model = model; + } + + public async Task ExecuteAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + // Execute each action in the current context + await this.HandleAsync(context, cancellationToken).ConfigureAwait(false); + } + catch (ProcessWorkflowException exception) + { + context.Logger.LogError(exception, "*** ACTION [{Id}] ERROR - {TypeName}\n{Message}", this.Id, this.GetType().Name, exception.Message); + throw; + } + catch (Exception exception) + { + context.Logger.LogError(exception, "*** ACTION [{Id}] ERROR - {TypeName}\n{Message}", this.Id, this.GetType().Name, exception.Message); + throw new ProcessWorkflowException($"Unexpected failure executing action #{this.Id} [{this.GetType().Name}]", exception); + } + } + + protected abstract Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionScopes.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionScopes.cs new file mode 100644 index 0000000000..000ab00135 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionScopes.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Microsoft.Agents.Workflows.Declarative.Extensions; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Describes the type of action scope. +/// +internal sealed class ActionScopeType // %%% NEEDED +{ + // https://msazure.visualstudio.com/CCI/_git/ObjectModel?path=/src/ObjectModel/Nodes/VariableScopeNames.cs&_a=contents&version=GBmain + public static readonly ActionScopeType Env = new(VariableScopeNames.Environment); + public static readonly ActionScopeType Topic = new(VariableScopeNames.Topic); + public static readonly ActionScopeType Global = new(VariableScopeNames.Global); + public static readonly ActionScopeType System = new(VariableScopeNames.System); + + public static ActionScopeType Parse(string? scope) + { + return scope switch + { + nameof(Env) => Env, + nameof(Global) => Global, + nameof(System) => System, + nameof(Topic) => Topic, + null => throw new InvalidScopeException("Undefined action scope type."), + _ => throw new InvalidScopeException($"Unknown action scope type: {scope}."), + }; + } + + private ActionScopeType(string name) + { + this.Name = name; + } + + public string Name { get; } + + public string Format(string name) => $"{this.Name}.{name}"; + + public override string ToString() => this.Name; + + public override int GetHashCode() => this.Name.GetHashCode(); + + public override bool Equals(object? obj) => + (obj is ActionScopeType other && this.Name.Equals(other.Name, StringComparison.Ordinal)) || + (obj is string name && this.Name.Equals(name, StringComparison.Ordinal)); +} + +/// +/// The set of variables for a specific action scope. +/// +internal sealed class ProcessActionScope : Dictionary +{ + public RecordValue BuildRecord() + { + return FormulaValue.NewRecordFromFields(GetFields()); + + IEnumerable GetFields() + { + foreach (KeyValuePair kvp in this) + { + yield return new NamedValue(kvp.Key, kvp.Value); + } + } + } + + public RecordDataValue BuildState() + { + RecordDataValue.Builder recordBuilder = new(); + + foreach (KeyValuePair kvp in this) + { + recordBuilder.Properties.Add(kvp.Key, kvp.Value.GetDataValue()); + } + + return recordBuilder.Build(); + } +} + +/// +/// Contains all action scopes for a process. +/// +internal sealed class ProcessActionScopes +{ + private readonly ImmutableDictionary _scopes; + + public ProcessActionScopes() + { + Dictionary scopes = + new() + { + { ActionScopeType.Env, [] }, + { ActionScopeType.Topic, [] }, + { ActionScopeType.Global, [] }, + { ActionScopeType.System, [] }, + }; + + this._scopes = scopes.ToImmutableDictionary(); + } + + public RecordValue BuildRecord(ActionScopeType scope) => this._scopes[scope].BuildRecord(); + + public RecordDataValue BuildState() + { + return RecordDataValue.RecordFromFields(BuildStateFields()); + + IEnumerable> BuildStateFields() + { + foreach (KeyValuePair kvp in this._scopes) + { + yield return new(kvp.Key.Name, kvp.Value.BuildState()); + } + } + } + + public FormulaValue Get(string name, ActionScopeType? type = null) + { + if (this._scopes[type ?? ActionScopeType.Topic].TryGetValue(name, out FormulaValue? value)) + { + return value; + } + + return FormulaValue.NewBlank(); + } + + public void Clear(ActionScopeType type) => this._scopes[type].Clear(); + + public void Remove(string name) => this.Remove(name, ActionScopeType.Topic); + + public void Remove(string name, ActionScopeType type) => this._scopes[type].Remove(name); + + public void Set(string name, FormulaValue value) => this.Set(name, ActionScopeType.Topic, value); + + public void Set(string name, ActionScopeType type, FormulaValue value) => this._scopes[type][name] = value; +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionStack.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionStack.cs new file mode 100644 index 0000000000..4dcd582bad --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionStack.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Agents.Workflows.Declarative; + +internal delegate void ScopeCompletionAction(string scopeId); // %%% NEEDED: scopeId ??? + +internal sealed class ProcessActionStack +{ + private readonly Stack _actionStack = []; + private readonly Dictionary _actionScopes = []; + + public string CurrentScope => + this._actionStack.Count > 0 ? + this._actionStack.Peek() : + throw new InvalidOperationException("No scope defined"); // %%% EXCEPTION TYPE + + public void Recognize(string scopeId, ScopeCompletionAction? callback = null) + { +#if NET + if (this._actionScopes.TryAdd(scopeId, callback)) + { +#else + if (!this._actionScopes.ContainsKey(scopeId)) + { + this._actionScopes[scopeId] = callback; +#endif + // If the scope is new, push it onto the stack + this._actionStack.Push(scopeId); + } + else + { + // Otherwise, unwind the stack to the given scope + string currentScopeId; + while ((currentScopeId = this.CurrentScope) != scopeId) + { + ScopeCompletionAction? unwoundCallback = this._actionScopes[currentScopeId]; + unwoundCallback?.Invoke(currentScopeId); + this._actionStack.Pop(); + this._actionScopes.Remove(currentScopeId); + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionVisitor.cs new file mode 100644 index 0000000000..7f558e57b7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionVisitor.cs @@ -0,0 +1,543 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Azure.AI.Agents.Persistent; +using Azure.Core.Pipeline; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Handlers; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx; +using Microsoft.SemanticKernel.Process.Workflows.Actions; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative; + +internal sealed class ProcessActionVisitor : DialogActionVisitor +{ + private readonly WorkflowBuilder _workflowBuilder; + private readonly ProcessWorkflowBuilder _workflowModel; + private readonly ProcessActionStack _actionStack; + private readonly WorkflowContext _context; + private readonly ProcessActionScopes _scopes; + + public ProcessActionVisitor( + ExecutorIsh rootAction, + WorkflowContext context, + ProcessActionScopes scopes) + { + this._actionStack = new ProcessActionStack(); + this._workflowModel = new ProcessWorkflowBuilder(rootAction); + this._workflowBuilder = new WorkflowBuilder(rootAction); + this._context = context; + this._scopes = scopes; + } + + public Workflow Complete() + { + // Process the cached links + this._workflowModel.ConnectNodes(this._workflowBuilder); + + // Build final workflow + return this._workflowBuilder.Build(); + } + + protected override void Visit(ActionScope item) + { + this.Trace(item, isSkipped: false); + + string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); // %%% NULL PARENT CASE ??? + if (item.Id.Equals(parentId)) + { + parentId = $"root_{parentId}"; + } + this.ContinueWith(this.CreateStep(item.Id.Value, nameof(ActionScope)), parentId); + //this._workflowBuilder.AddLink(parentId, item.Id.Value); // %%% NEEDED ??? + } + + protected override void Visit(ConditionGroup item) + { + this.Trace(item, isSkipped: false); + + //ConditionGroupAction action = new(item); + //this.ContinueWith(action); + //this.RestartFrom(item.Id.Value, nameof(ConditionGroupAction), action.ParentId); + + //// %%% SUPPORT: item.ElseActions + + //int index = 1; + //foreach (ConditionItem conditionItem in item.Conditions) + //{ + // // Visit each action in the condition item + // conditionItem.Accept(this); + + // ++index; + //} + } + + public override void VisitConditionItem(ConditionItem item) + { + this.Trace(item); + + //Func? condition = null; + + //if (item.Condition is not null) + //{ + // // %%% VERIFY IF ONLY ONE CONDITION IS EXPECTED / ALLOWED + // condition = + // new(() => + // { + // RecalcEngine engine = this.CreateEngine(); + // bool result = engine.Eval(item.Condition.ExpressionText ?? "true").AsBoolean(); + // Console.WriteLine($"!!! CONDITION: {item.Condition.ExpressionText ?? "true"}={result}"); + // return result; + // }); + //} + + //string stepId = item.Id ?? $"{nameof(ConditionItem)}_{Guid.NewGuid():N}"; + //string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); // %%% NULL PARENT CASE ??? + //this.ContinueWith(this.CreateStep(stepId, nameof(ConditionItem)), parentId, condition, callback: CompletionHandler); + + //base.VisitConditionItem(item); + + //void CompletionHandler(string _) + //{ + // string completionId = ConditionGroupAction.Steps.End(stepId); + // this.ContinueWith(this.CreateStep(completionId, $"{nameof(ConditionItem)}_End"), stepId); + // this._workflowBuilder.AddLink(completionId, RestartId(parentId)); + //} + } + + protected override void Visit(GotoAction item) + { + this.Trace(item, isSkipped: false); + + string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); // %%% NULL PARENT CASE ??? + this.ContinueWith(this.CreateStep(item.Id.Value, nameof(GotoAction)), parentId); + this._workflowModel.AddLink(item.Id.Value, item.ActionId.Value); + this.RestartFrom(item.Id.Value, nameof(GotoAction), parentId); + } + + protected override void Visit(Foreach item) + { + this.Trace(item, isSkipped: false); + + ForeachAction action = new(item); + this.ContinueWith(action); + string restartId = this.RestartFrom(action); + string loopId = ForeachAction.Steps.Next(action.Id); + this.ContinueWith(this.CreateStep(loopId, $"{nameof(ForeachAction)}_Next", action.TakeNext), action.Id, callback: CompletionHandler); + this._workflowModel.AddLink(loopId, restartId, (_) => !action.HasValue); + this.ContinueWith(this.CreateStep(ForeachAction.Steps.Start(action.Id), $"{nameof(ForeachAction)}_Start"), action.Id, (_) => action.HasValue); + void CompletionHandler(string _) + { + string completionId = ForeachAction.Steps.End(action.Id); + this.ContinueWith(this.CreateStep(completionId, $"{nameof(ForeachAction)}_End"), action.Id); + this._workflowModel.AddLink(completionId, loopId); + } + } + + protected override void Visit(BreakLoop item) // %%% SUPPORT + { + this.Trace(item, isSkipped: false); + + string? loopId = this._workflowModel.LocateParent(item.GetParentId()); + if (loopId is not null) + { + string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); // %%% NULL PARENT CASE ??? + this.ContinueWith(this.CreateStep(item.Id.Value, nameof(BreakLoop)), parentId); + this._workflowModel.AddLink(item.Id.Value, RestartId(loopId)); + this.RestartFrom(item.Id.Value, nameof(BreakLoop), parentId); + } + } + + protected override void Visit(ContinueLoop item) // %%% SUPPORT + { + this.Trace(item, isSkipped: false); + + string? loopId = this._workflowModel.LocateParent(item.GetParentId()); + if (loopId is not null) + { + string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); // %%% NULL PARENT CASE ??? + this.ContinueWith(this.CreateStep(item.Id.Value, nameof(ContinueLoop)), parentId); + this._workflowModel.AddLink(item.Id.Value, ForeachAction.Steps.Next(loopId)); + this.RestartFrom(item.Id.Value, nameof(ContinueLoop), parentId); + } + } + + protected override void Visit(EndConversation item) + { + this.Trace(item, isSkipped: false); + + EndConversationAction action = new(item); + this.ContinueWith(action); + this.RestartFrom(action); + } + + protected override void Visit(AnswerQuestionWithAI item) + { + this.Trace(item, isSkipped: false); + + this.ContinueWith(new AnswerQuestionWithAIAction(item)); + } + + protected override void Visit(SetVariable item) + { + this.Trace(item, isSkipped: false); + + this.ContinueWith(new SetVariableAction(item)); + } + + protected override void Visit(SetTextVariable item) + { + this.Trace(item, isSkipped: false); + + this.ContinueWith(new SetTextVariableAction(item)); + } + + protected override void Visit(ClearAllVariables item) + { + this.Trace(item, isSkipped: false); + + this.ContinueWith(new ClearAllVariablesAction(item)); + } + + protected override void Visit(ResetVariable item) + { + this.Trace(item, isSkipped: false); + + this.ContinueWith(new ResetVariableAction(item)); + } + + protected override void Visit(EditTable item) + { + this.Trace(item); + } + + protected override void Visit(EditTableV2 item) + { + this.Trace(item, isSkipped: false); + + this.ContinueWith(new EditTableV2Action(item)); + } + + protected override void Visit(ParseValue item) + { + this.Trace(item, isSkipped: false); + + this.ContinueWith(new ParseValueAction(item)); + } + + protected override void Visit(SendActivity item) + { + this.Trace(item, isSkipped: false); + + this.ContinueWith(new SendActivityAction(item, this._context.ActivityChannel)); + } + + #region Not supported + + protected override void Visit(DeleteActivity item) + { + this.Trace(item); + } + + protected override void Visit(GetActivityMembers item) + { + this.Trace(item); + } + + protected override void Visit(UpdateActivity item) + { + this.Trace(item); + } + + protected override void Visit(ActivateExternalTrigger item) + { + this.Trace(item); + } + + protected override void Visit(DisableTrigger item) + { + this.Trace(item); + } + + protected override void Visit(WaitForConnectorTrigger item) + { + this.Trace(item); + } + + protected override void Visit(InvokeConnectorAction item) + { + this.Trace(item); + } + + protected override void Visit(InvokeCustomModelAction item) + { + this.Trace(item); + } + + protected override void Visit(InvokeFlowAction item) + { + this.Trace(item); + } + + protected override void Visit(InvokeAIBuilderModelAction item) + { + this.Trace(item); + } + + protected override void Visit(InvokeSkillAction item) + { + this.Trace(item); + } + + protected override void Visit(AdaptiveCardPrompt item) + { + this.Trace(item); + } + + protected override void Visit(Question item) + { + this.Trace(item); + } + + protected override void Visit(CSATQuestion item) + { + this.Trace(item); + } + + protected override void Visit(OAuthInput item) + { + this.Trace(item); + } + + protected override void Visit(BeginDialog item) + { + this.Trace(item); + } + + protected override void Visit(UnknownDialogAction item) + { + this.Trace(item); + } + + protected override void Visit(EndDialog item) + { + this.Trace(item); + } + + protected override void Visit(RepeatDialog item) + { + this.Trace(item); + } + + protected override void Visit(ReplaceDialog item) + { + this.Trace(item); + } + + protected override void Visit(CancelAllDialogs item) + { + this.Trace(item); + } + + protected override void Visit(CancelDialog item) + { + this.Trace(item); + } + + protected override void Visit(EmitEvent item) + { + this.Trace(item); + } + + protected override void Visit(GetConversationMembers item) + { + this.Trace(item); + } + + protected override void Visit(HttpRequestAction item) + { + this.Trace(item); + } + + protected override void Visit(RecognizeIntent item) + { + this.Trace(item); + } + + protected override void Visit(TransferConversation item) + { + this.Trace(item); + } + + protected override void Visit(TransferConversationV2 item) + { + this.Trace(item); + } + + protected override void Visit(SignOutUser item) + { + this.Trace(item); + } + + protected override void Visit(LogCustomTelemetryEvent item) + { + this.Trace(item); + } + + protected override void Visit(DisconnectedNodeContainer item) + { + this.Trace(item); + } + + protected override void Visit(CreateSearchQuery item) + { + this.Trace(item); + } + + protected override void Visit(SearchKnowledgeSources item) + { + this.Trace(item); + } + + protected override void Visit(SearchAndSummarizeWithCustomModel item) + { + this.Trace(item); + } + + protected override void Visit(SearchAndSummarizeContent item) + { + this.Trace(item); + } + + #endregion + + private void ContinueWith( + ProcessAction action, + Func? condition = null, + ScopeCompletionAction? callback = null) => + this.ContinueWith(this.CreateActionStep(action), action.ParentId, condition, action.GetType(), callback); + + private void ContinueWith( + ExecutorIsh step, + string parentId, + Func? condition = null, + Type? actionType = null, + ScopeCompletionAction? callback = null) + { + Console.WriteLine($"##### RECOGNIZE {parentId} <= {step.Id}"); // %%% LOGGER + this._actionStack.Recognize(parentId, callback); + this._workflowModel.AddNode(step, parentId, actionType); + this._workflowModel.AddLinkFromPeer(parentId, step.Id, condition); + } + + private static string RestartId(string actionId) => $"post_{actionId}"; + + private string RestartFrom(ProcessAction action) => + this.RestartFrom(action.Id, action.GetType().Name, action.ParentId); + + private string RestartFrom(string actionId, string name, string parentId) + { + string restartId = RestartId(actionId); + this._workflowModel.AddNode(this.CreateStep(restartId, $"{name}_Restart"), parentId); + return restartId; + } + + private ExecutorIsh CreateStep(string actionId, string name, Action? stepAction = null) + { + DeclarativeActionExecutor stepExecutor = + new(actionId, + () => + { + Console.WriteLine($"!!! STEP {name} [{actionId}]"); // %%% REMOVE + stepAction?.Invoke(this.CreateActionContext(actionId)); + return new ValueTask(); + }); + + //this._workflowBuilder.BindExecutor(stepExecutor); + + return stepExecutor; + } + + // This implementation accepts the context as a parameter in order to pin the context closure. + // The step cannot reference this.CurrentContext directly, as this will always be the final context. + private ExecutorIsh CreateActionStep(ProcessAction action) + { + DeclarativeActionExecutor stepExecutor = + new(action.Id, + async () => + { + Console.WriteLine($"!!! STEP {action.GetType().Name} [{action.Id}]"); // %%% REMOVE + + if (action.Model.Disabled) // %%% VALIDATE + { + Console.WriteLine($"!!! DISABLED {action.GetType().Name} [{action.Id}]"); // %%% REMOVE + return; + } + + try + { + await action.ExecuteAsync( + this.CreateActionContext(action.Id), + cancellationToken: default).ConfigureAwait(false); // %%% CANCELTOKEN + } + catch (ProcessActionException) + { + Console.WriteLine($"*** STEP [{action.Id}] ERROR - Action failure"); // %%% LOGGER + throw; + } + catch (Exception exception) + { + Console.WriteLine($"*** STEP [{action.Id}] ERROR - {exception.GetType().Name}\n{exception.Message}"); // %%% LOGGER + throw; + } + }); + + //this._workflowBuilder.BindExecutor(stepExecutor); + + return stepExecutor; + } + + private ProcessActionContext CreateActionContext(string actionId) => new(this.CreateEngine(), this._scopes, this.CreateClient, this._context.LoggerFactory.CreateLogger(actionId)); + + private PersistentAgentsClient CreateClient() + { + PersistentAgentsAdministrationClientOptions clientOptions = new(); + + if (this._context.HttpClient is not null) + { + clientOptions.Transport = new HttpClientTransport(this._context.HttpClient); + //clientOptions.RetryPolicy = new RetryPolicy(maxRetries: 0); + } + + return new PersistentAgentsClient(this._context.ProjectEndpoint, this._context.ProjectCredentials, clientOptions); + } + + private RecalcEngine CreateEngine() => RecalcEngineFactory.Create(this._scopes, this._context.MaximumExpressionLength); + + private void Trace(BotElement item) + { + Console.WriteLine($"> VISIT: {new string('\t', this._workflowModel.GetDepth(item.GetParentId()))}{FormatItem(item)} => {FormatParent(item)}"); // %%% LOGGER + } + + private void Trace(DialogAction item, bool isSkipped = true) + { + string? parentId = item.GetParentId(); + if (item.Id.Equals(parentId ?? string.Empty)) + { + parentId = $"root_{parentId}"; + } + Console.WriteLine($"> {(isSkipped ? "EMPTY" : "VISIT")}: {new string('\t', this._workflowModel.GetDepth(parentId))}{FormatItem(item)} => {FormatParent(item)}"); // %%% LOGGER + } + + private static string FormatItem(BotElement element) => $"{element.GetType().Name} ({element.GetId()})"; + + private static string FormatParent(BotElement element) => + element.Parent is null ? + throw new InvalidActionException($"Undefined parent for {element.GetType().Name} that is member of {element.GetId()}.") : + $"{element.Parent.GetType().Name} ({element.GetParentId()})"; +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionWalker.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionWalker.cs new file mode 100644 index 0000000000..7fcb2a70c2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionWalker.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.Workflows.Core; +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative; + +internal sealed class ProcessActionWalker : BotElementWalker +{ + private readonly ProcessActionVisitor _visitor; + + public ProcessActionWalker(BotElement rootElement, ProcessActionVisitor visitor) + { + this._visitor = visitor; + this.Visit(rootElement); + this.Workflow = this._visitor.Complete(); + } + + public Workflow Workflow { get; } + + public override bool DefaultVisit(BotElement definition) + { + if (definition is DialogAction action) + { + action.Accept(this._visitor); + } + + return true; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessWorkflowBuilder.cs new file mode 100644 index 0000000000..fce3812a7d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessWorkflowBuilder.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Provides builder patterns for constructing a declarative process workflow. +/// +internal sealed class ProcessWorkflowBuilder +{ + public ProcessWorkflowBuilder(ExecutorIsh rootStep) + { + this.RootNode = this.DefineNode(rootStep); + } + + private ProcessWorkflowNode RootNode { get; } + + private Dictionary Steps { get; } = []; + + private List Links { get; } = []; + + public int GetDepth(string? nodeId) + { + if (nodeId == null) + { + return 0; + } + + if (!this.Steps.TryGetValue(nodeId, out ProcessWorkflowNode? sourceNode)) + { + throw new UnknownActionException($"Unresolved step: {nodeId}."); + } + + return sourceNode.Depth; + } + + public void AddNode(ExecutorIsh step, string parentId, Type? actionType = null) + { + if (!this.Steps.TryGetValue(parentId, out ProcessWorkflowNode? parentNode)) + { + throw new UnknownActionException($"Unresolved parent for {step.Id}: {parentId}."); + } + + ProcessWorkflowNode stepNode = this.DefineNode(step, parentNode, actionType); + + parentNode.Children.Add(stepNode); + } + + public void AddLinkFromPeer(string parentId, string targetId, Func? condition = null) + { + if (!this.Steps.TryGetValue(parentId, out ProcessWorkflowNode? parentNode)) + { + throw new UnknownActionException($"Unresolved step: {parentId}."); + } + + if (parentNode.Children.Count == 0) + { + throw new WorkflowBuilderException($"Cannot add a link from a node with no children: {parentId}."); + } + + ProcessWorkflowNode sourceNode = parentNode.Children.Count == 1 ? parentNode : parentNode.Children[parentNode.Children.Count - 2]; + + this.Links.Add(new ProcessWorkflowLink(sourceNode, targetId, condition)); + } + + public void AddLink(string sourceId, string targetId, Func? condition = null) + { + if (!this.Steps.TryGetValue(sourceId, out ProcessWorkflowNode? sourceNode)) + { + throw new UnknownActionException($"Unresolved step: {sourceId}."); + } + + this.Links.Add(new ProcessWorkflowLink(sourceNode, targetId, condition)); + } + + //public void AddStop(string nodeId) // %%% REMOVE + //{ + // if (!this.Steps.TryGetValue(nodeId, out ProcessWorkflowNode? sourceNode)) + // { + // throw new UnknownActionException($"Unresolved node: {nodeId}."); + // } + + // sourceNode.Step.OnFunctionResult(KernelDelegateProcessStep.FunctionName).StopProcess(); + //} + + public void ConnectNodes(WorkflowBuilder workflowBuilder) + { + foreach (ProcessWorkflowLink link in this.Links) + { + if (!this.Steps.TryGetValue(link.TargetId, out ProcessWorkflowNode? targetNode)) + { + throw new WorkflowBuilderException($"Unresolved target for {link.Source.Id}: {link.TargetId}."); + } + + Console.WriteLine($"> CONNECT: {link.Source.Id} => {link.TargetId}"); // %%% LOGGER + + workflowBuilder.AddEdge(link.Source.Step, targetNode.Step, link.Condition); + } + } + + private ProcessWorkflowNode DefineNode(ExecutorIsh step, ProcessWorkflowNode? parentNode = null, Type? actionType = null) + { + ProcessWorkflowNode stepNode = new(step, parentNode, actionType); + this.Steps[stepNode.Id] = stepNode; + + return stepNode; + } + + internal string? LocateParent(string? itemId) + { + if (string.IsNullOrEmpty(itemId)) + { + return null; + } + + while (itemId != null) + { + if (!this.Steps.TryGetValue(itemId, out ProcessWorkflowNode? itemNode)) + { + throw new UnknownActionException($"Unresolved child: {itemId}."); + } + + if (itemNode.ActionType == typeof(TAction)) + { + return itemNode.Id; + } + + itemId = itemNode.Parent?.Id; + } + + return null; + } + + private sealed class ProcessWorkflowNode(ExecutorIsh step, ProcessWorkflowNode? parent = null, Type? actionType = null) + { + public string Id => step.Id; + + public ExecutorIsh Step => step; + + public ProcessWorkflowNode? Parent { get; } = parent; + + public List Children { get; } = []; + + public int Depth => this.Parent?.Depth + 1 ?? 0; + + public Type? ActionType => actionType; + } + + private sealed record class ProcessWorkflowLink(ProcessWorkflowNode Source, string TargetId, Func? Condition = null); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowContext.cs new file mode 100644 index 0000000000..492e403423 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowContext.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Net.Http; +using Azure.Core; +using Azure.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Provides configuration and context for workflow execution. +/// +public sealed class WorkflowContext +{ + /// + /// Defines the endpoint for the Foundry project. + /// + public string ProjectEndpoint { get; init; } = string.Empty; + + /// + /// Defines the credentials that authorize access to the Foundry project. + /// + public TokenCredential ProjectCredentials { get; init; } = new DefaultAzureCredential(); + + /// + /// Defines the maximum number of nested calls allowed in a PowerFx formula. + /// + public int? MaximumCallDepth { get; init; } + + /// + /// Defines the maximum allowed length for expressions evaluated in the workflow. + /// + public int? MaximumExpressionLength { get; init; } + + /// + /// Gets the instance used to send HTTP requests. + /// + public HttpClient? HttpClient { get; init; } + + /// + /// Gets the used to create loggers for workflow components. + /// + public ILoggerFactory LoggerFactory { get; init; } = NullLoggerFactory.Instance; + + /// + /// Gets the used for activity output and diagnostics. + /// + public TextWriter ActivityChannel { get; init; } = Console.Out; // %%% REMOVE: For POC only +} diff --git a/dotnet/src/Shared/Samples/TestConfiguration.cs b/dotnet/src/Shared/Samples/TestConfiguration.cs index be436ee9f7..cb072649aa 100644 --- a/dotnet/src/Shared/Samples/TestConfiguration.cs +++ b/dotnet/src/Shared/Samples/TestConfiguration.cs @@ -18,6 +18,9 @@ public sealed class TestConfiguration /// Gets the configuration settings for the Azure OpenAI integration. public static AzureOpenAIConfig AzureOpenAI => LoadSection(); + /// Gets the configuration settings for the AzureAI integration. + public static AzureAIConfig AzureAI => LoadSection(); + /// Represents the configuration settings required to interact with the OpenAI service. public class OpenAIConfig { @@ -72,11 +75,6 @@ private TestConfiguration(IConfigurationRoot configRoot) this._configRoot = configRoot; } - /// - /// Gets the configuration settings for the AzureAI integration. - /// - public static AzureAIConfig AzureAI => LoadSection(); - private static T LoadSection([CallerMemberName] string? caller = null) { if (s_instance is null) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs new file mode 100644 index 0000000000..59b3b97929 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Extensions; + +public class FormulaValueExtensionsTests +{ + [Fact] + public void BooleanValue() + { + BooleanValue formulaValue = FormulaValue.New(true); + BooleanDataValue dataValue = formulaValue.ToDataValue(); + Assert.Equal(formulaValue.Value, dataValue.Value); + + BooleanValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); + Assert.Equal(dataValue.Value, formulaCopy.Value); + + Assert.Equal(bool.TrueString, formulaValue.Format()); + } + + [Fact] + public void StringValues() + { + StringValue formulaValue = FormulaValue.New("test value"); + StringDataValue dataValue = formulaValue.ToDataValue(); + Assert.Equal(formulaValue.Value, dataValue.Value); + + StringValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); + Assert.Equal(dataValue.Value, formulaCopy.Value); + + Assert.Equal(formulaValue.Value, formulaValue.Format()); + } + + [Fact] + public void DecimalValues() + { + DecimalValue formulaValue = FormulaValue.New(45.3m); + NumberDataValue dataValue = formulaValue.ToDataValue(); + Assert.Equal(formulaValue.Value, dataValue.Value); + + DecimalValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); + Assert.Equal(dataValue.Value, formulaCopy.Value); + + Assert.Equal("45.3", formulaValue.Format()); + } + + [Fact] + public void NumberValues() + { + NumberValue formulaValue = FormulaValue.New(3.1415926535897); + FloatDataValue dataValue = formulaValue.ToDataValue(); + Assert.Equal(formulaValue.Value, dataValue.Value); + + NumberValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); + Assert.Equal(dataValue.Value, formulaCopy.Value); + + Assert.Equal("3.1415926535897", formulaValue.Format()); + } + + [Fact] + public void BlankValues() + { + BlankValue formulaValue = FormulaValue.NewBlank(); + + BlankDataValue dataCopy = Assert.IsType(formulaValue.GetDataValue()); + + Assert.Equal(string.Empty, formulaValue.Format()); + } + + [Fact] + public void VoidValues() + { + VoidValue formulaValue = FormulaValue.NewVoid(); + BlankDataValue dataCopy = Assert.IsType(formulaValue.GetDataValue()); + } + + [Fact] + public void DateValues() + { + DateValue formulaValue = FormulaValue.NewDateOnly(DateTime.UtcNow.Date); + DateDataValue dataValue = formulaValue.ToDataValue(); + Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), dataValue.Value); + + DateValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); + Assert.Equal(dataValue.Value, formulaCopy.GetConvertedValue(TimeZoneInfo.Utc)); + + //Assert.Equal("45.3", formulaValue.Format()); // %%% TEST ASSERT ??? + } + + [Fact] + public void DateTimeValues() + { + DateTimeValue formulaValue = FormulaValue.New(DateTime.UtcNow); + DateTimeDataValue dataValue = formulaValue.ToDataValue(); + Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), dataValue.Value); + + DateTimeValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); + Assert.Equal(dataValue.Value, formulaCopy.GetConvertedValue(TimeZoneInfo.Utc)); + + //Assert.Equal("45.3", formulaValue.Format()); // %%% TEST ASSERT ??? + } + + [Fact] + public void TimeValues() + { + TimeValue formulaValue = FormulaValue.New(TimeSpan.Parse("10:35")); + TimeDataValue dataValue = formulaValue.ToDataValue(); + Assert.Equal(formulaValue.Value, dataValue.Value); + + TimeValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); + Assert.Equal(dataValue.Value, formulaCopy.Value); + + Assert.Equal("10:35:00", formulaValue.Format()); + } + + [Fact] + public void RecordValues() + { + RecordValue formulaValue = FormulaValue.NewRecordFromFields( + new NamedValue("FieldA", FormulaValue.New("Value1")), + new NamedValue("FieldB", FormulaValue.New("Value2")), + new NamedValue("FieldC", FormulaValue.New("Value3"))); + + RecordDataValue dataValue = formulaValue.ToDataValue(); + Assert.Equal(formulaValue.Fields.Count(), dataValue.Properties.Count); + foreach (KeyValuePair property in dataValue.Properties) + { + Assert.Contains(property.Key, formulaValue.Fields.Select(field => field.Name)); + } + + RecordValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue(), exactMatch: false); + Assert.Equal(formulaCopy.Fields.Count(), dataValue.Properties.Count); + foreach (NamedValue field in formulaCopy.Fields) + { + Assert.Contains(field.Name, dataValue.Properties.Keys); + } + + //Assert.Equal("45.3", formulaValue.Format()); // %%% TEST ASSERT ??? + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/StringExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/StringExtensionsTests.cs new file mode 100644 index 0000000000..87a5ce6848 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/StringExtensionsTests.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.Workflows.Declarative.Extensions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Extensions; + +public class StringExtensionsTests +{ + [Fact] + public void TrimJsonWithDelimiter() + { + // Arrange + const string Input = + """ + ```json + { + "key": "value" + } + ``` + """; + + // Act + string result = Input.TrimJsonDelimiter(); + + // Assert + Assert.Equal( + """ + { + "key": "value" + } + """, + result); + } + [Fact] + public void TrimJsonWithPadding() + { + // Arrange + const string Input = + """ + + ```json + { + "key": "value" + } + ``` + """; + + // Act + string result = Input.TrimJsonDelimiter(); + + // Assert + Assert.Equal( + """ + { + "key": "value" + } + """, + result); + } + + [Fact] + public void TrimJsonWithUnqualifiedDelimiter() + { + // Arrange + const string Input = + """ + ``` + { + "key": "value" + } + ``` + """; + + // Act + string result = Input.TrimJsonDelimiter(); + + // Assert + Assert.Equal( + """ + { + "key": "value" + } + """, + result); + } + + [Fact] + public void TrimJsonWithoutDelimiter() + { + // Arrange + const string Input = + """ + { + "key": "value" + } + """; + + // Act + string result = Input.TrimJsonDelimiter(); + + // Assert + Assert.Equal( + """ + { + "key": "value" + } + """, + result); + } + + [Fact] + public void TrimJsonWithoutDelimiterWithPadding() + { + // Arrange + const string Input = + """ + + { + "key": "value" + } + """; + + // Act + string result = Input.TrimJsonDelimiter(); + + // Assert + Assert.Equal( + """ + { + "key": "value" + } + """, + result); + } + + [Fact] + public void TrimMissingWithDelimiter() + { + // Arrange + const string Input = + """ + ```json + ``` + """; + + // Act + string result = Input.TrimJsonDelimiter(); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void TrimEmptyString() + { + // Act + string result = string.Empty.TrimJsonDelimiter(); + + // Assert + Assert.Equal(string.Empty, result); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Microsoft.Agents.Workflows.Declarative.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Microsoft.Agents.Workflows.Declarative.UnitTests.csproj new file mode 100644 index 0000000000..1a57b35e54 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Microsoft.Agents.Workflows.Declarative.UnitTests.csproj @@ -0,0 +1,16 @@ + + + + $(ProjectsTargetFrameworks) + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/FoundryExpressionEngineTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/FoundryExpressionEngineTests.cs new file mode 100644 index 0000000000..ff128cd7b0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/FoundryExpressionEngineTests.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.PowerFx; + +public class FoundryExpressionEngineTests(ITestOutputHelper output) : RecalcEngineTest(output) +{ + [Fact] + public void DefaultNotNull() + { + // Act + RecalcEngine engine = this.CreateEngine(); + FoundryExpressionEngine expressionEngine = new(engine); + this.Scopes.Set("test", FormulaValue.New("value")); + engine.SetScopedVariable(this.Scopes, PropertyPath.TopicVariable("test"), FormulaValue.New("value")); + + EvaluationResult valueResult = expressionEngine.GetValue(StringExpression.Variable(PropertyPath.TopicVariable("test")), this.Scopes.BuildState()); + + // Assert + Assert.Equal("value", valueResult.Value); + Assert.Equal(SensitivityLevel.None, valueResult.Sensitivity); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineEvaluationTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineEvaluationTests.cs new file mode 100644 index 0000000000..b6daeb4a96 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineEvaluationTests.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.PowerFx; + +#pragma warning disable CA1308 // Ignore "Normalize strings to uppercase" warning for test cases + +public sealed class RecalcEngineEvaluationTests(ITestOutputHelper output) : RecalcEngineTest(output) +{ + [Fact] + public void EvaluateConstant() + { + RecalcEngine engine = this.CreateEngine(); + + this.EvaluateExpression(engine, 0m, "0"); + this.EvaluateExpression(engine, -1m, "-1"); + this.EvaluateExpression(engine, true, "true"); + this.EvaluateExpression(engine, false, "false"); + this.EvaluateExpression(engine, (string?)null, string.Empty); + this.EvaluateExpression(engine, "Hi", "\"Hi\""); + } + + [Fact] + public void EvaluateInvalid() + { + RecalcEngine engine = this.CreateEngine(); + engine.UpdateVariable("Scoped.Value", FormulaValue.New(33)); + + this.EvaluateFailure(engine, "Hi"); + this.EvaluateFailure(engine, "True"); + this.EvaluateFailure(engine, "TRUE"); + this.EvaluateFailure(engine, "=1", canParse: false); + this.EvaluateFailure(engine, "=1+2", canParse: false); + this.EvaluateFailure(engine, "CustomValue"); + this.EvaluateFailure(engine, "CustomValue + 1"); + this.EvaluateFailure(engine, "Scoped.Value"); + this.EvaluateFailure(engine, "Scoped.Value + 1"); + this.EvaluateFailure(engine, "\"BEGIN-\" & Scoped.Value & \"-END\""); + } + + [Fact] + public void EvaluateFormula() + { + NamedValue[] recordValues = + [ + new NamedValue("Label", FormulaValue.New("Test")), + new NamedValue("Value", FormulaValue.New(54)), + ]; + FormulaValue complexValue = FormulaValue.NewRecordFromFields(recordValues); + + RecalcEngine engine = this.CreateEngine(); + engine.UpdateVariable("CustomLabel", FormulaValue.New("Note")); + engine.UpdateVariable("CustomValue", FormulaValue.New(42)); + engine.UpdateVariable("Scoped", complexValue); + + this.EvaluateExpression(engine, 2m, "1 + 1"); + this.EvaluateExpression(engine, 42m, "CustomValue"); + this.EvaluateExpression(engine, 43m, "CustomValue + 1"); + this.EvaluateExpression(engine, "Note", "CustomLabel"); + //this.EvaluateExpression(engine, "Note", "\"{CustomLabel}\""); + this.EvaluateExpression(engine, "BEGIN-42-END", "\"BEGIN-\" & CustomValue & \"-END\""); + this.EvaluateExpression(engine, 54m, "Scoped.Value"); + this.EvaluateExpression(engine, 55m, "Scoped.Value + 1"); + this.EvaluateExpression(engine, "Test", "Scoped.Label"); + //this.EvaluateExpression(engine, "Test", "\"{Scoped.Label}\""); + } + + private void EvaluateFailure(RecalcEngine engine, string sourceExpression, bool canParse = true) + { + CheckResult checkResult = engine.Check(sourceExpression); + Assert.False(checkResult.IsSuccess); + ParseResult parseResult = engine.Parse(sourceExpression); + Assert.Equal(canParse, parseResult.IsSuccess); + Assert.Throws(() => engine.Eval(sourceExpression)); + } + + private void EvaluateExpression(RecalcEngine engine, T expectedResult, string sourceExpression) + { + CheckResult checkResult = engine.Check(sourceExpression); + Assert.True(checkResult.IsSuccess); + ParseResult parseResult = engine.Parse(sourceExpression); + Assert.True(parseResult.IsSuccess); + FormulaValue valueResult = engine.Eval(sourceExpression); + if (expectedResult is null) + { + Assert.Null(valueResult.ToObject()); + } + else + { + Assert.IsType(valueResult.ToObject()); + Assert.Equal(expectedResult, valueResult.ToObject()); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs new file mode 100644 index 0000000000..31512370f1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.PowerFx; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.PowerFx; + +public class RecalcEngineFactoryTests(ITestOutputHelper output) : RecalcEngineTest(output) +{ + [Fact] + public void DefaultNotNull() + { + // Act + RecalcEngine engine = this.CreateEngine(); + + // Assert + Assert.NotNull(engine); + } + + [Fact] + public void NewInstanceEachTime() + { + // Act + RecalcEngine engine1 = this.CreateEngine(); + RecalcEngine engine2 = this.CreateEngine(); + + // Assert + Assert.NotNull(engine1); + Assert.NotNull(engine2); + Assert.NotSame(engine1, engine2); + } + + [Fact] + public void HasSetFunctionEnabled() + { + // Arrange + RecalcEngine engine = this.CreateEngine(); + + // Act + CheckResult result = engine.Check("1+1"); + + // Assert + Assert.True(result.IsSuccess); + } + + [Fact] + public void HasCorrectMaximumExpressionLength() + { + // Arrange + RecalcEngine engine = this.CreateEngine(2000); + + // Act: Create a long expression that is within the limit + string goodExpression = string.Concat(GenerateExpression(999)); + CheckResult goodResult = engine.Check(goodExpression); + + // Assert + Assert.True(goodResult.IsSuccess); + + // Act: Create a long expression that exceeds the limit + string longExpression = string.Concat(GenerateExpression(1001)); + CheckResult longResult = engine.Check(longExpression); + + // Assert + Assert.False(longResult.IsSuccess); + + static IEnumerable GenerateExpression(int elements) + { + yield return "1"; + for (int i = 0; i < elements - 1; i++) + { + yield return "+1"; + } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineTest.cs new file mode 100644 index 0000000000..9bbd36912c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineTest.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.PowerFx; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.PowerFx; + +/// +/// Base test class for PowerFx engine tests. +/// +public abstract class RecalcEngineTest(ITestOutputHelper output) : WorkflowTest(output) +{ + internal ProcessActionScopes Scopes { get; } = new(); + + protected RecalcEngine CreateEngine(int maximumExpressionLength = 500) => RecalcEngineFactory.Create(this.Scopes, maximumExpressionLength); +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs new file mode 100644 index 0000000000..9286ccbced --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.PowerFx; + +public class TemplateExtensionsTests(ITestOutputHelper output) : RecalcEngineTest(output) +{ + [Fact] + public void FormatTemplateLines() + { + // Arrange + List template = + [ + TemplateLine.Parse("Hello"), + TemplateLine.Parse(" "), + TemplateLine.Parse("World"), + ]; + RecalcEngine engine = this.CreateEngine(); + + // Act + string? result = engine.Format(template); + + // Assert + Assert.Equal("Hello World", result); + } + + [Fact] + public void FormatTemplateLinesEmpty() + { + // Arrange + List template = []; + RecalcEngine engine = this.CreateEngine(); + + // Act + string? result = engine.Format(template); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void FormatTemplateLine() + { + // Arrange + TemplateLine line = TemplateLine.Parse("Test"); + RecalcEngine engine = this.CreateEngine(); + + // Act + string? result = engine.Format(line); + + // Assert + Assert.Equal("Test", result); + } + + [Fact] + public void FormatTemplateLineNull() + { + // Arrange + TemplateLine? line = null; + RecalcEngine engine = this.CreateEngine(); + + // Act + string? result = engine.Format(line); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void FormatTextSegment() + { + // Arrange + TemplateSegment textSegment = TextSegment.FromText("Hello World"); + TemplateLine line = new([textSegment]); + RecalcEngine engine = this.CreateEngine(); + + // Act + string? result = engine.Format(line); + + // Assert + Assert.Equal("Hello World", result); + } + + [Fact] + public void FormatExpressionSegment() + { + // Arrange + ExpressionSegment expressionSegment = new(ValueExpression.Expression("1 + 1")); + TemplateLine line = new([expressionSegment]); + RecalcEngine engine = this.CreateEngine(); + + // Act + string? result = engine.Format(line); + + // Assert + Assert.Equal("2", result); + } + + [Fact] + public void FormatVariableSegment() + { + // Arrange + this.Scopes.Set("Source", FormulaValue.New("Hello World")); + ExpressionSegment expressionSegment = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source"))); + TemplateLine line = new([expressionSegment]); + RecalcEngine engine = this.CreateEngine(); + + // Act + string? result = engine.Format(line); + + // Assert + Assert.Equal("Hello World", result); + } + + //[Fact] + //public void Format_WithExpressionSegmentWithVariableReference_ReturnsEvaluatedValue() + //{ + // // Arrange + // Mock mockVariableRef = new(); + // mockVariableRef.Setup(vr => vr.ToString()).Returns("myVariable"); + + // Expression expression = new() { VariableReference = mockVariableRef.Object }; + // ExpressionSegment expressionSegment = new() { Expression = expression }; + // TemplateLine line = new([expressionSegment]); + + // _mockFormulaValue.Setup(fv => fv.Format()).Returns("VariableValue"); + // _mockEngine.Setup(e => e.Eval("myVariable")).Returns(_mockFormulaValue.Object); + + // // Act + // string? result = engine.Format(line); + + // // Assert + // Assert.Equal("VariableValue", result); + // _mockEngine.Verify(e => e.Eval("myVariable"), Times.Once); + // _mockFormulaValue.Verify(fv => fv.Format(), Times.Once); + //} + + [Fact] + public void FormatExpressionSegmentUndefined() + { + // Arrange + ExpressionSegment expressionSegment = new(); + TemplateLine line = new([expressionSegment]); + RecalcEngine engine = this.CreateEngine(); + + // Act & Assert + Assert.Throws(() => engine.Format(line)); + } + + [Fact] + public void FormatMultipleSegments() + { + // Arrange + TemplateSegment textSegment = TextSegment.FromText("Hello "); + ExpressionSegment expressionSegment = new(ValueExpression.Expression(@"""World""")); + TemplateLine line = new([textSegment, expressionSegment]); + RecalcEngine engine = this.CreateEngine(); + + // Act + string? result = engine.Format(line); + + // Assert + Assert.Equal("Hello World", result); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ProcessActionScopesTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ProcessActionScopesTests.cs new file mode 100644 index 0000000000..b44a63f19a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ProcessActionScopesTests.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests; + +public class ProcessActionScopesTests +{ + [Fact] + public void ConstructorInitializesAllScopes() + { + // Arrange & Act + ProcessActionScopes scopes = new(); + + // Assert + RecordValue envRecord = scopes.BuildRecord(ActionScopeType.Env); + RecordValue topicRecord = scopes.BuildRecord(ActionScopeType.Topic); + RecordValue globalRecord = scopes.BuildRecord(ActionScopeType.Global); + RecordValue systemRecord = scopes.BuildRecord(ActionScopeType.System); + + Assert.NotNull(envRecord); + Assert.NotNull(topicRecord); + Assert.NotNull(globalRecord); + Assert.NotNull(systemRecord); + } + + [Fact] + public void BuildRecordWhenEmpty() + { + // Arrange + ProcessActionScopes scopes = new(); + + // Act + RecordValue record = scopes.BuildRecord(ActionScopeType.Topic); + + // Assert + Assert.NotNull(record); + Assert.Empty(record.Fields); + } + + [Fact] + public void BuildRecordContainsSetValues() + { + // Arrange + ProcessActionScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + scopes.Set("key1", ActionScopeType.Topic, testValue); + + // Act + RecordValue record = scopes.BuildRecord(ActionScopeType.Topic); + + // Assert + Assert.NotNull(record); + Assert.Single(record.Fields); + Assert.Equal("key1", record.Fields.First().Name); + Assert.Equal(testValue, record.Fields.First().Value); + } + + [Fact] + public void BuildRecordForAllScopeTypes() + { + // Arrange + ProcessActionScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + + // Act & Assert + scopes.Set("envKey", ActionScopeType.Env, testValue); + RecordValue envRecord = scopes.BuildRecord(ActionScopeType.Env); + Assert.Single(envRecord.Fields); + + scopes.Set("topicKey", ActionScopeType.Topic, testValue); + RecordValue topicRecord = scopes.BuildRecord(ActionScopeType.Topic); + Assert.Single(topicRecord.Fields); + + scopes.Set("globalKey", ActionScopeType.Global, testValue); + RecordValue globalRecord = scopes.BuildRecord(ActionScopeType.Global); + Assert.Single(globalRecord.Fields); + + scopes.Set("systemKey", ActionScopeType.System, testValue); + RecordValue systemRecord = scopes.BuildRecord(ActionScopeType.System); + Assert.Single(systemRecord.Fields); + } + + [Fact] + public void GetWithImplicitScope() + { + // Arrange + ProcessActionScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + scopes.Set("key1", ActionScopeType.Topic, testValue); + + // Act + FormulaValue result = scopes.Get("key1"); + + // Assert + Assert.Equal(testValue, result); + } + + [Fact] + public void GetWithSpecifiedScope() + { + // Arrange + ProcessActionScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + scopes.Set("key1", ActionScopeType.Global, testValue); + + // Act + FormulaValue result = scopes.Get("key1", ActionScopeType.Global); + + // Assert + Assert.Equal(testValue, result); + } + + [Fact] + public void SetDefaultScope() + { + // Arrange + ProcessActionScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + + // Act + scopes.Set("key1", testValue); + + // Assert + FormulaValue result = scopes.Get("key1", ActionScopeType.Topic); + Assert.Equal(testValue, result); + } + + [Fact] + public void SetSpecifiedScope() + { + // Arrange + ProcessActionScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + + // Act + scopes.Set("key1", ActionScopeType.System, testValue); + + // Assert + FormulaValue result = scopes.Get("key1", ActionScopeType.System); + Assert.Equal(testValue, result); + } + + [Fact] + public void SetOverwritesExistingValue() + { + // Arrange + ProcessActionScopes scopes = new(); + FormulaValue initialValue = FormulaValue.New("initial"); + FormulaValue newValue = FormulaValue.New("new"); + + // Act + scopes.Set("key1", ActionScopeType.Topic, initialValue); + scopes.Set("key1", ActionScopeType.Topic, newValue); + + // Assert + FormulaValue result = scopes.Get("key1", ActionScopeType.Topic); + Assert.Equal(newValue, result); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/TestOutputAdapter.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/TestOutputAdapter.cs new file mode 100644 index 0000000000..25c8260cc5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/TestOutputAdapter.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests; + +public sealed class TestOutputAdapter(ITestOutputHelper output) : TextWriter, ILogger, ILoggerFactory +{ + private readonly Stack _scopes = []; + + public override Encoding Encoding { get; } = Encoding.UTF8; + + public void AddProvider(ILoggerProvider provider) => throw new NotSupportedException(); + + public ILogger CreateLogger(string categoryName) => this; + + public bool IsEnabled(LogLevel logLevel) => true; + + public override void WriteLine(object? value = null) => this.SafeWrite($"{value}"); + + public override void WriteLine(string? format, params object?[] arg) => this.SafeWrite(string.Format(format ?? string.Empty, arg)); + + public override void WriteLine(string? value) => this.SafeWrite(value ?? string.Empty); + + public override void Write(object? value = null) => this.SafeWrite($"{value}"); + + public override void Write(char[]? buffer) => this.SafeWrite(new string(buffer)); + + public IDisposable BeginScope(TState state) where TState : notnull + { + this._scopes.Push($"{state}"); + return new LoggerScope(() => this._scopes.Pop()); + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + string message = formatter(state, exception); + string scope = this._scopes.Count > 0 ? $"[{this._scopes.Peek()}] " : string.Empty; + output.WriteLine($"{scope}{message}"); + } + + private void SafeWrite(string value) + { + try + { + output.WriteLine(value ?? string.Empty); + } + catch (InvalidOperationException exception) when (exception.Message == "There is no currently active test.") + { + // This exception is thrown when the test output is accessed outside of a test context. + // We can ignore it since we are not in a test context. + } + } + + private sealed class LoggerScope(Action action) : IDisposable + { + private bool _disposed; + + public void Dispose() + { + if (!this._disposed) + { + action.Invoke(); + this._disposed = true; + } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowTest.cs new file mode 100644 index 0000000000..47f8c03afd --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowTest.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests; + +/// +/// Base class for workflow tests. +/// +public abstract class WorkflowTest : IDisposable +{ + public TestOutputAdapter Output { get; } + + protected WorkflowTest(ITestOutputHelper output) + { + this.Output = new TestOutputAdapter(output); + System.Console.SetOut(this.Output); + } + + public void Dispose() + { + this.Dispose(isDisposing: true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool isDisposing) + { + if (isDisposing) + { + this.Output.Dispose(); + } + } + + internal static string FormatVariablePath(string variableName, ActionScopeType? scope = null) => $"{scope ?? ActionScopeType.Topic}.{variableName}"; +} From 7bd5f11d332c384c65aae9a500eb8379cd75c30e Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 11 Aug 2025 09:38:34 -0700 Subject: [PATCH 042/232] Fix namespace error --- dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index 0fb88652c2..681b711a55 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -2,7 +2,6 @@ #if NET -using System.Text.Json; using Azure.Identity; using Microsoft.Agents.Orchestration; using Microsoft.Agents.Workflows.Core; From 98c8f5d1d2cde551446b8ebe4239c90d3d81e9fc Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 11 Aug 2025 09:50:47 -0700 Subject: [PATCH 043/232] Restructure --- .../Actions/AnswerQuestionWithAIAction.cs | 2 +- .../Actions/AssignmentAction.cs | 1 + .../Actions/ClearAllVariablesAction.cs | 7 +- .../Actions/ConditionGroupAction.cs | 1 + .../Actions/EditTableV2Action.cs | 4 +- .../Actions/EndConversationAction.cs | 1 + .../Actions/ForeachAction.cs | 1 + .../Actions/ParseValueAction.cs | 1 + .../Actions/ResetVariableAction.cs | 1 + .../Actions/SendActivityAction.cs | 1 + .../Actions/SetTextVariableAction.cs | 1 + .../Actions/SetVariableAction.cs | 1 + .../DeclarativeWorkflowBuilder.cs | 9 +- .../DeclarativeActionExecutor.cs | 2 +- .../DeclarativeWorkflowExecutor.cs | 7 +- .../{ => Execution}/ProcessAction.cs | 8 +- .../{ => Interpreter}/ProcessActionStack.cs | 4 +- .../WorkflowActionVisitor.cs} | 15 +- .../WorkflowElementWalker.cs} | 8 +- .../WorkflowModel.cs} | 46 +++--- .../PowerFx/RecalcEngineExtensions.cs | 16 +- .../PowerFx/RecalcEngineFactory.cs | 12 +- ...nEngine.cs => WorkflowExpressionEngine.cs} | 34 ++--- .../PowerFx/WorkflowScope.cs | 39 +++++ .../PowerFx/WorkflowScopes.cs | 65 ++++++++ .../PowerFx/WorkflowScopesType.cs | 48 ++++++ .../ProcessActionScopes.cs | 140 ------------------ .../PowerFx/FoundryExpressionEngineTests.cs | 2 +- .../PowerFx/RecalcEngineTest.cs | 2 +- .../ProcessActionScopesTests.cs | 67 ++++----- .../WorkflowTest.cs | 3 +- 31 files changed, 290 insertions(+), 259 deletions(-) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{ => Execution}/DeclarativeActionExecutor.cs (92%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{ => Execution}/DeclarativeWorkflowExecutor.cs (67%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{ => Execution}/ProcessAction.cs (86%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{ => Interpreter}/ProcessActionStack.cs (92%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{ProcessActionVisitor.cs => Interpreter/WorkflowActionVisitor.cs} (97%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{ProcessActionWalker.cs => Interpreter/WorkflowElementWalker.cs} (65%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{ProcessWorkflowBuilder.cs => Interpreter/WorkflowModel.cs} (61%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/{FoundryExpressionEngine.cs => WorkflowExpressionEngine.cs} (90%) create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopesType.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionScopes.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AnswerQuestionWithAIAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AnswerQuestionWithAIAction.cs index 944220ecf9..f04f872b76 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AnswerQuestionWithAIAction.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AnswerQuestionWithAIAction.cs @@ -4,7 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Azure.AI.Agents.Persistent; -using Microsoft.Agents.Workflows.Declarative; +using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.Handlers; using Microsoft.Bot.ObjectModel; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AssignmentAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AssignmentAction.cs index e8921f4b26..95e0048965 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AssignmentAction.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AssignmentAction.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ClearAllVariablesAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ClearAllVariablesAction.cs index d76d146d47..c53560e4fb 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ClearAllVariablesAction.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ClearAllVariablesAction.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; @@ -28,7 +29,7 @@ private sealed class ScopeHandler(ProcessActionContext context) : IEnumVariables { public void HandleAllGlobalVariables() { - context.Engine.ClearScope(context.Scopes, ActionScopeType.Global); + context.Engine.ClearScope(context.Scopes, WorkflowScopeType.Global); } public void HandleConversationHistory() @@ -38,7 +39,7 @@ public void HandleConversationHistory() public void HandleConversationScopedVariables() { - context.Engine.ClearScope(context.Scopes, ActionScopeType.Topic); + context.Engine.ClearScope(context.Scopes, WorkflowScopeType.Topic); } public void HandleUnknownValue() @@ -48,7 +49,7 @@ public void HandleUnknownValue() public void HandleUserScopedVariables() { - context.Engine.ClearScope(context.Scopes, ActionScopeType.Env); + context.Engine.ClearScope(context.Scopes, WorkflowScopeType.Env); } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ConditionGroupAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ConditionGroupAction.cs index 2e22e35737..49fa7a3ee8 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ConditionGroupAction.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ConditionGroupAction.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Bot.ObjectModel; namespace Microsoft.Agents.Workflows.Declarative.Handlers; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EditTableV2Action.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EditTableV2Action.cs index 3b3d2505fa..834a1f69c2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EditTableV2Action.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EditTableV2Action.cs @@ -3,7 +3,9 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.PowerFx.Types; @@ -20,7 +22,7 @@ public EditTableV2Action(EditTableV2 model) protected override async Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) { - FormulaValue table = context.Scopes.Get(this.Target.VariableName!, ActionScopeType.Parse(this.Target.VariableScopeName)); + FormulaValue table = context.Scopes.Get(this.Target.VariableName!, WorkflowScopeType.Parse(this.Target.VariableScopeName)); TableValue tableValue = (TableValue)table; EditTableOperation? changeType = this.Model.ChangeType; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EndConversationAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EndConversationAction.cs index 7770820088..5d703a9992 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EndConversationAction.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EndConversationAction.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Bot.ObjectModel; namespace Microsoft.Agents.Workflows.Declarative.Handlers; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ForeachAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ForeachAction.cs index 457361a2d3..1443f3c85e 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ForeachAction.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ForeachAction.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ParseValueAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ParseValueAction.cs index 2d73a84e15..97ce7f2e86 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ParseValueAction.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ParseValueAction.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ResetVariableAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ResetVariableAction.cs index 2131893076..d24f183c58 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ResetVariableAction.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ResetVariableAction.cs @@ -3,6 +3,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SendActivityAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SendActivityAction.cs index c62a82a855..965798c944 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SendActivityAction.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SendActivityAction.cs @@ -4,6 +4,7 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Bot.ObjectModel; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetTextVariableAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetTextVariableAction.cs index b16cd6a81a..7c29c7dfab 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetTextVariableAction.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetTextVariableAction.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx.Types; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetVariableAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetVariableAction.cs index 851b33be74..95a68105f4 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetVariableAction.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetVariableAction.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 71c52fdeac..91c0463a21 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -3,6 +3,9 @@ using System; using System.IO; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Yaml; @@ -27,12 +30,12 @@ public static Workflow Build(TextReader yamlReader, string messageId, Wo string rootId = $"root_{GetRootId(rootElement)}"; Console.WriteLine("@ INITIALIZING BUILDER"); - ProcessActionScopes scopes = new(); + WorkflowScopes scopes = new(); DeclarativeWorkflowExecutor rootExecutor = new(scopes, rootId); Console.WriteLine("@ INTERPRETING WORKFLOW"); - ProcessActionVisitor visitor = new(rootExecutor, context ?? new WorkflowContext(), scopes); // %%% DEFAULT CONTEXT (IMMUTABLE) - ProcessActionWalker walker = new(rootElement, visitor); + WorkflowActionVisitor visitor = new(rootExecutor, context ?? new WorkflowContext(), scopes); // %%% DEFAULT CONTEXT (IMMUTABLE) + WorkflowElementWalker walker = new(rootElement, visitor); Console.WriteLine("@ FINALIZING WORKFLOW"); //ProcessStepBuilder errorHandler = // %%% DYNAMIC/CONTEXT ??? diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeActionExecutor.cs similarity index 92% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeActionExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeActionExecutor.cs index ac25996418..27b492c0f6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeActionExecutor.cs @@ -4,7 +4,7 @@ using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; -namespace Microsoft.Agents.Workflows.Declarative; +namespace Microsoft.Agents.Workflows.Declarative.Execution; internal sealed class DeclarativeActionExecutor(string actionId, Func action) : Executor(actionId), diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs similarity index 67% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs index ef10548808..d9f3d586ca 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs @@ -3,18 +3,19 @@ using System; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.PowerFx.Types; -namespace Microsoft.Agents.Workflows.Declarative; +namespace Microsoft.Agents.Workflows.Declarative.Execution; -internal sealed class DeclarativeWorkflowExecutor(ProcessActionScopes scopes, string workflowId) : +internal sealed class DeclarativeWorkflowExecutor(WorkflowScopes scopes, string workflowId) : Executor(workflowId), IMessageHandler { public async ValueTask HandleAsync(string message, IWorkflowContext context) { Console.WriteLine("!!! INIT WORKFLOW"); // %%% REMOVE - scopes.Set("LastMessage", ActionScopeType.System, StringValue.New(message)); // %%% MAGIC CONST "LastMessage" + scopes.Set("LastMessage", WorkflowScopeType.System, FormulaValue.New(message)); // %%% MAGIC CONST "LastMessage" //await context.AddEventAsync(new ExecutorInvokeEvent(this.Id, $"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}")).ConfigureAwait(false); await context.SendMessageAsync($"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}").ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ProcessAction.cs similarity index 86% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessAction.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ProcessAction.cs index 766236917f..07a078b5e2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessAction.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ProcessAction.cs @@ -10,13 +10,13 @@ using Microsoft.Extensions.Logging; using Microsoft.PowerFx; -namespace Microsoft.Agents.Workflows.Declarative; +namespace Microsoft.Agents.Workflows.Declarative.Execution; -internal sealed record class ProcessActionContext(RecalcEngine Engine, ProcessActionScopes Scopes, Func ClientFactory, ILogger Logger) +internal sealed record class ProcessActionContext(RecalcEngine Engine, WorkflowScopes Scopes, Func ClientFactory, ILogger Logger) { - private FoundryExpressionEngine? _expressionEngine; + private WorkflowExpressionEngine? _expressionEngine; - public FoundryExpressionEngine ExpressionEngine => this._expressionEngine ??= new FoundryExpressionEngine(this.Engine); + public WorkflowExpressionEngine ExpressionEngine => this._expressionEngine ??= new WorkflowExpressionEngine(this.Engine); } internal abstract class ProcessAction(TAction model) : ProcessAction(model) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionStack.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/ProcessActionStack.cs similarity index 92% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionStack.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/ProcessActionStack.cs index 4dcd582bad..ef91fc2f52 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionStack.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/ProcessActionStack.cs @@ -3,11 +3,11 @@ using System; using System.Collections.Generic; -namespace Microsoft.Agents.Workflows.Declarative; +namespace Microsoft.Agents.Workflows.Declarative.Interpreter; internal delegate void ScopeCompletionAction(string scopeId); // %%% NEEDED: scopeId ??? -internal sealed class ProcessActionStack +internal sealed class ProcessActionStack // %%% REMOVE ME { private readonly Stack _actionStack = []; private readonly Dictionary _actionScopes = []; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs similarity index 97% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionVisitor.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 7f558e57b7..5d5c09c0d5 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -5,6 +5,7 @@ using Azure.AI.Agents.Persistent; using Azure.Core.Pipeline; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.Handlers; using Microsoft.Agents.Workflows.Declarative.PowerFx; @@ -13,23 +14,23 @@ using Microsoft.SemanticKernel.Process.Workflows.Actions; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.Workflows.Declarative; +namespace Microsoft.Agents.Workflows.Declarative.Interpreter; -internal sealed class ProcessActionVisitor : DialogActionVisitor +internal sealed class WorkflowActionVisitor : DialogActionVisitor { private readonly WorkflowBuilder _workflowBuilder; - private readonly ProcessWorkflowBuilder _workflowModel; + private readonly WorkflowModel _workflowModel; private readonly ProcessActionStack _actionStack; private readonly WorkflowContext _context; - private readonly ProcessActionScopes _scopes; + private readonly WorkflowScopes _scopes; - public ProcessActionVisitor( + public WorkflowActionVisitor( ExecutorIsh rootAction, WorkflowContext context, - ProcessActionScopes scopes) + WorkflowScopes scopes) { this._actionStack = new ProcessActionStack(); - this._workflowModel = new ProcessWorkflowBuilder(rootAction); + this._workflowModel = new WorkflowModel(rootAction); this._workflowBuilder = new WorkflowBuilder(rootAction); this._context = context; this._scopes = scopes; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionWalker.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowElementWalker.cs similarity index 65% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionWalker.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowElementWalker.cs index 7fcb2a70c2..366ada41c0 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionWalker.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowElementWalker.cs @@ -3,13 +3,13 @@ using Microsoft.Agents.Workflows.Core; using Microsoft.Bot.ObjectModel; -namespace Microsoft.Agents.Workflows.Declarative; +namespace Microsoft.Agents.Workflows.Declarative.Interpreter; -internal sealed class ProcessActionWalker : BotElementWalker +internal sealed class WorkflowElementWalker : BotElementWalker { - private readonly ProcessActionVisitor _visitor; + private readonly WorkflowActionVisitor _visitor; - public ProcessActionWalker(BotElement rootElement, ProcessActionVisitor visitor) + public WorkflowElementWalker(BotElement rootElement, WorkflowActionVisitor visitor) { this._visitor = visitor; this.Visit(rootElement); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs similarity index 61% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessWorkflowBuilder.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs index fce3812a7d..f897e4c27a 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs @@ -3,23 +3,23 @@ using System; using System.Collections.Generic; -namespace Microsoft.Agents.Workflows.Declarative; +namespace Microsoft.Agents.Workflows.Declarative.Interpreter; /// /// Provides builder patterns for constructing a declarative process workflow. /// -internal sealed class ProcessWorkflowBuilder +internal sealed class WorkflowModel { - public ProcessWorkflowBuilder(ExecutorIsh rootStep) + public WorkflowModel(ExecutorIsh rootStep) { this.RootNode = this.DefineNode(rootStep); } - private ProcessWorkflowNode RootNode { get; } + private ModelNode RootNode { get; } - private Dictionary Steps { get; } = []; + private Dictionary Steps { get; } = []; - private List Links { get; } = []; + private List Links { get; } = []; public int GetDepth(string? nodeId) { @@ -28,7 +28,7 @@ public int GetDepth(string? nodeId) return 0; } - if (!this.Steps.TryGetValue(nodeId, out ProcessWorkflowNode? sourceNode)) + if (!this.Steps.TryGetValue(nodeId, out ModelNode? sourceNode)) { throw new UnknownActionException($"Unresolved step: {nodeId}."); } @@ -38,19 +38,19 @@ public int GetDepth(string? nodeId) public void AddNode(ExecutorIsh step, string parentId, Type? actionType = null) { - if (!this.Steps.TryGetValue(parentId, out ProcessWorkflowNode? parentNode)) + if (!this.Steps.TryGetValue(parentId, out ModelNode? parentNode)) { throw new UnknownActionException($"Unresolved parent for {step.Id}: {parentId}."); } - ProcessWorkflowNode stepNode = this.DefineNode(step, parentNode, actionType); + ModelNode stepNode = this.DefineNode(step, parentNode, actionType); parentNode.Children.Add(stepNode); } public void AddLinkFromPeer(string parentId, string targetId, Func? condition = null) { - if (!this.Steps.TryGetValue(parentId, out ProcessWorkflowNode? parentNode)) + if (!this.Steps.TryGetValue(parentId, out ModelNode? parentNode)) { throw new UnknownActionException($"Unresolved step: {parentId}."); } @@ -60,19 +60,19 @@ public void AddLinkFromPeer(string parentId, string targetId, Func? condition = null) { - if (!this.Steps.TryGetValue(sourceId, out ProcessWorkflowNode? sourceNode)) + if (!this.Steps.TryGetValue(sourceId, out ModelNode? sourceNode)) { throw new UnknownActionException($"Unresolved step: {sourceId}."); } - this.Links.Add(new ProcessWorkflowLink(sourceNode, targetId, condition)); + this.Links.Add(new ModelLink(sourceNode, targetId, condition)); } //public void AddStop(string nodeId) // %%% REMOVE @@ -87,9 +87,9 @@ public void AddLink(string sourceId, string targetId, Func? condi public void ConnectNodes(WorkflowBuilder workflowBuilder) { - foreach (ProcessWorkflowLink link in this.Links) + foreach (ModelLink link in this.Links) { - if (!this.Steps.TryGetValue(link.TargetId, out ProcessWorkflowNode? targetNode)) + if (!this.Steps.TryGetValue(link.TargetId, out ModelNode? targetNode)) { throw new WorkflowBuilderException($"Unresolved target for {link.Source.Id}: {link.TargetId}."); } @@ -100,9 +100,9 @@ public void ConnectNodes(WorkflowBuilder workflowBuilder) } } - private ProcessWorkflowNode DefineNode(ExecutorIsh step, ProcessWorkflowNode? parentNode = null, Type? actionType = null) + private ModelNode DefineNode(ExecutorIsh step, ModelNode? parentNode = null, Type? actionType = null) { - ProcessWorkflowNode stepNode = new(step, parentNode, actionType); + ModelNode stepNode = new(step, parentNode, actionType); this.Steps[stepNode.Id] = stepNode; return stepNode; @@ -117,7 +117,7 @@ private ProcessWorkflowNode DefineNode(ExecutorIsh step, ProcessWorkflowNode? pa while (itemId != null) { - if (!this.Steps.TryGetValue(itemId, out ProcessWorkflowNode? itemNode)) + if (!this.Steps.TryGetValue(itemId, out ModelNode? itemNode)) { throw new UnknownActionException($"Unresolved child: {itemId}."); } @@ -133,20 +133,20 @@ private ProcessWorkflowNode DefineNode(ExecutorIsh step, ProcessWorkflowNode? pa return null; } - private sealed class ProcessWorkflowNode(ExecutorIsh step, ProcessWorkflowNode? parent = null, Type? actionType = null) + private sealed class ModelNode(ExecutorIsh step, ModelNode? parent = null, Type? actionType = null) { public string Id => step.Id; public ExecutorIsh Step => step; - public ProcessWorkflowNode? Parent { get; } = parent; + public ModelNode? Parent { get; } = parent; - public List Children { get; } = []; + public List Children { get; } = []; public int Depth => this.Parent?.Depth + 1 ?? 0; public Type? ActionType => actionType; } - private sealed record class ProcessWorkflowLink(ProcessWorkflowNode Source, string TargetId, Func? Condition = null); + private sealed record class ModelLink(ModelNode Source, string TargetId, Func? Condition = null); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineExtensions.cs index 298fc96dc3..e2cba8d2bc 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineExtensions.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.Workflows.Declarative.PowerFx; internal static class RecalcEngineExtensions { - public static void ClearScope(this RecalcEngine engine, ProcessActionScopes scopes, ActionScopeType scope) + public static void ClearScope(this RecalcEngine engine, WorkflowScopes scopes, WorkflowScopeType scope) { // Clear all scope values. scopes.Clear(scope); @@ -18,10 +18,10 @@ public static void ClearScope(this RecalcEngine engine, ProcessActionScopes scop engine.UpdateScope(scopes, scope); } - public static void ClearScopedVariable(this RecalcEngine engine, ProcessActionScopes scopes, PropertyPath variablePath) => - engine.ClearScopedVariable(scopes, ActionScopeType.Parse(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName)); + public static void ClearScopedVariable(this RecalcEngine engine, WorkflowScopes scopes, PropertyPath variablePath) => + engine.ClearScopedVariable(scopes, WorkflowScopeType.Parse(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName)); - public static void ClearScopedVariable(this RecalcEngine engine, ProcessActionScopes scopes, ActionScopeType scope, string varName) + public static void ClearScopedVariable(this RecalcEngine engine, WorkflowScopes scopes, WorkflowScopeType scope, string varName) { // Clear value. scopes.Remove(varName, scope); @@ -30,10 +30,10 @@ public static void ClearScopedVariable(this RecalcEngine engine, ProcessActionSc engine.UpdateScope(scopes, scope); } - public static void SetScopedVariable(this RecalcEngine engine, ProcessActionScopes scopes, PropertyPath variablePath, FormulaValue value) => - engine.SetScopedVariable(scopes, ActionScopeType.Parse(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName), value); + public static void SetScopedVariable(this RecalcEngine engine, WorkflowScopes scopes, PropertyPath variablePath, FormulaValue value) => + engine.SetScopedVariable(scopes, WorkflowScopeType.Parse(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName), value); - public static void SetScopedVariable(this RecalcEngine engine, ProcessActionScopes scopes, ActionScopeType scope, string varName, FormulaValue value) + public static void SetScopedVariable(this RecalcEngine engine, WorkflowScopes scopes, WorkflowScopeType scope, string varName, FormulaValue value) { // Assign value. scopes.Set(varName, scope, value); @@ -48,7 +48,7 @@ public static void SetScope(this RecalcEngine engine, string scopeName, RecordVa engine.UpdateVariable(scopeName, scopeRecord); } - private static void UpdateScope(this RecalcEngine engine, ProcessActionScopes scopes, ActionScopeType scope) + private static void UpdateScope(this RecalcEngine engine, WorkflowScopes scopes, WorkflowScopeType scope) { RecordValue scopeRecord = scopes.BuildRecord(scope); engine.SetScope(scope.Name, scopeRecord); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs index 978895f54a..6f14c81eeb 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs @@ -8,20 +8,20 @@ namespace Microsoft.Agents.Workflows.Declarative.PowerFx; internal static class RecalcEngineFactory { public static RecalcEngine Create( - ProcessActionScopes scopes, + WorkflowScopes scopes, int? maximumExpressionLength = null, int? maximumCallDepth = null) { RecalcEngine engine = new(CreateConfig()); - SetScope(ActionScopeType.Topic); - SetScope(ActionScopeType.Global); - SetScope(ActionScopeType.Env); - SetScope(ActionScopeType.System); + SetScope(WorkflowScopeType.Topic); + SetScope(WorkflowScopeType.Global); + SetScope(WorkflowScopeType.Env); + SetScope(WorkflowScopeType.System); return engine; - void SetScope(ActionScopeType scope) + void SetScope(WorkflowScopeType scope) { RecordValue record = scopes.BuildRecord(scope); engine.UpdateVariable(scope.Name, record); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/FoundryExpressionEngine.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs similarity index 90% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/FoundryExpressionEngine.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs index eb8ce1cb32..9c452b6286 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/FoundryExpressionEngine.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs @@ -15,51 +15,51 @@ namespace Microsoft.Agents.Workflows.Declarative.PowerFx; -internal class FoundryExpressionEngine : IExpressionEngine +internal class WorkflowExpressionEngine : IExpressionEngine { //private static readonly JsonSerializerOptions s_options = new(); // %%% INVESTIGATE: ElementSerializer.CreateOptions(); private readonly RecalcEngine _engine; - public FoundryExpressionEngine(RecalcEngine engine) + public WorkflowExpressionEngine(RecalcEngine engine) { this._engine = engine; } - public EvaluationResult GetValue(BoolExpression boolean, ProcessActionScopes state) => this.GetValue(boolean, state, this.EvaluateScope); + public EvaluationResult GetValue(BoolExpression boolean, WorkflowScopes state) => this.GetValue(boolean, state, this.EvaluateScope); public EvaluationResult GetValue(BoolExpression boolean, RecordDataValue state) => this.GetValue(boolean, state, this.EvaluateState); - public EvaluationResult GetValue(StringExpression expression, ProcessActionScopes state) => this.GetValue(expression, state, this.EvaluateScope); + public EvaluationResult GetValue(StringExpression expression, WorkflowScopes state) => this.GetValue(expression, state, this.EvaluateScope); public EvaluationResult GetValue(StringExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); - public EvaluationResult GetValue(ValueExpression expression, ProcessActionScopes state) => this.GetValue(expression, state, this.EvaluateScope); + public EvaluationResult GetValue(ValueExpression expression, WorkflowScopes state) => this.GetValue(expression, state, this.EvaluateScope); public EvaluationResult GetValue(ValueExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); - public EvaluationResult GetValue(IntExpression expression, ProcessActionScopes state) => this.GetValue(expression, state, this.EvaluateScope); + public EvaluationResult GetValue(IntExpression expression, WorkflowScopes state) => this.GetValue(expression, state, this.EvaluateScope); public EvaluationResult GetValue(IntExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); - public EvaluationResult GetValue(NumberExpression expression, ProcessActionScopes state) => this.GetValue(expression, state, this.EvaluateScope); + public EvaluationResult GetValue(NumberExpression expression, WorkflowScopes state) => this.GetValue(expression, state, this.EvaluateScope); public EvaluationResult GetValue(NumberExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); - public EvaluationResult GetValue(ObjectExpression expression, ProcessActionScopes state) where TValue : BotElement => this.GetValue(expression, state, this.EvaluateScope); + public EvaluationResult GetValue(ObjectExpression expression, WorkflowScopes state) where TValue : BotElement => this.GetValue(expression, state, this.EvaluateScope); public EvaluationResult GetValue(ObjectExpression expression, RecordDataValue state) where TValue : BotElement => this.GetValue(expression, state, this.EvaluateState); - public ImmutableArray GetValue(ArrayExpression expression, ProcessActionScopes state) => this.GetValue(expression, state, this.EvaluateScope).Value; + public ImmutableArray GetValue(ArrayExpression expression, WorkflowScopes state) => this.GetValue(expression, state, this.EvaluateScope).Value; public ImmutableArray GetValue(ArrayExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState).Value; - public ImmutableArray GetValue(ArrayExpressionOnly expression, ProcessActionScopes state) => this.GetValue(expression, state, this.EvaluateScope).Value; + public ImmutableArray GetValue(ArrayExpressionOnly expression, WorkflowScopes state) => this.GetValue(expression, state, this.EvaluateScope).Value; public ImmutableArray GetValue(ArrayExpressionOnly expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState).Value; - public EvaluationResult GetValue(EnumExpression expression, ProcessActionScopes state) where TValue : EnumWrapper => - this.GetValue(expression, state, this.EvaluateScope); + public EvaluationResult GetValue(EnumExpression expression, WorkflowScopes state) where TValue : EnumWrapper => + this.GetValue(expression, state, this.EvaluateScope); public EvaluationResult GetValue(EnumExpression expression, RecordDataValue state) where TValue : EnumWrapper => this.GetValue(expression, state, this.EvaluateState); @@ -307,12 +307,12 @@ private EvaluationResult EvaluateState(ExpressionBase expression, return this.Evaluate(expression); } - private EvaluationResult EvaluateScope(ExpressionBase expression, ProcessActionScopes state) + private EvaluationResult EvaluateScope(ExpressionBase expression, WorkflowScopes state) { - this._engine.SetScope(ActionScopeType.System.Name, state.BuildRecord(ActionScopeType.System)); - this._engine.SetScope(ActionScopeType.Env.Name, state.BuildRecord(ActionScopeType.Env)); - this._engine.SetScope(ActionScopeType.Global.Name, state.BuildRecord(ActionScopeType.Global)); - this._engine.SetScope(ActionScopeType.Topic.Name, state.BuildRecord(ActionScopeType.Topic)); + this._engine.SetScope(WorkflowScopeType.System.Name, state.BuildRecord(WorkflowScopeType.System)); + this._engine.SetScope(WorkflowScopeType.Env.Name, state.BuildRecord(WorkflowScopeType.Env)); + this._engine.SetScope(WorkflowScopeType.Global.Name, state.BuildRecord(WorkflowScopeType.Global)); + this._engine.SetScope(WorkflowScopeType.Topic.Name, state.BuildRecord(WorkflowScopeType.Topic)); return this.Evaluate(expression); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs new file mode 100644 index 0000000000..675b4ff64b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.PowerFx; + +/// +/// The set of variables for a specific action scope. +/// +internal sealed class WorkflowScope : Dictionary +{ + public RecordValue BuildRecord() + { + return FormulaValue.NewRecordFromFields(GetFields()); + + IEnumerable GetFields() + { + foreach (KeyValuePair kvp in this) + { + yield return new NamedValue(kvp.Key, kvp.Value); + } + } + } + + public RecordDataValue BuildState() + { + RecordDataValue.Builder recordBuilder = new(); + + foreach (KeyValuePair kvp in this) + { + recordBuilder.Properties.Add(kvp.Key, kvp.Value.GetDataValue()); + } + + return recordBuilder.Build(); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs new file mode 100644 index 0000000000..3ba8ccc711 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.PowerFx; + +/// +/// Contains all action scopes for a process. +/// +internal sealed class WorkflowScopes +{ + private readonly ImmutableDictionary _scopes; + + public WorkflowScopes() + { + Dictionary scopes = + new() + { + { WorkflowScopeType.Env, [] }, + { WorkflowScopeType.Topic, [] }, + { WorkflowScopeType.Global, [] }, + { WorkflowScopeType.System, [] }, + }; + + this._scopes = scopes.ToImmutableDictionary(); + } + + public RecordValue BuildRecord(WorkflowScopeType scope) => this._scopes[scope].BuildRecord(); + + public RecordDataValue BuildState() + { + return DataValue.RecordFromFields(BuildStateFields()); + + IEnumerable> BuildStateFields() + { + foreach (KeyValuePair kvp in this._scopes) + { + yield return new(kvp.Key.Name, kvp.Value.BuildState()); + } + } + } + + public FormulaValue Get(string name, WorkflowScopeType? type = null) + { + if (this._scopes[type ?? WorkflowScopeType.Topic].TryGetValue(name, out FormulaValue? value)) + { + return value; + } + + return FormulaValue.NewBlank(); + } + + public void Clear(WorkflowScopeType type) => this._scopes[type].Clear(); + + public void Remove(string name) => this.Remove(name, WorkflowScopeType.Topic); + + public void Remove(string name, WorkflowScopeType type) => this._scopes[type].Remove(name); + + public void Set(string name, FormulaValue value) => this.Set(name, WorkflowScopeType.Topic, value); + + public void Set(string name, WorkflowScopeType type, FormulaValue value) => this._scopes[type][name] = value; +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopesType.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopesType.cs new file mode 100644 index 0000000000..52c0274783 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopesType.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative.PowerFx; + +/// +/// Describes the type of action scope. +/// +internal sealed class WorkflowScopeType // %%% NEEDED +{ + // https://msazure.visualstudio.com/CCI/_git/ObjectModel?path=/src/ObjectModel/Nodes/VariableScopeNames.cs&_a=contents&version=GBmain + public static readonly WorkflowScopeType Env = new(VariableScopeNames.Environment); + public static readonly WorkflowScopeType Topic = new(VariableScopeNames.Topic); + public static readonly WorkflowScopeType Global = new(VariableScopeNames.Global); + public static readonly WorkflowScopeType System = new(VariableScopeNames.System); + + public static WorkflowScopeType Parse(string? scope) + { + return scope switch + { + nameof(Env) => Env, + nameof(Global) => Global, + nameof(System) => System, + nameof(Topic) => Topic, + null => throw new InvalidScopeException("Undefined action scope type."), + _ => throw new InvalidScopeException($"Unknown action scope type: {scope}."), + }; + } + + private WorkflowScopeType(string name) + { + this.Name = name; + } + + public string Name { get; } + + public string Format(string name) => $"{this.Name}.{name}"; + + public override string ToString() => this.Name; + + public override int GetHashCode() => this.Name.GetHashCode(); + + public override bool Equals(object? obj) => + (obj is WorkflowScopeType other && this.Name.Equals(other.Name, StringComparison.Ordinal)) || + (obj is string name && this.Name.Equals(name, StringComparison.Ordinal)); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionScopes.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionScopes.cs deleted file mode 100644 index 000ab00135..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ProcessActionScopes.cs +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using Microsoft.Bot.ObjectModel; -using Microsoft.PowerFx.Types; -using Microsoft.Agents.Workflows.Declarative.Extensions; - -namespace Microsoft.Agents.Workflows.Declarative; - -/// -/// Describes the type of action scope. -/// -internal sealed class ActionScopeType // %%% NEEDED -{ - // https://msazure.visualstudio.com/CCI/_git/ObjectModel?path=/src/ObjectModel/Nodes/VariableScopeNames.cs&_a=contents&version=GBmain - public static readonly ActionScopeType Env = new(VariableScopeNames.Environment); - public static readonly ActionScopeType Topic = new(VariableScopeNames.Topic); - public static readonly ActionScopeType Global = new(VariableScopeNames.Global); - public static readonly ActionScopeType System = new(VariableScopeNames.System); - - public static ActionScopeType Parse(string? scope) - { - return scope switch - { - nameof(Env) => Env, - nameof(Global) => Global, - nameof(System) => System, - nameof(Topic) => Topic, - null => throw new InvalidScopeException("Undefined action scope type."), - _ => throw new InvalidScopeException($"Unknown action scope type: {scope}."), - }; - } - - private ActionScopeType(string name) - { - this.Name = name; - } - - public string Name { get; } - - public string Format(string name) => $"{this.Name}.{name}"; - - public override string ToString() => this.Name; - - public override int GetHashCode() => this.Name.GetHashCode(); - - public override bool Equals(object? obj) => - (obj is ActionScopeType other && this.Name.Equals(other.Name, StringComparison.Ordinal)) || - (obj is string name && this.Name.Equals(name, StringComparison.Ordinal)); -} - -/// -/// The set of variables for a specific action scope. -/// -internal sealed class ProcessActionScope : Dictionary -{ - public RecordValue BuildRecord() - { - return FormulaValue.NewRecordFromFields(GetFields()); - - IEnumerable GetFields() - { - foreach (KeyValuePair kvp in this) - { - yield return new NamedValue(kvp.Key, kvp.Value); - } - } - } - - public RecordDataValue BuildState() - { - RecordDataValue.Builder recordBuilder = new(); - - foreach (KeyValuePair kvp in this) - { - recordBuilder.Properties.Add(kvp.Key, kvp.Value.GetDataValue()); - } - - return recordBuilder.Build(); - } -} - -/// -/// Contains all action scopes for a process. -/// -internal sealed class ProcessActionScopes -{ - private readonly ImmutableDictionary _scopes; - - public ProcessActionScopes() - { - Dictionary scopes = - new() - { - { ActionScopeType.Env, [] }, - { ActionScopeType.Topic, [] }, - { ActionScopeType.Global, [] }, - { ActionScopeType.System, [] }, - }; - - this._scopes = scopes.ToImmutableDictionary(); - } - - public RecordValue BuildRecord(ActionScopeType scope) => this._scopes[scope].BuildRecord(); - - public RecordDataValue BuildState() - { - return RecordDataValue.RecordFromFields(BuildStateFields()); - - IEnumerable> BuildStateFields() - { - foreach (KeyValuePair kvp in this._scopes) - { - yield return new(kvp.Key.Name, kvp.Value.BuildState()); - } - } - } - - public FormulaValue Get(string name, ActionScopeType? type = null) - { - if (this._scopes[type ?? ActionScopeType.Topic].TryGetValue(name, out FormulaValue? value)) - { - return value; - } - - return FormulaValue.NewBlank(); - } - - public void Clear(ActionScopeType type) => this._scopes[type].Clear(); - - public void Remove(string name) => this.Remove(name, ActionScopeType.Topic); - - public void Remove(string name, ActionScopeType type) => this._scopes[type].Remove(name); - - public void Set(string name, FormulaValue value) => this.Set(name, ActionScopeType.Topic, value); - - public void Set(string name, ActionScopeType type, FormulaValue value) => this._scopes[type][name] = value; -} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/FoundryExpressionEngineTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/FoundryExpressionEngineTests.cs index ff128cd7b0..45a1771bf4 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/FoundryExpressionEngineTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/FoundryExpressionEngineTests.cs @@ -16,7 +16,7 @@ public void DefaultNotNull() { // Act RecalcEngine engine = this.CreateEngine(); - FoundryExpressionEngine expressionEngine = new(engine); + WorkflowExpressionEngine expressionEngine = new(engine); this.Scopes.Set("test", FormulaValue.New("value")); engine.SetScopedVariable(this.Scopes, PropertyPath.TopicVariable("test"), FormulaValue.New("value")); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineTest.cs index 9bbd36912c..022baf481b 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineTest.cs @@ -11,7 +11,7 @@ namespace Microsoft.Agents.Workflows.Declarative.UnitTests.PowerFx; /// public abstract class RecalcEngineTest(ITestOutputHelper output) : WorkflowTest(output) { - internal ProcessActionScopes Scopes { get; } = new(); + internal WorkflowScopes Scopes { get; } = new(); protected RecalcEngine CreateEngine(int maximumExpressionLength = 500) => RecalcEngineFactory.Create(this.Scopes, maximumExpressionLength); } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ProcessActionScopesTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ProcessActionScopesTests.cs index b44a63f19a..cd53f3c457 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ProcessActionScopesTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ProcessActionScopesTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Linq; +using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.PowerFx.Types; namespace Microsoft.Agents.Workflows.Declarative.UnitTests; @@ -11,13 +12,13 @@ public class ProcessActionScopesTests public void ConstructorInitializesAllScopes() { // Arrange & Act - ProcessActionScopes scopes = new(); + WorkflowScopes scopes = new(); // Assert - RecordValue envRecord = scopes.BuildRecord(ActionScopeType.Env); - RecordValue topicRecord = scopes.BuildRecord(ActionScopeType.Topic); - RecordValue globalRecord = scopes.BuildRecord(ActionScopeType.Global); - RecordValue systemRecord = scopes.BuildRecord(ActionScopeType.System); + RecordValue envRecord = scopes.BuildRecord(WorkflowScopeType.Env); + RecordValue topicRecord = scopes.BuildRecord(WorkflowScopeType.Topic); + RecordValue globalRecord = scopes.BuildRecord(WorkflowScopeType.Global); + RecordValue systemRecord = scopes.BuildRecord(WorkflowScopeType.System); Assert.NotNull(envRecord); Assert.NotNull(topicRecord); @@ -29,10 +30,10 @@ public void ConstructorInitializesAllScopes() public void BuildRecordWhenEmpty() { // Arrange - ProcessActionScopes scopes = new(); + WorkflowScopes scopes = new(); // Act - RecordValue record = scopes.BuildRecord(ActionScopeType.Topic); + RecordValue record = scopes.BuildRecord(WorkflowScopeType.Topic); // Assert Assert.NotNull(record); @@ -43,12 +44,12 @@ public void BuildRecordWhenEmpty() public void BuildRecordContainsSetValues() { // Arrange - ProcessActionScopes scopes = new(); + WorkflowScopes scopes = new(); FormulaValue testValue = FormulaValue.New("test"); - scopes.Set("key1", ActionScopeType.Topic, testValue); + scopes.Set("key1", WorkflowScopeType.Topic, testValue); // Act - RecordValue record = scopes.BuildRecord(ActionScopeType.Topic); + RecordValue record = scopes.BuildRecord(WorkflowScopeType.Topic); // Assert Assert.NotNull(record); @@ -61,24 +62,24 @@ public void BuildRecordContainsSetValues() public void BuildRecordForAllScopeTypes() { // Arrange - ProcessActionScopes scopes = new(); + WorkflowScopes scopes = new(); FormulaValue testValue = FormulaValue.New("test"); // Act & Assert - scopes.Set("envKey", ActionScopeType.Env, testValue); - RecordValue envRecord = scopes.BuildRecord(ActionScopeType.Env); + scopes.Set("envKey", WorkflowScopeType.Env, testValue); + RecordValue envRecord = scopes.BuildRecord(WorkflowScopeType.Env); Assert.Single(envRecord.Fields); - scopes.Set("topicKey", ActionScopeType.Topic, testValue); - RecordValue topicRecord = scopes.BuildRecord(ActionScopeType.Topic); + scopes.Set("topicKey", WorkflowScopeType.Topic, testValue); + RecordValue topicRecord = scopes.BuildRecord(WorkflowScopeType.Topic); Assert.Single(topicRecord.Fields); - scopes.Set("globalKey", ActionScopeType.Global, testValue); - RecordValue globalRecord = scopes.BuildRecord(ActionScopeType.Global); + scopes.Set("globalKey", WorkflowScopeType.Global, testValue); + RecordValue globalRecord = scopes.BuildRecord(WorkflowScopeType.Global); Assert.Single(globalRecord.Fields); - scopes.Set("systemKey", ActionScopeType.System, testValue); - RecordValue systemRecord = scopes.BuildRecord(ActionScopeType.System); + scopes.Set("systemKey", WorkflowScopeType.System, testValue); + RecordValue systemRecord = scopes.BuildRecord(WorkflowScopeType.System); Assert.Single(systemRecord.Fields); } @@ -86,9 +87,9 @@ public void BuildRecordForAllScopeTypes() public void GetWithImplicitScope() { // Arrange - ProcessActionScopes scopes = new(); + WorkflowScopes scopes = new(); FormulaValue testValue = FormulaValue.New("test"); - scopes.Set("key1", ActionScopeType.Topic, testValue); + scopes.Set("key1", WorkflowScopeType.Topic, testValue); // Act FormulaValue result = scopes.Get("key1"); @@ -101,12 +102,12 @@ public void GetWithImplicitScope() public void GetWithSpecifiedScope() { // Arrange - ProcessActionScopes scopes = new(); + WorkflowScopes scopes = new(); FormulaValue testValue = FormulaValue.New("test"); - scopes.Set("key1", ActionScopeType.Global, testValue); + scopes.Set("key1", WorkflowScopeType.Global, testValue); // Act - FormulaValue result = scopes.Get("key1", ActionScopeType.Global); + FormulaValue result = scopes.Get("key1", WorkflowScopeType.Global); // Assert Assert.Equal(testValue, result); @@ -116,14 +117,14 @@ public void GetWithSpecifiedScope() public void SetDefaultScope() { // Arrange - ProcessActionScopes scopes = new(); + WorkflowScopes scopes = new(); FormulaValue testValue = FormulaValue.New("test"); // Act scopes.Set("key1", testValue); // Assert - FormulaValue result = scopes.Get("key1", ActionScopeType.Topic); + FormulaValue result = scopes.Get("key1", WorkflowScopeType.Topic); Assert.Equal(testValue, result); } @@ -131,14 +132,14 @@ public void SetDefaultScope() public void SetSpecifiedScope() { // Arrange - ProcessActionScopes scopes = new(); + WorkflowScopes scopes = new(); FormulaValue testValue = FormulaValue.New("test"); // Act - scopes.Set("key1", ActionScopeType.System, testValue); + scopes.Set("key1", WorkflowScopeType.System, testValue); // Assert - FormulaValue result = scopes.Get("key1", ActionScopeType.System); + FormulaValue result = scopes.Get("key1", WorkflowScopeType.System); Assert.Equal(testValue, result); } @@ -146,16 +147,16 @@ public void SetSpecifiedScope() public void SetOverwritesExistingValue() { // Arrange - ProcessActionScopes scopes = new(); + WorkflowScopes scopes = new(); FormulaValue initialValue = FormulaValue.New("initial"); FormulaValue newValue = FormulaValue.New("new"); // Act - scopes.Set("key1", ActionScopeType.Topic, initialValue); - scopes.Set("key1", ActionScopeType.Topic, newValue); + scopes.Set("key1", WorkflowScopeType.Topic, initialValue); + scopes.Set("key1", WorkflowScopeType.Topic, newValue); // Assert - FormulaValue result = scopes.Get("key1", ActionScopeType.Topic); + FormulaValue result = scopes.Get("key1", WorkflowScopeType.Topic); Assert.Equal(newValue, result); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowTest.cs index 47f8c03afd..9f35353139 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowTest.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using Microsoft.Agents.Workflows.Declarative.PowerFx; using Xunit.Abstractions; namespace Microsoft.Agents.Workflows.Declarative.UnitTests; @@ -32,5 +33,5 @@ protected virtual void Dispose(bool isDisposing) } } - internal static string FormatVariablePath(string variableName, ActionScopeType? scope = null) => $"{scope ?? ActionScopeType.Topic}.{variableName}"; + internal static string FormatVariablePath(string variableName, WorkflowScopeType? scope = null) => $"{scope ?? WorkflowScopeType.Topic}.{variableName}"; } From 0062f511c2c3b9319073c5af1e35de15ff791693 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 11 Aug 2025 12:51:23 -0700 Subject: [PATCH 044/232] Completion --- .../Workflows/DeepResearchAgent.fdl | 144 ------------------ .../DeclarativeWorkflowBuilder.cs | 2 +- .../Interpreter/ProcessActionStack.cs | 46 ------ .../Interpreter/WorkflowActionVisitor.cs | 26 ++-- .../Interpreter/WorkflowModel.cs | 52 ++++--- 5 files changed, 40 insertions(+), 230 deletions(-) delete mode 100644 dotnet/samples/GettingStarted/Workflows/DeepResearchAgent.fdl delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/ProcessActionStack.cs diff --git a/dotnet/samples/GettingStarted/Workflows/DeepResearchAgent.fdl b/dotnet/samples/GettingStarted/Workflows/DeepResearchAgent.fdl deleted file mode 100644 index 536342dd32..0000000000 --- a/dotnet/samples/GettingStarted/Workflows/DeepResearchAgent.fdl +++ /dev/null @@ -1,144 +0,0 @@ -name: deepresearch -states: - - name: GatherFacts - actors: - - agent: LedgerFacts - inputs: - instructions: instructions - outputs: - task: task - facts: facts - thread: Planning - humanInLoopMode: onNoMessage - streamOutput: false - isFinal: false - - name: Plan - actors: - - agent: LedgerPlanner - inputs: - task: task - facts: facts - team: team - instructions: instructions - messagesOut: plannerMessages - thread: Planning - humanInLoopMode: never - streamOutput: true - isFinal: false - - name: ProcessProgress - actors: - - agent: ProgressLedger - inputs: - task: task - team: team - systemAgents: systemAgents - messagesOut: nextStepMessages - messagesIn: - - plannerMessages - thread: Run - humanInLoopMode: never - streamOutput: true - isFinal: false - - name: actionRouter - actors: - - agent: ActionRouterAgent - messagesIn: - - nextStepMessages - inputs: - team: team - systemAgents: systemAgents - outputs: - targetAgent: nextAgent - humanInLoopMode: never - streamOutput: true - - name: dynamicStepAgent - actors: - - agent: nextAgent - thread: Run - humanInLoopMode: never - streamOutput: true - - name: UpdateLedgerFact - actors: - - agent: LedgerFactsUpdate - thread: Run - inputs: - task: task - facts: facts - outputs: - updatedFacts: facts - humanInLoopMode: never - streamOutput: false - isFinal: false - - name: LedgerPlanUpdate - actors: - - agent: LedgerPlanUpdate - inputs: - facts: facts - team: team - messagesOut: plannerMessages - thread: Run - humanInLoopMode: never - streamOutput: true - isFinal: false - - name: Summarizer - actors: - - agent: FinalStepAgent - thread: Run - inputs: - task: task - humanInLoopMode: never - streamOutput: true - isFinal: true -transitions: - - from: GatherFacts - to: Plan - - from: Plan - to: ProcessProgress - - from: LedgerPlanUpdate - to: ProcessProgress - - from: ProcessProgress - to: actionRouter - - from: actionRouter - to: UpdateLedgerFact - condition: nextAgent.Equals(LedgerFactsUpdate) - - from: actionRouter - to: Summarizer - condition: nextAgent.Equals(FinalStepAgent) - - from: actionRouter - to: dynamicStepAgent - condition: nextAgent.NotContains(FinalStepAgent) - - from: dynamicStepAgent - to: ProcessProgress - - from: UpdateLedgerFact - to: LedgerPlanUpdate -variables: - - Type: userDefined - name: team - - Type: userDefined - name: instructions - - Type: userDefined - name: task - - Type: userDefined - name: facts - - Type: userDefined - name: plan - - Type: messages - name: plannerMessages - - Type: thread - name: Planning - - Type: thread - name: Run - - Type: messages - name: nextStepMessages - - Type: userDefined - name: nextAgent - - Type: userDefined - name: systemAgents - value: - - agent: FinalStepAgent - description: >- - Agent which summarizes the output after task is complete. When next speaker is none. - - agent: LedgerFactsUpdate - description: >- - Agent which can update the plan if we are looping without making progress or stall is detected. -startstate: GatherFacts diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 91c0463a21..964cfdd870 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -37,7 +37,7 @@ public static Workflow Build(TextReader yamlReader, string messageId, Wo WorkflowActionVisitor visitor = new(rootExecutor, context ?? new WorkflowContext(), scopes); // %%% DEFAULT CONTEXT (IMMUTABLE) WorkflowElementWalker walker = new(rootElement, visitor); - Console.WriteLine("@ FINALIZING WORKFLOW"); + //Console.WriteLine("@ FINALIZING WORKFLOW"); //ProcessStepBuilder errorHandler = // %%% DYNAMIC/CONTEXT ??? // processBuilder.AddStepFromFunction( // $"{processBuilder.Name}_unhandled_error", diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/ProcessActionStack.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/ProcessActionStack.cs deleted file mode 100644 index ef91fc2f52..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/ProcessActionStack.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; - -namespace Microsoft.Agents.Workflows.Declarative.Interpreter; - -internal delegate void ScopeCompletionAction(string scopeId); // %%% NEEDED: scopeId ??? - -internal sealed class ProcessActionStack // %%% REMOVE ME -{ - private readonly Stack _actionStack = []; - private readonly Dictionary _actionScopes = []; - - public string CurrentScope => - this._actionStack.Count > 0 ? - this._actionStack.Peek() : - throw new InvalidOperationException("No scope defined"); // %%% EXCEPTION TYPE - - public void Recognize(string scopeId, ScopeCompletionAction? callback = null) - { -#if NET - if (this._actionScopes.TryAdd(scopeId, callback)) - { -#else - if (!this._actionScopes.ContainsKey(scopeId)) - { - this._actionScopes[scopeId] = callback; -#endif - // If the scope is new, push it onto the stack - this._actionStack.Push(scopeId); - } - else - { - // Otherwise, unwind the stack to the given scope - string currentScopeId; - while ((currentScopeId = this.CurrentScope) != scopeId) - { - ScopeCompletionAction? unwoundCallback = this._actionScopes[currentScopeId]; - unwoundCallback?.Invoke(currentScopeId); - this._actionStack.Pop(); - this._actionScopes.Remove(currentScopeId); - } - } - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 5d5c09c0d5..29cfdbde07 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -20,7 +20,6 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor { private readonly WorkflowBuilder _workflowBuilder; private readonly WorkflowModel _workflowModel; - private readonly ProcessActionStack _actionStack; private readonly WorkflowContext _context; private readonly WorkflowScopes _scopes; @@ -29,7 +28,6 @@ public WorkflowActionVisitor( WorkflowContext context, WorkflowScopes scopes) { - this._actionStack = new ProcessActionStack(); this._workflowModel = new WorkflowModel(rootAction); this._workflowBuilder = new WorkflowBuilder(rootAction); this._context = context; @@ -126,13 +124,13 @@ protected override void Visit(Foreach item) this.Trace(item, isSkipped: false); ForeachAction action = new(item); - this.ContinueWith(action); - string restartId = this.RestartFrom(action); string loopId = ForeachAction.Steps.Next(action.Id); - this.ContinueWith(this.CreateStep(loopId, $"{nameof(ForeachAction)}_Next", action.TakeNext), action.Id, callback: CompletionHandler); + this.ContinueWith(action, callback: CompletionHandler); + string restartId = this.RestartFrom(action); + this.ContinueWith(this.CreateStep(loopId, $"{nameof(ForeachAction)}_Next", action.TakeNext), action.Id); this._workflowModel.AddLink(loopId, restartId, (_) => !action.HasValue); this.ContinueWith(this.CreateStep(ForeachAction.Steps.Start(action.Id), $"{nameof(ForeachAction)}_Start"), action.Id, (_) => action.HasValue); - void CompletionHandler(string _) + void CompletionHandler() { string completionId = ForeachAction.Steps.End(action.Id); this.ContinueWith(this.CreateStep(completionId, $"{nameof(ForeachAction)}_End"), action.Id); @@ -420,20 +418,18 @@ protected override void Visit(SearchAndSummarizeContent item) private void ContinueWith( ProcessAction action, Func? condition = null, - ScopeCompletionAction? callback = null) => - this.ContinueWith(this.CreateActionStep(action), action.ParentId, condition, action.GetType(), callback); + ScopeCompletionHandler? callback = null) => + this.ContinueWith(this.DefineActionExecutor(action), action.ParentId, condition, action.GetType(), callback); private void ContinueWith( - ExecutorIsh step, + ExecutorIsh executor, string parentId, Func? condition = null, Type? actionType = null, - ScopeCompletionAction? callback = null) + ScopeCompletionHandler? callback = null) { - Console.WriteLine($"##### RECOGNIZE {parentId} <= {step.Id}"); // %%% LOGGER - this._actionStack.Recognize(parentId, callback); - this._workflowModel.AddNode(step, parentId, actionType); - this._workflowModel.AddLinkFromPeer(parentId, step.Id, condition); + this._workflowModel.AddNode(executor, parentId, actionType, callback); + this._workflowModel.AddLinkFromPeer(parentId, executor.Id, condition); } private static string RestartId(string actionId) => $"post_{actionId}"; @@ -466,7 +462,7 @@ private ExecutorIsh CreateStep(string actionId, string name, Action -/// Provides builder patterns for constructing a declarative process workflow. +/// %%% COMMENT +/// +internal delegate void ScopeCompletionHandler(); // %%% ACTION ??? + +/// +/// Provides dynamic model for constructing a declarative workflow. /// internal sealed class WorkflowModel { @@ -17,7 +23,7 @@ public WorkflowModel(ExecutorIsh rootStep) private ModelNode RootNode { get; } - private Dictionary Steps { get; } = []; + private Dictionary Nodes { get; } = []; private List Links { get; } = []; @@ -28,7 +34,7 @@ public int GetDepth(string? nodeId) return 0; } - if (!this.Steps.TryGetValue(nodeId, out ModelNode? sourceNode)) + if (!this.Nodes.TryGetValue(nodeId, out ModelNode? sourceNode)) { throw new UnknownActionException($"Unresolved step: {nodeId}."); } @@ -36,21 +42,21 @@ public int GetDepth(string? nodeId) return sourceNode.Depth; } - public void AddNode(ExecutorIsh step, string parentId, Type? actionType = null) + public void AddNode(ExecutorIsh step, string parentId, Type? actionType = null, ScopeCompletionHandler? completionHandler = null) { - if (!this.Steps.TryGetValue(parentId, out ModelNode? parentNode)) + if (!this.Nodes.TryGetValue(parentId, out ModelNode? parentNode)) { throw new UnknownActionException($"Unresolved parent for {step.Id}: {parentId}."); } - ModelNode stepNode = this.DefineNode(step, parentNode, actionType); + ModelNode stepNode = this.DefineNode(step, parentNode, actionType, completionHandler); parentNode.Children.Add(stepNode); } public void AddLinkFromPeer(string parentId, string targetId, Func? condition = null) { - if (!this.Steps.TryGetValue(parentId, out ModelNode? parentNode)) + if (!this.Nodes.TryGetValue(parentId, out ModelNode? parentNode)) { throw new UnknownActionException($"Unresolved step: {parentId}."); } @@ -67,7 +73,7 @@ public void AddLinkFromPeer(string parentId, string targetId, Func? condition = null) { - if (!this.Steps.TryGetValue(sourceId, out ModelNode? sourceNode)) + if (!this.Nodes.TryGetValue(sourceId, out ModelNode? sourceNode)) { throw new UnknownActionException($"Unresolved step: {sourceId}."); } @@ -75,21 +81,16 @@ public void AddLink(string sourceId, string targetId, Func? condi this.Links.Add(new ModelLink(sourceNode, targetId, condition)); } - //public void AddStop(string nodeId) // %%% REMOVE - //{ - // if (!this.Steps.TryGetValue(nodeId, out ProcessWorkflowNode? sourceNode)) - // { - // throw new UnknownActionException($"Unresolved node: {nodeId}."); - // } - - // sourceNode.Step.OnFunctionResult(KernelDelegateProcessStep.FunctionName).StopProcess(); - //} - public void ConnectNodes(WorkflowBuilder workflowBuilder) { + foreach (ModelNode node in this.Nodes.Values.ToImmutableArray()) + { + node.CompletionHandler?.Invoke(); + } + foreach (ModelLink link in this.Links) { - if (!this.Steps.TryGetValue(link.TargetId, out ModelNode? targetNode)) + if (!this.Nodes.TryGetValue(link.TargetId, out ModelNode? targetNode)) { throw new WorkflowBuilderException($"Unresolved target for {link.Source.Id}: {link.TargetId}."); } @@ -100,10 +101,11 @@ public void ConnectNodes(WorkflowBuilder workflowBuilder) } } - private ModelNode DefineNode(ExecutorIsh step, ModelNode? parentNode = null, Type? actionType = null) + private ModelNode DefineNode(ExecutorIsh step, ModelNode? parentNode = null, Type? actionType = null, ScopeCompletionHandler? completionHandler = null) { - ModelNode stepNode = new(step, parentNode, actionType); - this.Steps[stepNode.Id] = stepNode; + ModelNode stepNode = new(step, parentNode, actionType, completionHandler); + + this.Nodes[stepNode.Id] = stepNode; return stepNode; } @@ -117,7 +119,7 @@ private ModelNode DefineNode(ExecutorIsh step, ModelNode? parentNode = null, Typ while (itemId != null) { - if (!this.Steps.TryGetValue(itemId, out ModelNode? itemNode)) + if (!this.Nodes.TryGetValue(itemId, out ModelNode? itemNode)) { throw new UnknownActionException($"Unresolved child: {itemId}."); } @@ -133,7 +135,7 @@ private ModelNode DefineNode(ExecutorIsh step, ModelNode? parentNode = null, Typ return null; } - private sealed class ModelNode(ExecutorIsh step, ModelNode? parent = null, Type? actionType = null) + private sealed class ModelNode(ExecutorIsh step, ModelNode? parent = null, Type? actionType = null, ScopeCompletionHandler? completionHandler = null) { public string Id => step.Id; @@ -146,6 +148,8 @@ private sealed class ModelNode(ExecutorIsh step, ModelNode? parent = null, Type? public int Depth => this.Parent?.Depth + 1 ?? 0; public Type? ActionType => actionType; + + public ScopeCompletionHandler? CompletionHandler => completionHandler; } private sealed record class ModelLink(ModelNode Source, string TargetId, Func? Condition = null); From 142a95be10055b068c28faba372962f11faac345 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 11 Aug 2025 16:54:07 -0700 Subject: [PATCH 045/232] Executor checkpoint --- .../Workflows/Workflows_Declarative.cs | 10 +- .../Actions/AssignmentAction.cs | 41 --- .../Actions/ConditionGroupAction.cs | 26 -- .../Actions/EndConversationAction.cs | 21 -- .../Actions/ResetVariableAction.cs | 32 --- .../Actions/SendActivityAction.cs | 48 ---- .../Actions/SetTextVariableAction.cs | 28 --- .../Actions/SetVariableAction.cs | 36 --- .../DeclarativeWorkflowBuilder.cs | 7 +- ...ntext.cs => DeclarativeWorkflowContext.cs} | 27 +- .../AnswerQuestionWithAIExecutor.cs} | 21 +- .../ClearAllVariablesExecutor.cs} | 20 +- .../Execution/ConditionGroupExecutor.cs | 25 ++ .../Execution/DeclarativeWorkflowExecutor.cs | 1 - .../EditTableV2Executor.cs} | 18 +- .../Execution/EndConversationExecutor.cs | 16 ++ .../ForeachExecutor.cs} | 27 +- .../ParseValueExecutor.cs} | 26 +- .../Execution/ProcessAction.cs | 72 ------ .../Execution/ResetVariableExecutor.cs | 29 +++ .../Execution/SendActivityExecutor.cs | 38 +++ .../Execution/SetTextVariableExecutor.cs | 31 +++ .../Execution/SetVariableExecutor.cs | 32 +++ .../Execution/WorkflowActionExecutor.cs | 91 +++++++ .../Interpreter/WorkflowActionVisitor.cs | 237 +++++++----------- .../Interpreter/WorkflowExecutionContext.cs | 16 ++ .../Interpreter/WorkflowModel.cs | 25 +- 27 files changed, 468 insertions(+), 533 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AssignmentAction.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ConditionGroupAction.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EndConversationAction.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ResetVariableAction.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SendActivityAction.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetTextVariableAction.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetVariableAction.cs rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{WorkflowContext.cs => DeclarativeWorkflowContext.cs} (62%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{Actions/AnswerQuestionWithAIAction.cs => Execution/AnswerQuestionWithAIExecutor.cs} (55%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{Actions/ClearAllVariablesAction.cs => Execution/ClearAllVariablesExecutor.cs} (56%) create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ConditionGroupExecutor.cs rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{Actions/EditTableV2Action.cs => Execution/EditTableV2Executor.cs} (69%) create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EndConversationExecutor.cs rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{Actions/ForeachAction.cs => Execution/ForeachExecutor.cs} (64%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{Actions/ParseValueAction.cs => Execution/ParseValueExecutor.cs} (62%) delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ProcessAction.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ResetVariableExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetTextVariableExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetVariableExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowExecutionContext.cs diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index 681b711a55..895b4c738d 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -40,7 +40,7 @@ public async Task RunWorkflow(string fileName) Console.WriteLine("WORKFLOW INIT\n"); using StreamReader yamlReader = File.OpenText(@$"{nameof(Workflows)}\{fileName}.yaml"); - WorkflowContext workflowContext = + DeclarativeWorkflowContext workflowContext = new() { //HttpClient = customClient, @@ -57,9 +57,13 @@ public async Task RunWorkflow(string fileName) StreamingRun handle = await runner.StreamAsync(""); await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is ExecutorCompleteEvent executorComplete) + if (evt is ExecutorInvokeEvent executorInvoked) { - Console.WriteLine($"WORKFLOW EVENT: {executorComplete.Data}"); + Console.WriteLine($"!!! ENTER #{executorInvoked.ExecutorId}"); + } + else if (evt is ExecutorCompleteEvent executorComplete) + { + Console.WriteLine($"!!! EXIT #{executorComplete.ExecutorId}"); } } Console.WriteLine("\nWORKFLOW DONE"); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AssignmentAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AssignmentAction.cs deleted file mode 100644 index 95e0048965..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AssignmentAction.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using Microsoft.Agents.Workflows.Declarative.Execution; -using Microsoft.Agents.Workflows.Declarative.Extensions; -using Microsoft.Agents.Workflows.Declarative.PowerFx; -using Microsoft.Bot.ObjectModel; -using Microsoft.Extensions.Logging; -using Microsoft.PowerFx.Types; - -namespace Microsoft.Agents.Workflows.Declarative.Handlers; - -internal abstract class AssignmentAction : ProcessAction where TAction : DialogAction -{ - protected AssignmentAction(TAction model, PropertyPath assignmentTarget) - : base(model) - { - this.Target = assignmentTarget; - } - - public PropertyPath Target { get; } - - protected void AssignTarget(ProcessActionContext context, FormulaValue result) - { - context.Engine.SetScopedVariable(context.Scopes, this.Target, result); - string? resultValue = result.Format(); - string valuePosition = (resultValue?.IndexOf('\n') ?? -1) >= 0 ? Environment.NewLine : " "; - context.Logger.LogDebug( - """ - !!! ASSIGN {ActionName} [{ActionId}] - NAME: {TargetName} - VALUE:{ValuePosition}{Result} ({ResultType}) - """, - this.GetType().Name, - this.Id, - this.Target.Format(), - valuePosition, - result.Format(), - result.GetType().Name); - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ConditionGroupAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ConditionGroupAction.cs deleted file mode 100644 index 49fa7a3ee8..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ConditionGroupAction.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Execution; -using Microsoft.Bot.ObjectModel; - -namespace Microsoft.Agents.Workflows.Declarative.Handlers; - -internal sealed class ConditionGroupAction : ProcessAction -{ - public static class Steps - { - public static string End(string id) => $"{id}_{nameof(End)}"; - } - - public ConditionGroupAction(ConditionGroup model) - : base(model) - { - } - - protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EndConversationAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EndConversationAction.cs deleted file mode 100644 index 5d703a9992..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EndConversationAction.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Execution; -using Microsoft.Bot.ObjectModel; - -namespace Microsoft.Agents.Workflows.Declarative.Handlers; - -internal sealed class EndConversationAction : ProcessAction // %%% REMOVE ??? -{ - public EndConversationAction(EndConversation model) - : base(model) - { - } - - protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ResetVariableAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ResetVariableAction.cs deleted file mode 100644 index d24f183c58..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ResetVariableAction.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Execution; -using Microsoft.Agents.Workflows.Declarative.Extensions; -using Microsoft.Agents.Workflows.Declarative.PowerFx; -using Microsoft.Bot.ObjectModel; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.Workflows.Declarative.Handlers; - -internal sealed class ResetVariableAction : AssignmentAction -{ - public ResetVariableAction(ResetVariable model) - : base(model, Throw.IfNull(model.Variable, $"{nameof(model)}.{nameof(model.Variable)}")) - { - } - - protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) - { - context.Engine.ClearScopedVariable(context.Scopes, this.Target); - Console.WriteLine( // %%% LOGGER - $""" - !!! CLEAR {this.GetType().Name} [{this.Id}] - NAME: {this.Model.Variable!.Format()} - """); - - return Task.CompletedTask; - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SendActivityAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SendActivityAction.cs deleted file mode 100644 index 965798c944..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SendActivityAction.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Execution; -using Microsoft.Agents.Workflows.Declarative.Extensions; -using Microsoft.Bot.ObjectModel; - -namespace Microsoft.Agents.Workflows.Declarative.Handlers; - -internal sealed class SendActivityAction : ProcessAction -{ - private readonly TextWriter _activityWriter; - - public SendActivityAction(SendActivity source, TextWriter activityWriter) - : base(source) - { - this._activityWriter = activityWriter; - } - - protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) - { - Console.WriteLine($"\nACTIVITY: {this.Model.Activity?.GetType().Name ?? "Unknown"}"); // %%% LOGGER - - if (this.Model.Activity is MessageActivityTemplate messageActivity) - { - Console.ForegroundColor = ConsoleColor.Yellow; - try - { - if (!string.IsNullOrEmpty(messageActivity.Summary)) - { - this._activityWriter.WriteLine($"\t{messageActivity.Summary}"); - } - - string? activityText = context.Engine.Format(messageActivity.Text); - this._activityWriter.WriteLine(activityText + Environment.NewLine); - } - finally - { - Console.ResetColor(); - } - } - - return Task.CompletedTask; - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetTextVariableAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetTextVariableAction.cs deleted file mode 100644 index 7c29c7dfab..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetTextVariableAction.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Execution; -using Microsoft.Agents.Workflows.Declarative.Extensions; -using Microsoft.Bot.ObjectModel; -using Microsoft.PowerFx.Types; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.Workflows.Declarative.Handlers; - -internal sealed class SetTextVariableAction : AssignmentAction -{ - public SetTextVariableAction(SetTextVariable model) - : base(model, Throw.IfNull(model.Variable?.Path, $"{nameof(model)}.{nameof(model.Variable)}.{nameof(InitializablePropertyPath.Path)}")) - { - } - - protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) - { - FormulaValue result = FormulaValue.New(context.Engine.Format(this.Model.Value)); - - this.AssignTarget(context, result); - - return Task.CompletedTask; - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetVariableAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetVariableAction.cs deleted file mode 100644 index 95a68105f4..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/SetVariableAction.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Execution; -using Microsoft.Agents.Workflows.Declarative.Extensions; -using Microsoft.Bot.ObjectModel; -using Microsoft.Bot.ObjectModel.Abstractions; -using Microsoft.PowerFx.Types; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.Workflows.Declarative.Handlers; - -internal sealed class SetVariableAction : AssignmentAction -{ - public SetVariableAction(SetVariable model) - : base(model, Throw.IfNull(model.Variable?.Path, $"{nameof(model)}.{nameof(model.Variable)}.{nameof(InitializablePropertyPath.Path)}")) - { - } - - protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) - { - if (this.Model.Value is null) - { - this.AssignTarget(context, FormulaValue.NewBlank()); - } - else - { - EvaluationResult result = context.ExpressionEngine.GetValue(this.Model.Value, context.Scopes); // %%% FAILURE CASE (CATCH) - - this.AssignTarget(context, result.Value.ToFormulaValue()); - } - - return Task.CompletedTask; - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 964cfdd870..bee2fb4ccb 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -23,18 +23,19 @@ public static class DeclarativeWorkflowBuilder /// The identifier for the message. /// The hosting context for the workflow. /// The that corresponds with the YAML object model. - public static Workflow Build(TextReader yamlReader, string messageId, WorkflowContext? context = null) + public static Workflow Build(TextReader yamlReader, string messageId, DeclarativeWorkflowContext? context = null) { Console.WriteLine("@ PARSING YAML"); BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new InvalidOperationException("Unable to parse YAML content."); // %%% EXCEPTION TYPE string rootId = $"root_{GetRootId(rootElement)}"; Console.WriteLine("@ INITIALIZING BUILDER"); + context ??= DeclarativeWorkflowContext.Default; WorkflowScopes scopes = new(); - DeclarativeWorkflowExecutor rootExecutor = new(scopes, rootId); + DeclarativeWorkflowExecutor rootExecutor = new(scopes, rootId); // %%% DISPOSE Console.WriteLine("@ INTERPRETING WORKFLOW"); - WorkflowActionVisitor visitor = new(rootExecutor, context ?? new WorkflowContext(), scopes); // %%% DEFAULT CONTEXT (IMMUTABLE) + WorkflowActionVisitor visitor = new(rootExecutor, context, scopes); WorkflowElementWalker walker = new(rootElement, visitor); //Console.WriteLine("@ FINALIZING WORKFLOW"); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs similarity index 62% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowContext.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs index 492e403423..9078b1ad06 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs @@ -3,8 +3,12 @@ using System; using System.IO; using System.Net.Http; +using Azure.AI.Agents.Persistent; using Azure.Core; +using Azure.Core.Pipeline; using Azure.Identity; +using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -13,8 +17,10 @@ namespace Microsoft.Agents.Workflows.Declarative; /// /// Provides configuration and context for workflow execution. /// -public sealed class WorkflowContext +public sealed class DeclarativeWorkflowContext { + internal static DeclarativeWorkflowContext Default { get; } = new(); + /// /// Defines the endpoint for the Foundry project. /// @@ -49,4 +55,23 @@ public sealed class WorkflowContext /// Gets the used for activity output and diagnostics. /// public TextWriter ActivityChannel { get; init; } = Console.Out; // %%% REMOVE: For POC only + + internal WorkflowExecutionContext CreateActionContext(string rootId, WorkflowScopes scopes) => + new(RecalcEngineFactory.Create(scopes, this.MaximumExpressionLength), + scopes, + this.CreateClient, + this.LoggerFactory.CreateLogger(rootId)); + + private PersistentAgentsClient CreateClient() + { + PersistentAgentsAdministrationClientOptions clientOptions = new(); + + if (this.HttpClient is not null) + { + clientOptions.Transport = new HttpClientTransport(this.HttpClient); + //clientOptions.RetryPolicy = new RetryPolicy(maxRetries: 0); + } + + return new PersistentAgentsClient(this.ProjectEndpoint, this.ProjectCredentials, clientOptions); + } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AnswerQuestionWithAIAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs similarity index 55% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AnswerQuestionWithAIAction.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs index f04f872b76..ad0c7b64c6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/AnswerQuestionWithAIAction.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs @@ -4,9 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Azure.AI.Agents.Persistent; -using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Extensions; -using Microsoft.Agents.Workflows.Declarative.Handlers; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.Extensions.AI; @@ -14,25 +12,22 @@ using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; -namespace Microsoft.SemanticKernel.Process.Workflows.Actions; +namespace Microsoft.Agents.Workflows.Declarative.Execution; -internal sealed class AnswerQuestionWithAIAction : AssignmentAction +internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model) : WorkflowActionExecutor(model) { - public AnswerQuestionWithAIAction(AnswerQuestionWithAI model) - : base(model, Throw.IfNull(model.Variable?.Path, $"{nameof(model)}.{nameof(model.Variable)}.{nameof(InitializablePropertyPath.Path)}")) + protected override async ValueTask ExecuteAsync(CancellationToken cancellationToken) { - } + PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(this.Model.Variable)}"); - protected override async Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) - { - PersistentAgentsClient client = context.ClientFactory.Invoke(); + PersistentAgentsClient client = this.Context.ClientFactory.Invoke(); using NewPersistentAgentsChatClient chatClient = new(client, "asst_ueIjfGxAjsnZ4A61LlbjG9vJ"); ChatClientAgent agent = new(chatClient); string? userInput = null; if (this.Model.UserInput is not null) { - EvaluationResult result = context.ExpressionEngine.GetValue(this.Model.UserInput!, context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE + EvaluationResult result = this.Context.ExpressionEngine.GetValue(this.Model.UserInput!, this.Context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE userInput = result.Value; } @@ -40,7 +35,7 @@ protected override async Task HandleAsync(ProcessActionContext context, Cancella new( new ChatOptions() { - Instructions = context.Engine.Format(this.Model.AdditionalInstructions) ?? string.Empty, + Instructions = this.Context.Engine.Format(this.Model.AdditionalInstructions) ?? string.Empty, }); AgentRunResponse response = userInput != null ? @@ -48,6 +43,6 @@ await agent.RunAsync(userInput, thread: null, options, cancellationToken).Config await agent.RunAsync(thread: null, options, cancellationToken).ConfigureAwait(false); StringValue responseValue = FormulaValue.New(response.Messages.Last().ToString()); - this.AssignTarget(context, responseValue); + this.AssignTarget(this.Context, variablePath, responseValue); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ClearAllVariablesAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs similarity index 56% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ClearAllVariablesAction.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs index c53560e4fb..b1c2d6b1c2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ClearAllVariablesAction.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs @@ -2,30 +2,24 @@ using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; -namespace Microsoft.Agents.Workflows.Declarative.Handlers; +namespace Microsoft.Agents.Workflows.Declarative.Execution; -internal sealed class ClearAllVariablesAction : ProcessAction +internal sealed class ClearAllVariablesExecutor(ClearAllVariables model) : WorkflowActionExecutor(model) { - public ClearAllVariablesAction(ClearAllVariables source) - : base(source) + protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) { - } - - protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) - { - EvaluationResult result = context.ExpressionEngine.GetValue(this.Model.Variables, context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE + EvaluationResult result = this.Context.ExpressionEngine.GetValue(this.Model.Variables, this.Context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE - result.Value.Handle(new ScopeHandler(context)); + result.Value.Handle(new ScopeHandler(this.Context)); - return Task.CompletedTask; + return new ValueTask(); } - private sealed class ScopeHandler(ProcessActionContext context) : IEnumVariablesToClearHandler + private sealed class ScopeHandler(WorkflowExecutionContext context) : IEnumVariablesToClearHandler { public void HandleAllGlobalVariables() { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ConditionGroupExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ConditionGroupExecutor.cs new file mode 100644 index 0000000000..4cc5cba7c0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ConditionGroupExecutor.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative.Execution; + +internal sealed class ConditionGroupExecutor : WorkflowActionExecutor +{ + public static class Steps + { + public static string End(string id) => $"{id}_{nameof(End)}"; + } + + public ConditionGroupExecutor(ConditionGroup model) + : base(model) + { + } + + protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) + { + return new ValueTask(); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs index d9f3d586ca..80c1644255 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs @@ -14,7 +14,6 @@ internal sealed class DeclarativeWorkflowExecutor(WorkflowScopes scopes, string { public async ValueTask HandleAsync(string message, IWorkflowContext context) { - Console.WriteLine("!!! INIT WORKFLOW"); // %%% REMOVE scopes.Set("LastMessage", WorkflowScopeType.System, FormulaValue.New(message)); // %%% MAGIC CONST "LastMessage" //await context.AddEventAsync(new ExecutorInvokeEvent(this.Id, $"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}")).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EditTableV2Action.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs similarity index 69% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EditTableV2Action.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs index 834a1f69c2..c89c15a517 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/EditTableV2Action.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; @@ -11,27 +10,24 @@ using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.Workflows.Declarative.Handlers; +namespace Microsoft.Agents.Workflows.Declarative.Execution; -internal sealed class EditTableV2Action : AssignmentAction +internal sealed class EditTableV2Executor(EditTableV2 model) : WorkflowActionExecutor(model) { - public EditTableV2Action(EditTableV2 model) - : base(model, Throw.IfNull(model.ItemsVariable?.Path, $"{nameof(model)}.{nameof(model.ItemsVariable)}.{nameof(InitializablePropertyPath.Path)}")) + protected async override ValueTask ExecuteAsync(CancellationToken cancellationToken) { - } + PropertyPath variablePath = Throw.IfNull(this.Model.ItemsVariable?.Path, $"{nameof(this.Model)}.{nameof(this.Model.ItemsVariable)}"); - protected override async Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) - { - FormulaValue table = context.Scopes.Get(this.Target.VariableName!, WorkflowScopeType.Parse(this.Target.VariableScopeName)); + FormulaValue table = this.Context.Scopes.Get(variablePath.VariableName!, WorkflowScopeType.Parse(variablePath.VariableScopeName)); TableValue tableValue = (TableValue)table; EditTableOperation? changeType = this.Model.ChangeType; if (changeType is AddItemOperation addItemOperation) { - EvaluationResult result = context.ExpressionEngine.GetValue(addItemOperation.Value!, context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE + EvaluationResult result = this.Context.ExpressionEngine.GetValue(addItemOperation.Value!, this.Context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), result.Value.ToFormulaValue()); await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); - this.AssignTarget(context, tableValue); + this.AssignTarget(this.Context, variablePath, tableValue); } else if (changeType is ClearItemsOperation) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EndConversationExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EndConversationExecutor.cs new file mode 100644 index 0000000000..3c7d58075b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EndConversationExecutor.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative.Execution; + +internal sealed class EndConversationExecutor(EndConversation model) : WorkflowActionExecutor(model) +{ + protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) + { + // %%% DIAGNOSTICS / STATE MANAGEMENT ??? + return new ValueTask(); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ForeachAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ForeachExecutor.cs similarity index 64% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ForeachAction.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ForeachExecutor.cs index 1443f3c85e..55b006bf6e 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ForeachAction.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ForeachExecutor.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; @@ -11,9 +10,9 @@ using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.Workflows.Declarative.Handlers; +namespace Microsoft.Agents.Workflows.Declarative.Execution; -internal sealed class ForeachAction : ProcessAction +internal sealed class ForeachExecutor : WorkflowActionExecutor { public static class Steps { @@ -25,7 +24,7 @@ public static class Steps private int _index; private FormulaValue[] _values; - public ForeachAction(Foreach model) + public ForeachExecutor(Foreach model) : base(model) { this._values = []; @@ -33,7 +32,7 @@ public ForeachAction(Foreach model) public bool HasValue { get; private set; } - protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) { this._index = 0; @@ -41,18 +40,20 @@ protected override Task HandleAsync(ProcessActionContext context, CancellationTo { this._values = []; this.HasValue = false; - return Task.CompletedTask; + } + else + { + EvaluationResult result = this.Context.ExpressionEngine.GetValue(this.Model.Items, this.Context.Scopes); + TableDataValue tableValue = (TableDataValue)result.Value; // %%% CAST - TYPE ASSUMPTION (TableDataValue) + this._values = [.. tableValue.Values.Select(value => value.Properties.Values.First().ToFormulaValue())]; } - EvaluationResult result = context.ExpressionEngine.GetValue(this.Model.Items, context.Scopes); - TableDataValue tableValue = (TableDataValue)result.Value; // %%% CAST - TYPE ASSUMPTION (TableDataValue) - this._values = [.. tableValue.Values.Select(value => value.Properties.Values.First().ToFormulaValue())]; - return Task.CompletedTask; + return new ValueTask(); } - public void TakeNext(ProcessActionContext context) + public void TakeNext(WorkflowExecutionContext context) { - if (this.HasValue = (this._index < this._values.Length)) + if (this.HasValue = this._index < this._values.Length) { FormulaValue value = this._values[this._index]; @@ -67,7 +68,7 @@ public void TakeNext(ProcessActionContext context) } } - public void Reset(ProcessActionContext context) + public void Reset(WorkflowExecutionContext context) { context.Engine.ClearScopedVariable(context.Scopes, Throw.IfNull(this.Model.Value)); if (this.Model.Index is not null) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ParseValueAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs similarity index 62% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ParseValueAction.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs index 97ce7f2e86..dd70fd621d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Actions/ParseValueAction.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs @@ -1,31 +1,25 @@ -// Copyright (c) Microsoft. All rights reserved. + +// Copyright (c) Microsoft. All rights reserved. using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.Workflows.Declarative.Handlers; +namespace Microsoft.Agents.Workflows.Declarative.Execution; -internal sealed class ParseValueAction : AssignmentAction +internal sealed class ParseValueExecutor(ParseValue model) : + WorkflowActionExecutor(model) { - public ParseValueAction(ParseValue model) - : base(model, Throw.IfNull(model.Variable?.Path, $"{nameof(model)}.{nameof(model.Variable)}.{nameof(InitializablePropertyPath.Path)}")) + protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) { - if (this.Model.Value is null) - { - throw new InvalidActionException($"{nameof(ParseValue)} must define {nameof(ParseValue.Value)}"); - } - } + PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); - protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) - { - EvaluationResult result = context.ExpressionEngine.GetValue(this.Model.Value!, context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE + EvaluationResult result = this.Context.ExpressionEngine.GetValue(this.Model.Value!, this.Context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE FormulaValue? parsedResult = null; @@ -54,9 +48,9 @@ protected override Task HandleAsync(ProcessActionContext context, CancellationTo throw new ProcessActionException($"Unable to parse {result.Value.GetType().Name}"); } - this.AssignTarget(context, parsedResult); + this.AssignTarget(this.Context, variablePath, parsedResult); - return Task.CompletedTask; + return new ValueTask(); } private static RecordValue ParseRecord(RecordDataType recordType, string rawText) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ProcessAction.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ProcessAction.cs deleted file mode 100644 index 07a078b5e2..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ProcessAction.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading; -using System.Threading.Tasks; -using Azure.AI.Agents.Persistent; -using Microsoft.Agents.Workflows.Declarative.Extensions; -using Microsoft.Agents.Workflows.Declarative.PowerFx; -using Microsoft.Bot.ObjectModel; -using Microsoft.Extensions.Logging; -using Microsoft.PowerFx; - -namespace Microsoft.Agents.Workflows.Declarative.Execution; - -internal sealed record class ProcessActionContext(RecalcEngine Engine, WorkflowScopes Scopes, Func ClientFactory, ILogger Logger) -{ - private WorkflowExpressionEngine? _expressionEngine; - - public WorkflowExpressionEngine ExpressionEngine => this._expressionEngine ??= new WorkflowExpressionEngine(this.Engine); -} - -internal abstract class ProcessAction(TAction model) : ProcessAction(model) - where TAction : DialogAction -{ - public new TAction Model => (TAction)base.Model; -} - -internal abstract class ProcessAction -{ - public const string RootActionId = "(root)"; - - private string? _parentId; - - public string Id => this.Model.Id.Value; - - public string ParentId => this._parentId ??= this.Model.GetParentId() ?? RootActionId; - - public DialogAction Model { get; } - - protected ProcessAction(DialogAction model) - { - if (!model.HasRequiredProperties) - { - throw new InvalidActionException($"Action {this.GetType().Name} [{model.Id}]"); - } - - this.Model = model; - } - - public async Task ExecuteAsync(ProcessActionContext context, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - // Execute each action in the current context - await this.HandleAsync(context, cancellationToken).ConfigureAwait(false); - } - catch (ProcessWorkflowException exception) - { - context.Logger.LogError(exception, "*** ACTION [{Id}] ERROR - {TypeName}\n{Message}", this.Id, this.GetType().Name, exception.Message); - throw; - } - catch (Exception exception) - { - context.Logger.LogError(exception, "*** ACTION [{Id}] ERROR - {TypeName}\n{Message}", this.Id, this.GetType().Name, exception.Message); - throw new ProcessWorkflowException($"Unexpected failure executing action #{this.Id} [{this.GetType().Name}]", exception); - } - } - - protected abstract Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken); -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ResetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ResetVariableExecutor.cs new file mode 100644 index 0000000000..65dad3e58a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ResetVariableExecutor.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.Execution; + +internal sealed class ResetVariableExecutor(ResetVariable model) : + WorkflowActionExecutor(model) +{ + protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) + { + PropertyPath variablePath = Throw.IfNull(this.Model.Variable, $"{nameof(this.Model)}.{nameof(model.Variable)}"); + + this.Context.Engine.ClearScopedVariable(this.Context.Scopes, this.Model.Variable); + Console.WriteLine( // %%% LOGGER + $""" + !!! CLEAR {this.GetType().Name} [{this.Id}] + NAME: {this.Model.Variable!.Format()} + """); + + return new ValueTask(); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs new file mode 100644 index 0000000000..352dc96fc5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative.Execution; + +internal sealed class SendActivityExecutor(SendActivity model, TextWriter activityWriter) : + WorkflowActionExecutor(model) +{ + protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) + { + if (this.Model.Activity is MessageActivityTemplate messageActivity) + { + Console.ForegroundColor = ConsoleColor.Yellow; + try + { + if (!string.IsNullOrEmpty(messageActivity.Summary)) + { + activityWriter.WriteLine($"\t{messageActivity.Summary}"); + } + + string? activityText = this.Context.Engine.Format(messageActivity.Text)?.Trim(); + activityWriter.WriteLine(activityText + Environment.NewLine); + } + finally + { + Console.ResetColor(); + } + } + + return new ValueTask(); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetTextVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetTextVariableExecutor.cs new file mode 100644 index 0000000000..021f601fa8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetTextVariableExecutor.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.Execution; + +internal sealed class SetTextVariableExecutor(SetTextVariable model) : WorkflowActionExecutor(model) +{ + protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) + { + PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); + + if (this.Model.Value is null) + { + this.AssignTarget(this.Context, variablePath, FormulaValue.NewBlank()); + } + else + { + FormulaValue result = FormulaValue.New(this.Context.Engine.Format(this.Model.Value)); + + this.AssignTarget(this.Context, variablePath, result); + } + + return new ValueTask(); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetVariableExecutor.cs new file mode 100644 index 0000000000..67ec55a739 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetVariableExecutor.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.Execution; + +internal sealed class SetVariableExecutor(SetVariable model) : WorkflowActionExecutor(model) +{ + protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) + { + PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); + + if (this.Model.Value is null) + { + this.AssignTarget(this.Context, variablePath, FormulaValue.NewBlank()); + } + else + { + EvaluationResult result = this.Context.ExpressionEngine.GetValue(this.Model.Value, this.Context.Scopes); // %%% FAILURE CASE (CATCH) + + this.AssignTarget(this.Context, variablePath, result.Value.ToFormulaValue()); + } + + return new ValueTask(); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs new file mode 100644 index 0000000000..081471078f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.Logging; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.Execution; + +internal abstract class WorkflowActionExecutor(TAction model) : + WorkflowActionExecutor(model) + where TAction : DialogAction +{ + public new TAction Model => (TAction)base.Model; +} + +internal abstract class WorkflowActionExecutor(DialogAction model) : + Executor(model.Id.Value), + IMessageHandler +{ + public const string RootActionId = "(root)"; + + private string? _parentId; + private WorkflowExecutionContext? _context; + + public string ParentId => this._parentId ??= this.Model.GetParentId() ?? RootActionId; + + public DialogAction Model { get; } = model; + + protected WorkflowExecutionContext Context => + this._context ?? + throw new InvalidOperationException("Context not assigned"); // %%% EXCEPTION TYPE + + internal void Attach(WorkflowExecutionContext executionContext) + { + this._context = executionContext; + } + + /// + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + if (this.Model.Disabled) // %%% VALIDATE + { + Console.WriteLine($"!!! DISABLED {this.GetType().Name} [{this.Id}]"); // %%% REMOVE + return; + } + + try + { + await this.ExecuteAsync(cancellationToken: default).ConfigureAwait(false); // %%% CONTEXT + + await context.SendMessageAsync($"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}").ConfigureAwait(false); + } + catch (ProcessActionException) + { + Console.WriteLine($"*** STEP [{this.Id}] ERROR - Action failure"); // %%% LOGGER + throw; + } + catch (Exception exception) + { + Console.WriteLine($"*** STEP [{this.Id}] ERROR - {exception.GetType().Name}\n{exception.Message}"); // %%% LOGGER + throw; + } + } + + protected abstract ValueTask ExecuteAsync(CancellationToken cancellationToken = default); + + protected void AssignTarget(WorkflowExecutionContext context, PropertyPath targetPath, FormulaValue result) + { + context.Engine.SetScopedVariable(context.Scopes, targetPath, result); + string? resultValue = result.Format(); + string valuePosition = (resultValue?.IndexOf('\n') ?? -1) >= 0 ? Environment.NewLine : " "; + context.Logger.LogDebug( + """ + !!! ASSIGN {ActionName} [{ActionId}] + NAME: {TargetName} + VALUE:{ValuePosition}{Result} ({ResultType}) + """, + this.GetType().Name, + this.Id, + targetPath.Format(), + valuePosition, + result.Format(), + result.GetType().Name); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 29cfdbde07..8c43e81b6b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -2,16 +2,11 @@ using System; using System.Threading.Tasks; -using Azure.AI.Agents.Persistent; -using Azure.Core.Pipeline; using Microsoft.Agents.Workflows.Core; using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Extensions; -using Microsoft.Agents.Workflows.Declarative.Handlers; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; -using Microsoft.PowerFx; -using Microsoft.SemanticKernel.Process.Workflows.Actions; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Declarative.Interpreter; @@ -20,20 +15,25 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor { private readonly WorkflowBuilder _workflowBuilder; private readonly WorkflowModel _workflowModel; - private readonly WorkflowContext _context; + private readonly DeclarativeWorkflowContext _workflowContext; private readonly WorkflowScopes _scopes; + private readonly WorkflowExecutionContext _executionContext; public WorkflowActionVisitor( ExecutorIsh rootAction, - WorkflowContext context, + DeclarativeWorkflowContext workflowContext, WorkflowScopes scopes) { this._workflowModel = new WorkflowModel(rootAction); this._workflowBuilder = new WorkflowBuilder(rootAction); - this._context = context; + this._workflowContext = workflowContext; this._scopes = scopes; + + this._executionContext = workflowContext.CreateActionContext(rootAction.Id, scopes); } + public bool HasUnsupportedActions { get; private set; } + public Workflow Complete() { // Process the cached links @@ -45,7 +45,7 @@ public Workflow Complete() protected override void Visit(ActionScope item) { - this.Trace(item, isSkipped: false); + this.Trace(item); string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); // %%% NULL PARENT CASE ??? if (item.Id.Equals(parentId)) @@ -58,7 +58,7 @@ protected override void Visit(ActionScope item) protected override void Visit(ConditionGroup item) { - this.Trace(item, isSkipped: false); + this.Trace(item); //ConditionGroupAction action = new(item); //this.ContinueWith(action); @@ -111,7 +111,7 @@ public override void VisitConditionItem(ConditionItem item) protected override void Visit(GotoAction item) { - this.Trace(item, isSkipped: false); + this.Trace(item); string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); // %%% NULL PARENT CASE ??? this.ContinueWith(this.CreateStep(item.Id.Value, nameof(GotoAction)), parentId); @@ -121,28 +121,28 @@ protected override void Visit(GotoAction item) protected override void Visit(Foreach item) { - this.Trace(item, isSkipped: false); + this.Trace(item); - ForeachAction action = new(item); - string loopId = ForeachAction.Steps.Next(action.Id); + ForeachExecutor action = new(item); + string loopId = ForeachExecutor.Steps.Next(action.Id); this.ContinueWith(action, callback: CompletionHandler); string restartId = this.RestartFrom(action); - this.ContinueWith(this.CreateStep(loopId, $"{nameof(ForeachAction)}_Next", action.TakeNext), action.Id); + this.ContinueWith(this.CreateStep(loopId, $"{nameof(ForeachExecutor)}_Next", action.TakeNext), action.Id); this._workflowModel.AddLink(loopId, restartId, (_) => !action.HasValue); - this.ContinueWith(this.CreateStep(ForeachAction.Steps.Start(action.Id), $"{nameof(ForeachAction)}_Start"), action.Id, (_) => action.HasValue); + this.ContinueWith(this.CreateStep(ForeachExecutor.Steps.Start(action.Id), $"{nameof(ForeachExecutor)}_Start"), action.Id, (_) => action.HasValue); void CompletionHandler() { - string completionId = ForeachAction.Steps.End(action.Id); - this.ContinueWith(this.CreateStep(completionId, $"{nameof(ForeachAction)}_End"), action.Id); + string completionId = ForeachExecutor.Steps.End(action.Id); + this.ContinueWith(this.CreateStep(completionId, $"{nameof(ForeachExecutor)}_End"), action.Id); this._workflowModel.AddLink(completionId, loopId); } } protected override void Visit(BreakLoop item) // %%% SUPPORT { - this.Trace(item, isSkipped: false); + this.Trace(item); - string? loopId = this._workflowModel.LocateParent(item.GetParentId()); + string? loopId = this._workflowModel.LocateParent(item.GetParentId()); if (loopId is not null) { string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); // %%% NULL PARENT CASE ??? @@ -154,288 +154,290 @@ protected override void Visit(BreakLoop item) // %%% SUPPORT protected override void Visit(ContinueLoop item) // %%% SUPPORT { - this.Trace(item, isSkipped: false); + this.Trace(item); - string? loopId = this._workflowModel.LocateParent(item.GetParentId()); + string? loopId = this._workflowModel.LocateParent(item.GetParentId()); if (loopId is not null) { string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); // %%% NULL PARENT CASE ??? this.ContinueWith(this.CreateStep(item.Id.Value, nameof(ContinueLoop)), parentId); - this._workflowModel.AddLink(item.Id.Value, ForeachAction.Steps.Next(loopId)); + this._workflowModel.AddLink(item.Id.Value, ForeachExecutor.Steps.Next(loopId)); this.RestartFrom(item.Id.Value, nameof(ContinueLoop), parentId); } } protected override void Visit(EndConversation item) { - this.Trace(item, isSkipped: false); + this.Trace(item); - EndConversationAction action = new(item); + EndConversationExecutor action = new(item); this.ContinueWith(action); this.RestartFrom(action); } protected override void Visit(AnswerQuestionWithAI item) { - this.Trace(item, isSkipped: false); + this.Trace(item); - this.ContinueWith(new AnswerQuestionWithAIAction(item)); + this.ContinueWith(new AnswerQuestionWithAIExecutor(item)); } protected override void Visit(SetVariable item) { - this.Trace(item, isSkipped: false); + this.Trace(item); - this.ContinueWith(new SetVariableAction(item)); + this.ContinueWith(new SetVariableExecutor(item)); } protected override void Visit(SetTextVariable item) { - this.Trace(item, isSkipped: false); + this.Trace(item); - this.ContinueWith(new SetTextVariableAction(item)); + this.ContinueWith(new SetTextVariableExecutor(item)); } protected override void Visit(ClearAllVariables item) { - this.Trace(item, isSkipped: false); + this.Trace(item); - this.ContinueWith(new ClearAllVariablesAction(item)); + this.ContinueWith(new ClearAllVariablesExecutor(item)); } protected override void Visit(ResetVariable item) { - this.Trace(item, isSkipped: false); + this.Trace(item); - this.ContinueWith(new ResetVariableAction(item)); + this.ContinueWith(new ResetVariableExecutor(item)); } protected override void Visit(EditTable item) { - this.Trace(item); + this.Trace(item); // %%% TODO } protected override void Visit(EditTableV2 item) { - this.Trace(item, isSkipped: false); + this.Trace(item); - this.ContinueWith(new EditTableV2Action(item)); + this.ContinueWith(new EditTableV2Executor(item)); } protected override void Visit(ParseValue item) { - this.Trace(item, isSkipped: false); + this.Trace(item); - this.ContinueWith(new ParseValueAction(item)); + this.ContinueWith(new ParseValueExecutor(item)); } protected override void Visit(SendActivity item) { - this.Trace(item, isSkipped: false); + this.Trace(item); - this.ContinueWith(new SendActivityAction(item, this._context.ActivityChannel)); + this.ContinueWith(new SendActivityExecutor(item, this._workflowContext.ActivityChannel)); } #region Not supported protected override void Visit(DeleteActivity item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(GetActivityMembers item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(UpdateActivity item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(ActivateExternalTrigger item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(DisableTrigger item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(WaitForConnectorTrigger item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(InvokeConnectorAction item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(InvokeCustomModelAction item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(InvokeFlowAction item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(InvokeAIBuilderModelAction item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(InvokeSkillAction item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(AdaptiveCardPrompt item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(Question item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(CSATQuestion item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(OAuthInput item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(BeginDialog item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(UnknownDialogAction item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(EndDialog item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(RepeatDialog item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(ReplaceDialog item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(CancelAllDialogs item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(CancelDialog item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(EmitEvent item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(GetConversationMembers item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(HttpRequestAction item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(RecognizeIntent item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(TransferConversation item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(TransferConversationV2 item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(SignOutUser item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(LogCustomTelemetryEvent item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(DisconnectedNodeContainer item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(CreateSearchQuery item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(SearchKnowledgeSources item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(SearchAndSummarizeWithCustomModel item) { - this.Trace(item); + this.NotSupported(item); } protected override void Visit(SearchAndSummarizeContent item) { - this.Trace(item); + this.NotSupported(item); } #endregion private void ContinueWith( - ProcessAction action, + WorkflowActionExecutor executor, Func? condition = null, - ScopeCompletionHandler? callback = null) => - this.ContinueWith(this.DefineActionExecutor(action), action.ParentId, condition, action.GetType(), callback); + ScopeCompletionHandler? callback = null) + { + executor.Attach(this._executionContext); + this.ContinueWith(executor, executor.ParentId, condition, callback); + } private void ContinueWith( - ExecutorIsh executor, + ExecutorBase executor, string parentId, Func? condition = null, - Type? actionType = null, ScopeCompletionHandler? callback = null) { - this._workflowModel.AddNode(executor, parentId, actionType, callback); + this._workflowModel.AddNode(executor, parentId, callback); this._workflowModel.AddLinkFromPeer(parentId, executor.Id, condition); } private static string RestartId(string actionId) => $"post_{actionId}"; - private string RestartFrom(ProcessAction action) => - this.RestartFrom(action.Id, action.GetType().Name, action.ParentId); + private string RestartFrom(WorkflowActionExecutor executor) => + this.RestartFrom(executor.Id, executor.GetType().Name, executor.ParentId); private string RestartFrom(string actionId, string name, string parentId) { @@ -444,14 +446,13 @@ private string RestartFrom(string actionId, string name, string parentId) return restartId; } - private ExecutorIsh CreateStep(string actionId, string name, Action? stepAction = null) + private DeclarativeActionExecutor CreateStep(string actionId, string name, Action? stepAction = null) { DeclarativeActionExecutor stepExecutor = new(actionId, () => { - Console.WriteLine($"!!! STEP {name} [{actionId}]"); // %%% REMOVE - stepAction?.Invoke(this.CreateActionContext(actionId)); + stepAction?.Invoke(this._executionContext); // %%% FIX return new ValueTask(); }); @@ -460,75 +461,25 @@ private ExecutorIsh CreateStep(string actionId, string name, Action - { - Console.WriteLine($"!!! STEP {action.GetType().Name} [{action.Id}]"); // %%% REMOVE - - if (action.Model.Disabled) // %%% VALIDATE - { - Console.WriteLine($"!!! DISABLED {action.GetType().Name} [{action.Id}]"); // %%% REMOVE - return; - } - - try - { - await action.ExecuteAsync( - this.CreateActionContext(action.Id), - cancellationToken: default).ConfigureAwait(false); // %%% CANCELTOKEN - } - catch (ProcessActionException) - { - Console.WriteLine($"*** STEP [{action.Id}] ERROR - Action failure"); // %%% LOGGER - throw; - } - catch (Exception exception) - { - Console.WriteLine($"*** STEP [{action.Id}] ERROR - {exception.GetType().Name}\n{exception.Message}"); // %%% LOGGER - throw; - } - }); - - //this._workflowBuilder.BindExecutor(stepExecutor); - - return stepExecutor; - } - - private ProcessActionContext CreateActionContext(string actionId) => new(this.CreateEngine(), this._scopes, this.CreateClient, this._context.LoggerFactory.CreateLogger(actionId)); - - private PersistentAgentsClient CreateClient() + private void NotSupported(DialogAction item) { - PersistentAgentsAdministrationClientOptions clientOptions = new(); - - if (this._context.HttpClient is not null) - { - clientOptions.Transport = new HttpClientTransport(this._context.HttpClient); - //clientOptions.RetryPolicy = new RetryPolicy(maxRetries: 0); - } - - return new PersistentAgentsClient(this._context.ProjectEndpoint, this._context.ProjectCredentials, clientOptions); + Console.WriteLine($"> UNKNOWN: {new string('\t', this._workflowModel.GetDepth(item.GetParentId()))}{FormatItem(item)} => {FormatParent(item)}"); // %%% LOGGER + this.HasUnsupportedActions = true; } - private RecalcEngine CreateEngine() => RecalcEngineFactory.Create(this._scopes, this._context.MaximumExpressionLength); - private void Trace(BotElement item) { Console.WriteLine($"> VISIT: {new string('\t', this._workflowModel.GetDepth(item.GetParentId()))}{FormatItem(item)} => {FormatParent(item)}"); // %%% LOGGER } - private void Trace(DialogAction item, bool isSkipped = true) + private void Trace(DialogAction item) { string? parentId = item.GetParentId(); if (item.Id.Equals(parentId ?? string.Empty)) { parentId = $"root_{parentId}"; } - Console.WriteLine($"> {(isSkipped ? "EMPTY" : "VISIT")}: {new string('\t', this._workflowModel.GetDepth(parentId))}{FormatItem(item)} => {FormatParent(item)}"); // %%% LOGGER + Console.WriteLine($"> VISIT: {new string('\t', this._workflowModel.GetDepth(parentId))}{FormatItem(item)} => {FormatParent(item)}"); // %%% LOGGER } private static string FormatItem(BotElement element) => $"{element.GetType().Name} ({element.GetId()})"; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowExecutionContext.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowExecutionContext.cs new file mode 100644 index 0000000000..f91cd0d8d8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowExecutionContext.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Azure.AI.Agents.Persistent; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Extensions.Logging; +using Microsoft.PowerFx; + +namespace Microsoft.Agents.Workflows.Declarative.Execution; + +internal sealed record class WorkflowExecutionContext(RecalcEngine Engine, WorkflowScopes Scopes, Func ClientFactory, ILogger Logger) +{ + private WorkflowExpressionEngine? _expressionEngine; + + public WorkflowExpressionEngine ExpressionEngine => this._expressionEngine ??= new WorkflowExpressionEngine(this.Engine); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs index 09b4076fae..75a3f2fee0 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using Microsoft.Agents.Workflows.Core; namespace Microsoft.Agents.Workflows.Declarative.Interpreter; @@ -42,14 +43,14 @@ public int GetDepth(string? nodeId) return sourceNode.Depth; } - public void AddNode(ExecutorIsh step, string parentId, Type? actionType = null, ScopeCompletionHandler? completionHandler = null) + public void AddNode(ExecutorBase executor, string parentId, ScopeCompletionHandler? completionHandler = null) { if (!this.Nodes.TryGetValue(parentId, out ModelNode? parentNode)) { - throw new UnknownActionException($"Unresolved parent for {step.Id}: {parentId}."); + throw new UnknownActionException($"Unresolved parent for {executor.Id}: {parentId}."); } - ModelNode stepNode = this.DefineNode(step, parentNode, actionType, completionHandler); + ModelNode stepNode = this.DefineNode(executor, parentNode, executor.GetType(), completionHandler); parentNode.Children.Add(stepNode); } @@ -97,13 +98,13 @@ public void ConnectNodes(WorkflowBuilder workflowBuilder) Console.WriteLine($"> CONNECT: {link.Source.Id} => {link.TargetId}"); // %%% LOGGER - workflowBuilder.AddEdge(link.Source.Step, targetNode.Step, link.Condition); + workflowBuilder.AddEdge(link.Source.Executor, targetNode.Executor, link.Condition); } } - private ModelNode DefineNode(ExecutorIsh step, ModelNode? parentNode = null, Type? actionType = null, ScopeCompletionHandler? completionHandler = null) + private ModelNode DefineNode(ExecutorIsh executor, ModelNode? parentNode = null, Type? executorType = null, ScopeCompletionHandler? completionHandler = null) { - ModelNode stepNode = new(step, parentNode, actionType, completionHandler); + ModelNode stepNode = new(executor, parentNode, executorType, completionHandler); this.Nodes[stepNode.Id] = stepNode; @@ -124,7 +125,7 @@ private ModelNode DefineNode(ExecutorIsh step, ModelNode? parentNode = null, Typ throw new UnknownActionException($"Unresolved child: {itemId}."); } - if (itemNode.ActionType == typeof(TAction)) + if (itemNode.ExecutorType == typeof(TAction)) { return itemNode.Id; } @@ -135,11 +136,13 @@ private ModelNode DefineNode(ExecutorIsh step, ModelNode? parentNode = null, Typ return null; } - private sealed class ModelNode(ExecutorIsh step, ModelNode? parent = null, Type? actionType = null, ScopeCompletionHandler? completionHandler = null) + private sealed class ModelNode(ExecutorIsh executor, ModelNode? parent = null, Type? executorType = null, ScopeCompletionHandler? completionHandler = null) { - public string Id => step.Id; + public string Id => executor.Id; - public ExecutorIsh Step => step; + public ExecutorIsh Executor => executor; + + public Type? ExecutorType => executorType; public ModelNode? Parent { get; } = parent; @@ -147,8 +150,6 @@ private sealed class ModelNode(ExecutorIsh step, ModelNode? parent = null, Type? public int Depth => this.Parent?.Depth + 1 ?? 0; - public Type? ActionType => actionType; - public ScopeCompletionHandler? CompletionHandler => completionHandler; } From 94dfd5d698c0ac79c1b53f4612e89e16b3ffe4b6 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 11 Aug 2025 21:09:08 -0700 Subject: [PATCH 046/232] Conditional checkpoint --- .../Workflows/Workflows_Declarative.cs | 3 +- .../Workflows/testCondition0.yaml | 70 ++++++++++ ...testCondition.yaml => testCondition1.yaml} | 36 +++-- .../Execution/WorkflowActionExecutor.cs | 6 +- .../Interpreter/WorkflowActionVisitor.cs | 125 ++++++++++-------- .../Interpreter/WorkflowModel.cs | 9 +- 6 files changed, 176 insertions(+), 73 deletions(-) create mode 100644 dotnet/samples/GettingStarted/Workflows/testCondition0.yaml rename dotnet/samples/GettingStarted/Workflows/{testCondition.yaml => testCondition1.yaml} (65%) diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index 895b4c738d..d9d85fd91b 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -22,7 +22,8 @@ public class Workflows_Declarative(ITestOutputHelper output) : OrchestrationSamp [InlineData("deepResearch")] [InlineData("demo250729")] [InlineData("testChat")] - [InlineData("testCondition")] + [InlineData("testCondition0")] + [InlineData("testCondition1")] [InlineData("testEnd")] [InlineData("testExpression")] [InlineData("testGoto")] diff --git a/dotnet/samples/GettingStarted/Workflows/testCondition0.yaml b/dotnet/samples/GettingStarted/Workflows/testCondition0.yaml new file mode 100644 index 0000000000..dd5c9f7387 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/testCondition0.yaml @@ -0,0 +1,70 @@ +kind: AdaptiveDialog +beginDialog: + + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + + - kind: SetVariable + id: setVariable_start + displayName: Invocation count + variable: Topic.Count + value: =0 + + - kind: SetVariable + id: setVariable_loop + displayName: Invocation count + variable: Topic.Count + value: =Topic.Count + 11 + + - kind: SendActivity + id: sendActivity_loop + activity: Looping (x{Topic.Count}) + + - kind: ConditionGroup + id: conditionGroup_test + conditions: + - id: conditionItem_p1 + condition: =Topic.Count < 3 + + - id: conditionItem_p2 + condition: =Topic.Count >= 3 && Topic.Count < 6 + actions: + - kind: SendActivity + id: sendActivity_p2 + activity: 3 <= ({Topic.Count}) < 6 + + - kind: GotoAction + id: goto_p2 + actionId: sendActivity_done + + - id: conditionItem_p3 + condition: =Topic.Count >= 6 && Topic.Count < 9 + actions: + - kind: SendActivity + id: sendActivity_p3 + activity: 6 <= ({Topic.Count}) < 9 + + - id: conditionItem_p4 + condition: =Topic.Count >= 9 + actions: + - kind: SendActivity + id: sendActivity_p4 + activity: ({Topic.Count}) >= 9 + + # elseActions: + # - kind: SendActivity + # id: sendActivity_rOk31p + # activity: All done (x{Topic.Count}) + + # - kind: EndConversation + # id: end_SVoNSV + + - kind: SendActivity + id: sendActivity_extra + activity: Fall-through... + + - kind: SendActivity + id: sendActivity_done + activity: Complete! diff --git a/dotnet/samples/GettingStarted/Workflows/testCondition.yaml b/dotnet/samples/GettingStarted/Workflows/testCondition1.yaml similarity index 65% rename from dotnet/samples/GettingStarted/Workflows/testCondition.yaml rename to dotnet/samples/GettingStarted/Workflows/testCondition1.yaml index 8fbb3d703d..958f8d1aa9 100644 --- a/dotnet/samples/GettingStarted/Workflows/testCondition.yaml +++ b/dotnet/samples/GettingStarted/Workflows/testCondition1.yaml @@ -31,17 +31,15 @@ beginDialog: - kind: ConditionGroup id: conditionGroup_mVIecC conditions: - - id: conditionItem_fj432c - condition: =Topic.Count < 5 - displayName: Just started + - id: conditionItem_p1 + condition: =Topic.Count < 3 actions: - kind: SendActivity id: sendActivity_Pkkmpq activity: Just started (x{Topic.Count}) - - id: conditionItem_yiqund - condition: =Topic.Count > 5 && Topic.Count < 10 - displayName: Making progress + - id: conditionItem_p2 + condition: =Topic.Count >= 3 && Topic.Count < 6 actions: - kind: SendActivity id: sendActivity_aLM1o3 @@ -51,13 +49,27 @@ beginDialog: id: goto_LzfJ8u actionId: setVariable_a9f4o2 - elseActions: - - kind: SendActivity - id: sendActivity_rOk31p - activity: All done (x{Topic.Count}) + - id: conditionItem_p3 + condition: =Topic.Count >= 6 + actions: + - kind: SendActivity + id: sendActivity_rOk31p + activity: All done (x{Topic.Count}) + + - kind: EndConversation + id: end_SVoNSV + + # - kind: GotoAction + # id: goto_HAX + # actionId: sendActivity_ohn03s + + # elseActions: + # - kind: SendActivity + # id: sendActivity_rOk31p + # activity: All done (x{Topic.Count}) - - kind: EndConversation - id: end_SVoNSV + # - kind: EndConversation + # id: end_SVoNSV - kind: SendActivity id: sendActivity_fJsbRz diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs index 081471078f..42f2fb098d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs @@ -44,15 +44,15 @@ internal void Attach(WorkflowExecutionContext executionContext) /// public async ValueTask HandleAsync(string message, IWorkflowContext context) { - if (this.Model.Disabled) // %%% VALIDATE + if (this.Model.Disabled) { - Console.WriteLine($"!!! DISABLED {this.GetType().Name} [{this.Id}]"); // %%% REMOVE + Console.WriteLine($"!!! DISABLED {this.GetType().Name} [{this.Id}]"); // %%% LOGGER return; } try { - await this.ExecuteAsync(cancellationToken: default).ConfigureAwait(false); // %%% CONTEXT + await this.ExecuteAsync(cancellationToken: default).ConfigureAwait(false); await context.SendMessageAsync($"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}").ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 8c43e81b6b..7be3c8d56e 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; using Microsoft.Agents.Workflows.Declarative.Execution; @@ -47,73 +48,88 @@ protected override void Visit(ActionScope item) { this.Trace(item); - string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); // %%% NULL PARENT CASE ??? + string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); if (item.Id.Equals(parentId)) { parentId = $"root_{parentId}"; } - this.ContinueWith(this.CreateStep(item.Id.Value, nameof(ActionScope)), parentId); - //this._workflowBuilder.AddLink(parentId, item.Id.Value); // %%% NEEDED ??? + this.ContinueWith(this.CreateStep(item.Id.Value, nameof(ActionScope)), parentId, condition: null, CompletionHandler); + + void CompletionHandler() + { + if (this._workflowModel.GetDepth(item.Id.Value) > 1) + { + string completionId = RestartId(item.Id.Value); + this.ContinueWith(this.CreateStep(completionId, $"{nameof(ActionScope)}_Post"), item.Id.Value); + this._workflowModel.AddLink(completionId, RestartId(parentId)); + } + } } - protected override void Visit(ConditionGroup item) + public override void VisitConditionItem(ConditionItem item) { this.Trace(item); - //ConditionGroupAction action = new(item); - //this.ContinueWith(action); - //this.RestartFrom(item.Id.Value, nameof(ConditionGroupAction), action.ParentId); + Func? condition = null; + + if (item.Condition is not null) + { + // %%% VERIFY IF ONLY ONE CONDITION IS EXPECTED / ALLOWED + condition = + new((_) => + { + bool result = this._executionContext.Engine.Eval(item.Condition.ExpressionText ?? "true").AsBoolean(); + Console.WriteLine($"!!! CONDITION: {item.Condition.ExpressionText ?? "true"}={result}"); + return result; + }); + } + + string stepId = item.Id ?? $"{nameof(ConditionItem)}_{Guid.NewGuid():N}"; + string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); - //// %%% SUPPORT: item.ElseActions + DeclarativeActionExecutor executor = this.CreateStep(stepId, nameof(ConditionItem)); + this._workflowModel.AddNode(executor, parentId, CompletionHandler); + this._workflowModel.AddLink(parentId, stepId, condition); - //int index = 1; - //foreach (ConditionItem conditionItem in item.Conditions) - //{ - // // Visit each action in the condition item - // conditionItem.Accept(this); + base.VisitConditionItem(item); - // ++index; - //} + void CompletionHandler() + { + string completionId = this.RestartFrom(stepId, nameof(ConditionItem), parentId); + this._workflowModel.AddLink(completionId, RestartId(parentId)); + + if (!item.Actions.Any()) + { + this._workflowModel.AddLink(stepId, completionId); + } + } } - public override void VisitConditionItem(ConditionItem item) + protected override void Visit(ConditionGroup item) { this.Trace(item); - //Func? condition = null; - - //if (item.Condition is not null) - //{ - // // %%% VERIFY IF ONLY ONE CONDITION IS EXPECTED / ALLOWED - // condition = - // new(() => - // { - // RecalcEngine engine = this.CreateEngine(); - // bool result = engine.Eval(item.Condition.ExpressionText ?? "true").AsBoolean(); - // Console.WriteLine($"!!! CONDITION: {item.Condition.ExpressionText ?? "true"}={result}"); - // return result; - // }); - //} + ConditionGroupExecutor action = new(item); + this.ContinueWith(action); + this.RestartFrom(action.Id, nameof(ConditionGroupExecutor), action.ParentId); - //string stepId = item.Id ?? $"{nameof(ConditionItem)}_{Guid.NewGuid():N}"; - //string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); // %%% NULL PARENT CASE ??? - //this.ContinueWith(this.CreateStep(stepId, nameof(ConditionItem)), parentId, condition, callback: CompletionHandler); + // %%% SUPPORT: item.ElseActions - //base.VisitConditionItem(item); + int index = 1; + foreach (ConditionItem conditionItem in item.Conditions) + { + // Visit each action in the condition item + conditionItem.Accept(this); - //void CompletionHandler(string _) - //{ - // string completionId = ConditionGroupAction.Steps.End(stepId); - // this.ContinueWith(this.CreateStep(completionId, $"{nameof(ConditionItem)}_End"), stepId); - // this._workflowBuilder.AddLink(completionId, RestartId(parentId)); - //} + ++index; + } } protected override void Visit(GotoAction item) { this.Trace(item); - string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); // %%% NULL PARENT CASE ??? + string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); this.ContinueWith(this.CreateStep(item.Id.Value, nameof(GotoAction)), parentId); this._workflowModel.AddLink(item.Id.Value, item.ActionId.Value); this.RestartFrom(item.Id.Value, nameof(GotoAction), parentId); @@ -125,11 +141,12 @@ protected override void Visit(Foreach item) ForeachExecutor action = new(item); string loopId = ForeachExecutor.Steps.Next(action.Id); - this.ContinueWith(action, callback: CompletionHandler); + this.ContinueWith(action, condition: null, CompletionHandler); string restartId = this.RestartFrom(action); this.ContinueWith(this.CreateStep(loopId, $"{nameof(ForeachExecutor)}_Next", action.TakeNext), action.Id); this._workflowModel.AddLink(loopId, restartId, (_) => !action.HasValue); this.ContinueWith(this.CreateStep(ForeachExecutor.Steps.Start(action.Id), $"{nameof(ForeachExecutor)}_Start"), action.Id, (_) => action.HasValue); + void CompletionHandler() { string completionId = ForeachExecutor.Steps.End(action.Id); @@ -138,28 +155,28 @@ void CompletionHandler() } } - protected override void Visit(BreakLoop item) // %%% SUPPORT + protected override void Visit(BreakLoop item) { this.Trace(item); string? loopId = this._workflowModel.LocateParent(item.GetParentId()); if (loopId is not null) { - string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); // %%% NULL PARENT CASE ??? + string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); this.ContinueWith(this.CreateStep(item.Id.Value, nameof(BreakLoop)), parentId); this._workflowModel.AddLink(item.Id.Value, RestartId(loopId)); this.RestartFrom(item.Id.Value, nameof(BreakLoop), parentId); } } - protected override void Visit(ContinueLoop item) // %%% SUPPORT + protected override void Visit(ContinueLoop item) { this.Trace(item); string? loopId = this._workflowModel.LocateParent(item.GetParentId()); if (loopId is not null) { - string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); // %%% NULL PARENT CASE ??? + string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); this.ContinueWith(this.CreateStep(item.Id.Value, nameof(ContinueLoop)), parentId); this._workflowModel.AddLink(item.Id.Value, ForeachExecutor.Steps.Next(loopId)); this.RestartFrom(item.Id.Value, nameof(ContinueLoop), parentId); @@ -418,23 +435,23 @@ protected override void Visit(SearchAndSummarizeContent item) private void ContinueWith( WorkflowActionExecutor executor, Func? condition = null, - ScopeCompletionHandler? callback = null) + ScopeCompletionHandler? completionHandler = null) { executor.Attach(this._executionContext); - this.ContinueWith(executor, executor.ParentId, condition, callback); + this.ContinueWith(executor, executor.ParentId, condition, completionHandler); } private void ContinueWith( ExecutorBase executor, string parentId, Func? condition = null, - ScopeCompletionHandler? callback = null) + ScopeCompletionHandler? completionHandler = null) { - this._workflowModel.AddNode(executor, parentId, callback); + this._workflowModel.AddNode(executor, parentId, completionHandler); this._workflowModel.AddLinkFromPeer(parentId, executor.Id, condition); } - private static string RestartId(string actionId) => $"post_{actionId}"; + private static string RestartId(string actionId) => $"{actionId}_Post"; private string RestartFrom(WorkflowActionExecutor executor) => this.RestartFrom(executor.Id, executor.GetType().Name, executor.ParentId); @@ -442,7 +459,7 @@ private string RestartFrom(WorkflowActionExecutor executor) => private string RestartFrom(string actionId, string name, string parentId) { string restartId = RestartId(actionId); - this._workflowModel.AddNode(this.CreateStep(restartId, $"{name}_Restart"), parentId); + this._workflowModel.AddNode(this.CreateStep(restartId, $"{name}_Post"), parentId); return restartId; } @@ -452,12 +469,10 @@ private DeclarativeActionExecutor CreateStep(string actionId, string name, Actio new(actionId, () => { - stepAction?.Invoke(this._executionContext); // %%% FIX + stepAction?.Invoke(this._executionContext); return new ValueTask(); }); - //this._workflowBuilder.BindExecutor(stepExecutor); - return stepExecutor; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs index 75a3f2fee0..716a67ae52 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs @@ -86,7 +86,12 @@ public void ConnectNodes(WorkflowBuilder workflowBuilder) { foreach (ModelNode node in this.Nodes.Values.ToImmutableArray()) { - node.CompletionHandler?.Invoke(); + if (node.CompletionHandler is not null) + { + Console.WriteLine($"> CLOSE: {node.Id} (x{node.Children.Count})"); // %%% LOGGER + + node.CompletionHandler.Invoke(); + } } foreach (ModelLink link in this.Links) @@ -96,7 +101,7 @@ public void ConnectNodes(WorkflowBuilder workflowBuilder) throw new WorkflowBuilderException($"Unresolved target for {link.Source.Id}: {link.TargetId}."); } - Console.WriteLine($"> CONNECT: {link.Source.Id} => {link.TargetId}"); // %%% LOGGER + Console.WriteLine($"> CONNECT: {link.Source.Id} => {link.TargetId}{(link.Condition is null ? string.Empty : " (?)")}"); // %%% LOGGER workflowBuilder.AddEdge(link.Source.Executor, targetNode.Executor, link.Condition); } From 34d0509a119a3c8d23c82c02617b724fe4493ef8 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 11 Aug 2025 21:35:21 -0700 Subject: [PATCH 047/232] Cleanup --- .../Workflows/Workflows_Declarative.cs | 132 ++++++++++-------- .../DeclarativeWorkflowBuilder.cs | 22 +-- ...ception.cs => ActionExecutionException.cs} | 16 +-- .../DeclarativeWorkflowException.cs | 35 +++++ .../Exceptions/InvalidActionException.cs | 2 +- .../Exceptions/InvalidScopeException.cs | 2 +- .../Exceptions/InvalidSegmentException.cs | 2 +- .../Exceptions/ProcessActionException.cs | 2 +- .../Exceptions/UnknownActionException.cs | 2 +- .../Exceptions/UnknownDataTypeException.cs | 2 +- .../Exceptions/WorkflowBuilderException.cs | 2 +- .../Execution/AnswerQuestionWithAIExecutor.cs | 3 +- .../Execution/ClearAllVariablesExecutor.cs | 2 +- .../Execution/EditTableV2Executor.cs | 3 +- .../Execution/ParseValueExecutor.cs | 3 +- .../Execution/WorkflowActionExecutor.cs | 2 +- .../Interpreter/WorkflowActionVisitor.cs | 4 +- .../Interpreter/WorkflowModel.cs | 10 +- .../PowerFx/WorkflowExpressionEngine.cs | 2 +- 19 files changed, 143 insertions(+), 105 deletions(-) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/{ProcessWorkflowException.cs => ActionExecutionException.cs} (53%) create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeWorkflowException.cs diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index d9d85fd91b..c13e8fd340 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -2,6 +2,7 @@ #if NET +using System.Text.Json; using Azure.Identity; using Microsoft.Agents.Orchestration; using Microsoft.Agents.Workflows.Core; @@ -18,10 +19,12 @@ namespace Workflows; /// public class Workflows_Declarative(ITestOutputHelper output) : OrchestrationSample(output) { + private const bool EnableApiIntercept = false; + [Theory] [InlineData("deepResearch")] [InlineData("demo250729")] - [InlineData("testChat")] + [InlineData("testChat", true)] [InlineData("testCondition0")] [InlineData("testCondition1")] [InlineData("testEnd")] @@ -31,79 +34,88 @@ public class Workflows_Declarative(ITestOutputHelper output) : OrchestrationSamp [InlineData("testLoopBreak")] [InlineData("testLoopContinue")] [InlineData("testTopic")] - public async Task RunWorkflow(string fileName) + public async Task RunWorkflow(string fileName, bool enableApiIntercept = false) { - //using InterceptHandler customHandler = new(); - //using HttpClient customClient = new(customHandler, disposeHandler: false); + HttpClient? customClient = null; + try + { + if (enableApiIntercept || EnableApiIntercept) + { + customClient = new(new InterceptHandler(), disposeHandler: true); + } + + Console.WriteLine("WORKFLOW INIT\n"); - //const string InputEventId = "question"; + using StreamReader yamlReader = File.OpenText(@$"{nameof(Workflows)}\{fileName}.yaml"); + DeclarativeWorkflowContext workflowContext = + new() + { + HttpClient = customClient, + LoggerFactory = this.LoggerFactory, + ActivityChannel = this.Console, + ProjectEndpoint = Throw.IfNull(TestConfiguration.AzureAI.Endpoint), + ProjectCredentials = new AzureCliCredential(), + }; + Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, "input", workflowContext); - Console.WriteLine("WORKFLOW INIT\n"); + Console.WriteLine("\nWORKFLOW INVOKE\n"); - using StreamReader yamlReader = File.OpenText(@$"{nameof(Workflows)}\{fileName}.yaml"); - DeclarativeWorkflowContext workflowContext = - new() + LocalRunner runner = new(workflow); + StreamingRun handle = await runner.StreamAsync(""); + await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) { - //HttpClient = customClient, - LoggerFactory = this.LoggerFactory, - ActivityChannel = this.Console, - ProjectEndpoint = Throw.IfNull(TestConfiguration.AzureAI.Endpoint), - ProjectCredentials = new AzureCliCredential(), - }; - Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, "input", workflowContext); + if (evt is ExecutorInvokeEvent executorInvoked) + { + Console.WriteLine($"!!! ENTER #{executorInvoked.ExecutorId}"); + } + else if (evt is ExecutorCompleteEvent executorComplete) + { + Console.WriteLine($"!!! EXIT #{executorComplete.ExecutorId}"); + } + } + Console.WriteLine("\nWORKFLOW DONE"); + } + finally + { + customClient?.Dispose(); + } + } +} - Console.WriteLine("\nWORKFLOW INVOKE\n"); +internal sealed class InterceptHandler : HttpClientHandler +{ + private static readonly JsonSerializerOptions s_options = new() { WriteIndented = true }; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // Call the inner handler to process the request and get the response + HttpResponseMessage response = await base.SendAsync(request, cancellationToken); - LocalRunner runner = new(workflow); - StreamingRun handle = await runner.StreamAsync(""); - await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + // Intercept and modify the response + Console.WriteLine($"{request.Method} {request.RequestUri}"); + if (response.Content != null) { - if (evt is ExecutorInvokeEvent executorInvoked) + string responseContent; + try { - Console.WriteLine($"!!! ENTER #{executorInvoked.ExecutorId}"); + JsonDocument responseDocument = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); + responseContent = JsonSerializer.Serialize(responseDocument, s_options); } - else if (evt is ExecutorCompleteEvent executorComplete) + catch (ArgumentException) { - Console.WriteLine($"!!! EXIT #{executorComplete.ExecutorId}"); + responseContent = await response.Content.ReadAsStringAsync(cancellationToken); } + catch (JsonException) + { + responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + } + response.Content = new StringContent(responseContent); + + Console.WriteLine($"API:{Environment.NewLine}" + responseContent); // %%% RAISE EVENT } - Console.WriteLine("\nWORKFLOW DONE"); + + return response; } } -//internal sealed class InterceptHandler : HttpClientHandler -//{ -// private static readonly JsonSerializerOptions s_options = new() { WriteIndented = true }; - -// protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) -// { -// // Call the inner handler to process the request and get the response -// HttpResponseMessage response = await base.SendAsync(request, cancellationToken); - -// // Intercept and modify the response -// Console.WriteLine($"{request.Method} {request.RequestUri}"); -// if (response.Content != null) -// { -// string responseContent; -// try -// { -// JsonDocument responseDocument = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); -// responseContent = JsonSerializer.Serialize(responseDocument, s_options); -// } -// catch (ArgumentException) -// { -// responseContent = await response.Content.ReadAsStringAsync(cancellationToken); -// } -// catch (JsonException) -// { -// responseContent = await response.Content.ReadAsStringAsync(cancellationToken); -// } -// response.Content = new StringContent(responseContent); -// //Console.WriteLine(responseContent); // %%% RAISE EVENT -// } - -// return response; -// } -//} - #endif diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index bee2fb4ccb..6a396aaeed 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -26,37 +26,25 @@ public static class DeclarativeWorkflowBuilder public static Workflow Build(TextReader yamlReader, string messageId, DeclarativeWorkflowContext? context = null) { Console.WriteLine("@ PARSING YAML"); - BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new InvalidOperationException("Unable to parse YAML content."); // %%% EXCEPTION TYPE + BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new UnknownActionException("Unable to parse workflow."); string rootId = $"root_{GetRootId(rootElement)}"; Console.WriteLine("@ INITIALIZING BUILDER"); context ??= DeclarativeWorkflowContext.Default; WorkflowScopes scopes = new(); - DeclarativeWorkflowExecutor rootExecutor = new(scopes, rootId); // %%% DISPOSE + DeclarativeWorkflowExecutor rootExecutor = new(scopes, rootId); Console.WriteLine("@ INTERPRETING WORKFLOW"); WorkflowActionVisitor visitor = new(rootExecutor, context, scopes); WorkflowElementWalker walker = new(rootElement, visitor); - //Console.WriteLine("@ FINALIZING WORKFLOW"); - //ProcessStepBuilder errorHandler = // %%% DYNAMIC/CONTEXT ??? - // processBuilder.AddStepFromFunction( - // $"{processBuilder.Name}_unhandled_error", - // (kernel, context) => - // { - // // Handle unhandled errors here - // Console.WriteLine("*** PROCESS ERROR - Unhandled error"); // %%% EXTERNAL - // return Task.CompletedTask; - // }); - //processBuilder.OnError().SendEventTo(new ProcessFunctionTargetBuilder(errorHandler)); - return walker.Workflow; } - private static string GetRootId(BotElement element) => + private static string GetRootId(BotElement element) => // %%% WORKFLOW TYPE element switch { - AdaptiveDialog adaptiveDialog => adaptiveDialog.BeginDialog?.Id.Value ?? throw new InvalidOperationException("Undefined dialog"), // %%% EXCEPTION TYPE / WORKFLOW TYPE - _ => throw new InvalidOperationException($"Unsupported root element: {element.GetType().Name}."), // %%% EXCEPTION TYPE + AdaptiveDialog adaptiveDialog => adaptiveDialog.BeginDialog?.Id.Value ?? throw new UnknownActionException("Undefined dialog"), + _ => throw new UnknownActionException($"Unsupported root element: {element.GetType().Name}."), }; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ProcessWorkflowException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ActionExecutionException.cs similarity index 53% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ProcessWorkflowException.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ActionExecutionException.cs index 0f1847346b..4fdbab524d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ProcessWorkflowException.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ActionExecutionException.cs @@ -5,31 +5,31 @@ namespace Microsoft.Agents.Workflows.Declarative; /// -/// Represents any exception that occurs during the execution of a process workflow. +/// Represents an exception that occurs when an action is invalid or cannot be processed. /// -public class ProcessWorkflowException : Exception +public sealed class ActionExecutionException : DeclarativeWorkflowException { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public ProcessWorkflowException() + public ActionExecutionException() { } /// - /// Initializes a new instance of the class with a specified error message. + /// Initializes a new instance of the class with a specified error message. /// /// The error message that explains the reason for the exception. - public ProcessWorkflowException(string? message) : base(message) + public ActionExecutionException(string? message) : base(message) { } /// - /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. /// /// The error message that explains the reason for the exception. /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. - public ProcessWorkflowException(string? message, Exception? innerException) : base(message, innerException) + public ActionExecutionException(string? message, Exception? innerException) : base(message, innerException) { } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeWorkflowException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeWorkflowException.cs new file mode 100644 index 0000000000..1cad0fb2c8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeWorkflowException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Represents any exception that occurs during the execution of a process workflow. +/// +public class DeclarativeWorkflowException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public DeclarativeWorkflowException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public DeclarativeWorkflowException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public DeclarativeWorkflowException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidActionException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidActionException.cs index d5359eef5f..df20fa8b54 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidActionException.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidActionException.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.Workflows.Declarative; /// /// Represents an exception that occurs when an action is invalid or cannot be processed. /// -public sealed class InvalidActionException : ProcessWorkflowException +public sealed class InvalidActionException : DeclarativeWorkflowException { /// /// Initializes a new instance of the class. diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs index a11dc6fdd6..9e3b78088e 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.Workflows.Declarative; /// /// Represents an exception that occurs when the specific scope is invalid. /// -public sealed class InvalidScopeException : ProcessWorkflowException +public sealed class InvalidScopeException : DeclarativeWorkflowException { /// /// Initializes a new instance of the class. diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidSegmentException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidSegmentException.cs index 1b58a2b14a..310ff2f89f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidSegmentException.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidSegmentException.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.Workflows.Declarative; /// /// Represents an exception that occurs when an action is invalid or cannot be processed. /// -public sealed class InvalidSegmentException : ProcessWorkflowException +public sealed class InvalidSegmentException : DeclarativeWorkflowException { /// /// Initializes a new instance of the class. diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ProcessActionException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ProcessActionException.cs index 47166b15b1..e1cebe72a5 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ProcessActionException.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ProcessActionException.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.Workflows.Declarative; /// /// Represents an exception that occurs during the execution of a process action. /// -public class ProcessActionException : ProcessWorkflowException +public class ProcessActionException : DeclarativeWorkflowException { /// /// Initializes a new instance of the class. diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownActionException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownActionException.cs index dd41ecbeaf..0f7e512034 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownActionException.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownActionException.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.Workflows.Declarative; /// /// Represents an exception that occurs when an action is invalid or cannot be processed. /// -public sealed class UnknownActionException : ProcessWorkflowException +public sealed class UnknownActionException : DeclarativeWorkflowException { /// /// Initializes a new instance of the class. diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownDataTypeException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownDataTypeException.cs index 9e54d410c4..129de6ff41 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownDataTypeException.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownDataTypeException.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.Workflows.Declarative; /// /// Represents an exception that occurs when an unknown data type is encountered. /// -public sealed class UnknownDataTypeException : ProcessWorkflowException +public sealed class UnknownDataTypeException : DeclarativeWorkflowException { /// /// Initializes a new instance of the class. diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowBuilderException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowBuilderException.cs index a78278c793..ce10d45c88 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowBuilderException.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowBuilderException.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.Workflows.Declarative; /// /// Represents an exception that occurs when building the process workflow. /// -public class WorkflowBuilderException : ProcessWorkflowException +public class WorkflowBuilderException : DeclarativeWorkflowException { /// /// Initializes a new instance of the class. diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs index ad0c7b64c6..b80f6bcbac 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs @@ -19,6 +19,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model) : protected override async ValueTask ExecuteAsync(CancellationToken cancellationToken) { PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(this.Model.Variable)}"); + StringExpression userInputExpression = Throw.IfNull(this.Model.UserInput, $"{nameof(this.Model)}.{nameof(this.Model.UserInput)}"); PersistentAgentsClient client = this.Context.ClientFactory.Invoke(); using NewPersistentAgentsChatClient chatClient = new(client, "asst_ueIjfGxAjsnZ4A61LlbjG9vJ"); @@ -27,7 +28,7 @@ protected override async ValueTask ExecuteAsync(CancellationToken cancellationTo string? userInput = null; if (this.Model.UserInput is not null) { - EvaluationResult result = this.Context.ExpressionEngine.GetValue(this.Model.UserInput!, this.Context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE + EvaluationResult result = this.Context.ExpressionEngine.GetValue(userInputExpression, this.Context.Scopes); // %%% FAILURE CASE (CATCH) userInput = result.Value; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs index b1c2d6b1c2..9e4f4e4006 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs @@ -12,7 +12,7 @@ internal sealed class ClearAllVariablesExecutor(ClearAllVariables model) : Workf { protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) { - EvaluationResult result = this.Context.ExpressionEngine.GetValue(this.Model.Variables, this.Context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE + EvaluationResult result = this.Context.ExpressionEngine.GetValue(this.Model.Variables, this.Context.Scopes); // %%% FAILURE CASE (CATCH) result.Value.Handle(new ScopeHandler(this.Context)); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs index c89c15a517..3b49b836ac 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs @@ -24,7 +24,8 @@ protected async override ValueTask ExecuteAsync(CancellationToken cancellationTo EditTableOperation? changeType = this.Model.ChangeType; if (changeType is AddItemOperation addItemOperation) { - EvaluationResult result = this.Context.ExpressionEngine.GetValue(addItemOperation.Value!, this.Context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE + ValueExpression addItemValue = Throw.IfNull(addItemOperation.Value, $"{nameof(this.Model)}.{nameof(this.Model.ChangeType)}"); + EvaluationResult result = this.Context.ExpressionEngine.GetValue(addItemValue, this.Context.Scopes); // %%% FAILURE CASE (CATCH) RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), result.Value.ToFormulaValue()); await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); this.AssignTarget(this.Context, variablePath, tableValue); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs index dd70fd621d..08a4c61f2a 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs @@ -18,8 +18,9 @@ internal sealed class ParseValueExecutor(ParseValue model) : protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) { PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); + ValueExpression valueExpression = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); - EvaluationResult result = this.Context.ExpressionEngine.GetValue(this.Model.Value!, this.Context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE + EvaluationResult result = this.Context.ExpressionEngine.GetValue(valueExpression, this.Context.Scopes); // %%% FAILURE CASE (CATCH) FormulaValue? parsedResult = null; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs index 42f2fb098d..657d579ed5 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs @@ -34,7 +34,7 @@ internal abstract class WorkflowActionExecutor(DialogAction model) : protected WorkflowExecutionContext Context => this._context ?? - throw new InvalidOperationException("Context not assigned"); // %%% EXCEPTION TYPE + throw new ActionExecutionException("Context not assigned"); internal void Attach(WorkflowExecutionContext executionContext) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 7be3c8d56e..44c3b38647 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -435,7 +435,7 @@ protected override void Visit(SearchAndSummarizeContent item) private void ContinueWith( WorkflowActionExecutor executor, Func? condition = null, - ScopeCompletionHandler? completionHandler = null) + Action? completionHandler = null) { executor.Attach(this._executionContext); this.ContinueWith(executor, executor.ParentId, condition, completionHandler); @@ -445,7 +445,7 @@ private void ContinueWith( ExecutorBase executor, string parentId, Func? condition = null, - ScopeCompletionHandler? completionHandler = null) + Action? completionHandler = null) { this._workflowModel.AddNode(executor, parentId, completionHandler); this._workflowModel.AddLinkFromPeer(parentId, executor.Id, condition); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs index 716a67ae52..1322aeaef1 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs @@ -10,7 +10,7 @@ namespace Microsoft.Agents.Workflows.Declarative.Interpreter; /// /// %%% COMMENT /// -internal delegate void ScopeCompletionHandler(); // %%% ACTION ??? +internal delegate void Action(); /// /// Provides dynamic model for constructing a declarative workflow. @@ -43,7 +43,7 @@ public int GetDepth(string? nodeId) return sourceNode.Depth; } - public void AddNode(ExecutorBase executor, string parentId, ScopeCompletionHandler? completionHandler = null) + public void AddNode(ExecutorBase executor, string parentId, Action? completionHandler = null) { if (!this.Nodes.TryGetValue(parentId, out ModelNode? parentNode)) { @@ -107,7 +107,7 @@ public void ConnectNodes(WorkflowBuilder workflowBuilder) } } - private ModelNode DefineNode(ExecutorIsh executor, ModelNode? parentNode = null, Type? executorType = null, ScopeCompletionHandler? completionHandler = null) + private ModelNode DefineNode(ExecutorIsh executor, ModelNode? parentNode = null, Type? executorType = null, Action? completionHandler = null) { ModelNode stepNode = new(executor, parentNode, executorType, completionHandler); @@ -141,7 +141,7 @@ private ModelNode DefineNode(ExecutorIsh executor, ModelNode? parentNode = null, return null; } - private sealed class ModelNode(ExecutorIsh executor, ModelNode? parent = null, Type? executorType = null, ScopeCompletionHandler? completionHandler = null) + private sealed class ModelNode(ExecutorIsh executor, ModelNode? parent = null, Type? executorType = null, Action? completionHandler = null) { public string Id => executor.Id; @@ -155,7 +155,7 @@ private sealed class ModelNode(ExecutorIsh executor, ModelNode? parent = null, T public int Depth => this.Parent?.Depth + 1 ?? 0; - public ScopeCompletionHandler? CompletionHandler => completionHandler; + public Action? CompletionHandler => completionHandler; } private sealed record class ModelLink(ModelNode Source, string TargetId, Func? Condition = null); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs index 9c452b6286..e148b5f981 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs @@ -145,7 +145,7 @@ private EvaluationResult GetValue(IntExpression expression, TState EvaluationResult expressionResult = evaluator.Invoke(expression, state); - if (expressionResult.Value is not PrimitiveValue formulaValue) // %%% CORRECT ??? + if (expressionResult.Value is not PrimitiveValue formulaValue) { throw new InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.Number); } From c92c0e2990fa642f9449d5f988ef49ae156dea08 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 11 Aug 2025 21:47:48 -0700 Subject: [PATCH 048/232] Exception cleanup --- .../Exceptions/InvalidActionException.cs | 35 ------------------- .../Exceptions/WorkflowBuilderException.cs | 35 ------------------- ...ption.cs => WorkflowExecutionException.cs} | 14 ++++---- ...Exception.cs => WorkflowModelException.cs} | 16 ++++----- .../Execution/ParseValueExecutor.cs | 2 +- .../Execution/WorkflowActionExecutor.cs | 6 ++-- .../Extensions/BotElementExtensions.cs | 4 +-- .../Interpreter/WorkflowActionVisitor.cs | 6 ++-- .../Interpreter/WorkflowModel.cs | 4 +-- 9 files changed, 26 insertions(+), 96 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidActionException.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowBuilderException.cs rename dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/{ActionExecutionException.cs => WorkflowExecutionException.cs} (51%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/{ProcessActionException.cs => WorkflowModelException.cs} (54%) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidActionException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidActionException.cs deleted file mode 100644 index df20fa8b54..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidActionException.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Agents.Workflows.Declarative; - -/// -/// Represents an exception that occurs when an action is invalid or cannot be processed. -/// -public sealed class InvalidActionException : DeclarativeWorkflowException -{ - /// - /// Initializes a new instance of the class. - /// - public InvalidActionException() - { - } - - /// - /// Initializes a new instance of the class with a specified error message. - /// - /// The error message that explains the reason for the exception. - public InvalidActionException(string? message) : base(message) - { - } - - /// - /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. - /// - /// The error message that explains the reason for the exception. - /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. - public InvalidActionException(string? message, Exception? innerException) : base(message, innerException) - { - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowBuilderException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowBuilderException.cs deleted file mode 100644 index ce10d45c88..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowBuilderException.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Agents.Workflows.Declarative; - -/// -/// Represents an exception that occurs when building the process workflow. -/// -public class WorkflowBuilderException : DeclarativeWorkflowException -{ - /// - /// Initializes a new instance of the class. - /// - public WorkflowBuilderException() - { - } - - /// - /// Initializes a new instance of the class with a specified error message. - /// - /// The error message that explains the reason for the exception. - public WorkflowBuilderException(string? message) : base(message) - { - } - - /// - /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. - /// - /// The error message that explains the reason for the exception. - /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. - public WorkflowBuilderException(string? message, Exception? innerException) : base(message, innerException) - { - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ActionExecutionException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowExecutionException.cs similarity index 51% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ActionExecutionException.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowExecutionException.cs index 4fdbab524d..e562ff77d3 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ActionExecutionException.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowExecutionException.cs @@ -7,29 +7,29 @@ namespace Microsoft.Agents.Workflows.Declarative; /// /// Represents an exception that occurs when an action is invalid or cannot be processed. /// -public sealed class ActionExecutionException : DeclarativeWorkflowException +public sealed class WorkflowExecutionException : DeclarativeWorkflowException { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public ActionExecutionException() + public WorkflowExecutionException() { } /// - /// Initializes a new instance of the class with a specified error message. + /// Initializes a new instance of the class with a specified error message. /// /// The error message that explains the reason for the exception. - public ActionExecutionException(string? message) : base(message) + public WorkflowExecutionException(string? message) : base(message) { } /// - /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. /// /// The error message that explains the reason for the exception. /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. - public ActionExecutionException(string? message, Exception? innerException) : base(message, innerException) + public WorkflowExecutionException(string? message, Exception? innerException) : base(message, innerException) { } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ProcessActionException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowModelException.cs similarity index 54% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ProcessActionException.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowModelException.cs index e1cebe72a5..8bb08fb649 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/ProcessActionException.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowModelException.cs @@ -5,31 +5,31 @@ namespace Microsoft.Agents.Workflows.Declarative; /// -/// Represents an exception that occurs during the execution of a process action. +/// Represents an exception that occurs when building the process workflow. /// -public class ProcessActionException : DeclarativeWorkflowException +public class WorkflowModelException : DeclarativeWorkflowException { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public ProcessActionException() + public WorkflowModelException() { } /// - /// Initializes a new instance of the class with a specified error message. + /// Initializes a new instance of the class with a specified error message. /// /// The error message that explains the reason for the exception. - public ProcessActionException(string? message) : base(message) + public WorkflowModelException(string? message) : base(message) { } /// - /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. /// /// The error message that explains the reason for the exception. /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. - public ProcessActionException(string? message, Exception? innerException) : base(message, innerException) + public WorkflowModelException(string? message, Exception? innerException) : base(message, innerException) { } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs index 08a4c61f2a..e72f0bbf89 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs @@ -46,7 +46,7 @@ protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) if (parsedResult is null) { - throw new ProcessActionException($"Unable to parse {result.Value.GetType().Name}"); + throw new WorkflowExecutionException($"Unable to parse {result.Value.GetType().Name}"); } this.AssignTarget(this.Context, variablePath, parsedResult); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs index 657d579ed5..59b42571fe 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs @@ -34,7 +34,7 @@ internal abstract class WorkflowActionExecutor(DialogAction model) : protected WorkflowExecutionContext Context => this._context ?? - throw new ActionExecutionException("Context not assigned"); + throw new WorkflowExecutionException("Context not assigned"); internal void Attach(WorkflowExecutionContext executionContext) { @@ -56,7 +56,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext context) await context.SendMessageAsync($"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}").ConfigureAwait(false); } - catch (ProcessActionException) + catch (WorkflowExecutionException) { Console.WriteLine($"*** STEP [{this.Id}] ERROR - Action failure"); // %%% LOGGER throw; @@ -64,7 +64,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext context) catch (Exception exception) { Console.WriteLine($"*** STEP [{this.Id}] ERROR - {exception.GetType().Name}\n{exception.Message}"); // %%% LOGGER - throw; + throw new WorkflowExecutionException($"Unhandled workflow failure - #{this.Id} ({this.Model.GetType().Name})", exception); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs index 81b9527884..c513b00864 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs @@ -13,9 +13,9 @@ public static string GetId(this BotElement element) return element switch { DialogAction action => action.Id.Value, - ConditionItem conditionItem => conditionItem.Id ?? throw new InvalidActionException($"Undefined identifier for {nameof(ConditionItem)} that is member of {conditionItem.GetParentId() ?? "(root)"}."), + ConditionItem conditionItem => conditionItem.Id ?? throw new WorkflowModelException($"Undefined identifier for {nameof(ConditionItem)} that is member of {conditionItem.GetParentId() ?? "(root)"}."), OnActivity activity => activity.Id.Value, - _ => throw new InvalidActionException($"Unknown element type: {element.GetType().Name}"), + _ => throw new UnknownActionException($"Unknown element type: {element.GetType().Name}"), }; } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 44c3b38647..49d95f8570 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -227,9 +227,9 @@ protected override void Visit(ResetVariable item) this.ContinueWith(new ResetVariableExecutor(item)); } - protected override void Visit(EditTable item) + protected override void Visit(EditTable item) // %%% TODO { - this.Trace(item); // %%% TODO + this.Trace(item); } protected override void Visit(EditTableV2 item) @@ -501,6 +501,6 @@ private void Trace(DialogAction item) private static string FormatParent(BotElement element) => element.Parent is null ? - throw new InvalidActionException($"Undefined parent for {element.GetType().Name} that is member of {element.GetId()}.") : + throw new WorkflowModelException($"Undefined parent for {element.GetType().Name} that is member of {element.GetId()}.") : $"{element.Parent.GetType().Name} ({element.GetParentId()})"; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs index 1322aeaef1..2c26ff4050 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs @@ -64,7 +64,7 @@ public void AddLinkFromPeer(string parentId, string targetId, Func CONNECT: {link.Source.Id} => {link.TargetId}{(link.Condition is null ? string.Empty : " (?)")}"); // %%% LOGGER From a5203a2ce45d1bb911935132f03a37995f09c5ff Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 11 Aug 2025 21:54:28 -0700 Subject: [PATCH 049/232] Sample cleanup --- .../Workflows/Workflows_Declarative.cs | 16 ++++++++++++++-- .../DeclarativeWorkflowBuilder.cs | 3 +-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index c13e8fd340..c3c208ed24 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -46,17 +46,29 @@ public async Task RunWorkflow(string fileName, bool enableApiIntercept = false) Console.WriteLine("WORKFLOW INIT\n"); + ////////////////////////////////////////////////////// + // + // HOW TO: Create a workflow from a YAML file. + // using StreamReader yamlReader = File.OpenText(@$"{nameof(Workflows)}\{fileName}.yaml"); + // + // DeclarativeWorkflowContext provides the components for workflow execution. + // DeclarativeWorkflowContext workflowContext = new() { HttpClient = customClient, LoggerFactory = this.LoggerFactory, - ActivityChannel = this.Console, + ActivityChannel = System.Console.Out, ProjectEndpoint = Throw.IfNull(TestConfiguration.AzureAI.Endpoint), ProjectCredentials = new AzureCliCredential(), }; - Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, "input", workflowContext); + // + // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. + // + Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); + // + ////////////////////////////////////////////////////// Console.WriteLine("\nWORKFLOW INVOKE\n"); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 6a396aaeed..7455670233 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -20,10 +20,9 @@ public static class DeclarativeWorkflowBuilder /// Builds a process from the provided YAML definition of a CPS Topic ObjectModel. /// /// The reader that provides the workflow object model YAML. - /// The identifier for the message. /// The hosting context for the workflow. /// The that corresponds with the YAML object model. - public static Workflow Build(TextReader yamlReader, string messageId, DeclarativeWorkflowContext? context = null) + public static Workflow Build(TextReader yamlReader, DeclarativeWorkflowContext? context = null) { Console.WriteLine("@ PARSING YAML"); BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new UnknownActionException("Unable to parse workflow."); From 200a8076645f18ef05c1b270ed08e5d27a9723ae Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 12 Aug 2025 08:38:02 -0700 Subject: [PATCH 050/232] Updates --- .../Execution/AnswerQuestionWithAIExecutor.cs | 2 +- .../Execution/DeclarativeWorkflowExecutor.cs | 7 ++++++- ...rativeActionExecutor.cs => WorkflowDelegateExecutor.cs} | 5 ++--- .../Interpreter/WorkflowActionVisitor.cs | 6 +++--- 4 files changed, 12 insertions(+), 8 deletions(-) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/{DeclarativeActionExecutor.cs => WorkflowDelegateExecutor.cs} (62%) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs index b80f6bcbac..9518041cf1 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs @@ -22,7 +22,7 @@ protected override async ValueTask ExecuteAsync(CancellationToken cancellationTo StringExpression userInputExpression = Throw.IfNull(this.Model.UserInput, $"{nameof(this.Model)}.{nameof(this.Model.UserInput)}"); PersistentAgentsClient client = this.Context.ClientFactory.Invoke(); - using NewPersistentAgentsChatClient chatClient = new(client, "asst_ueIjfGxAjsnZ4A61LlbjG9vJ"); + using NewPersistentAgentsChatClient chatClient = new(client, "asst_ueIjfGxAjsnZ4A61LlbjG9vJ"); // %%% HACK - AGENT ID ChatClientAgent agent = new(chatClient); string? userInput = null; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs index 80c1644255..9322dd08e7 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs @@ -8,13 +8,18 @@ namespace Microsoft.Agents.Workflows.Declarative.Execution; +/// +/// The root executor for a declarative workflow. +/// +/// // %%% COMMENT / DESIGN +/// The unique identifier for the workflow. internal sealed class DeclarativeWorkflowExecutor(WorkflowScopes scopes, string workflowId) : Executor(workflowId), IMessageHandler { public async ValueTask HandleAsync(string message, IWorkflowContext context) { - scopes.Set("LastMessage", WorkflowScopeType.System, FormulaValue.New(message)); // %%% MAGIC CONST "LastMessage" + scopes.Set("LastMessage", WorkflowScopeType.System, FormulaValue.New(message)); // %%% MAGIC CONST "LastMessage" / SYSTEM scope //await context.AddEventAsync(new ExecutorInvokeEvent(this.Id, $"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}")).ConfigureAwait(false); await context.SendMessageAsync($"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}").ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowDelegateExecutor.cs similarity index 62% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeActionExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowDelegateExecutor.cs index 27b492c0f6..15d2868d37 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowDelegateExecutor.cs @@ -6,15 +6,14 @@ namespace Microsoft.Agents.Workflows.Declarative.Execution; -internal sealed class DeclarativeActionExecutor(string actionId, Func action) : - Executor(actionId), +internal sealed class WorkflowDelegateExecutor(string actionId, Func action) : + Executor(actionId), IMessageHandler { public async ValueTask HandleAsync(string message, IWorkflowContext context) { await action.Invoke().ConfigureAwait(false); - //await context.AddEventAsync(new ExecutorInvokeEvent(this.Id, $"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}")).ConfigureAwait(false); await context.SendMessageAsync($"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}").ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 49d95f8570..8636e12db6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -87,7 +87,7 @@ public override void VisitConditionItem(ConditionItem item) string stepId = item.Id ?? $"{nameof(ConditionItem)}_{Guid.NewGuid():N}"; string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); - DeclarativeActionExecutor executor = this.CreateStep(stepId, nameof(ConditionItem)); + WorkflowDelegateExecutor executor = this.CreateStep(stepId, nameof(ConditionItem)); this._workflowModel.AddNode(executor, parentId, CompletionHandler); this._workflowModel.AddLink(parentId, stepId, condition); @@ -463,9 +463,9 @@ private string RestartFrom(string actionId, string name, string parentId) return restartId; } - private DeclarativeActionExecutor CreateStep(string actionId, string name, Action? stepAction = null) + private WorkflowDelegateExecutor CreateStep(string actionId, string name, Action? stepAction = null) { - DeclarativeActionExecutor stepExecutor = + WorkflowDelegateExecutor stepExecutor = new(actionId, () => { From aaa0f7aa54d47c54eeb6b20aa84fab15f89916d5 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 28 Jul 2025 16:08:29 -0400 Subject: [PATCH 051/232] feat: Define Workflow and Executor APIs --- .../Core/CompletedValueTaskSource.cs | 27 ++ .../Workflows/Core/DisposableObject.cs | 59 +++ .../Workflows/Core/ExecutionContext.cs | 17 + .../Workflows/Core/Executor.cs | 275 ++++++++++++++ .../Workflows/Core/Message.cs | 117 ++++++ .../Workflows/Core/MessageHandler.cs | 37 ++ .../Workflows/Core/MessageRouting.cs | 342 +++++++++++++++++ .../Workflows/Core/TypeErasure.cs | 60 +++ .../Workflows/WorkflowBuilder.cs | 353 ++++++++++++++++++ .../Workflows/WorkflowBuilderExtensions.cs | 156 ++++++++ 10 files changed, 1443 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs new file mode 100644 index 0000000000..e33b614d20 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// Helper class to work around lack of proper ValueTask support in .NET Framework. +/// +internal static class CompletedValueTaskSource +{ + internal static ValueTask Completed => +#if NET5_0_OR_GREATER + ValueTask.CompletedTask; +#else + new(Task.CompletedTask); +#endif + + internal static ValueTask FromResult(T result) + { +#if NET5_0_OR_GREATER + return new ValueTask(result); +#else + return new ValueTask(Task.FromResult(result)); +#endif + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs new file mode 100644 index 0000000000..d147b7d78d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// Provides a base class implementing the interface using +/// the virtual Dispose pattern. +/// +public class DisposableObject : IAsyncDisposable +{ + /// + /// Implements invocation of the DisposeAsync method when the object is finalized to + /// dispose unmanaged resources properly. + /// + ~DisposableObject() + { + // Finalizer calls DisposeAsync to ensure resources are released. + // This is a safety net in case DisposeAsync was not called. +#pragma warning disable CA2012 // Use ValueTasks correctly: Uses OnCompleted to properly handle the ValueTask return. + ValueTask disposeTask = this.DisposeAsync(false); +#pragma warning restore CA2012 // Use ValueTasks correctly + + if (!disposeTask.IsCompleted) + { + using (ManualResetEvent barrier = new(false)) + { + disposeTask.GetAwaiter().OnCompleted(() => barrier.Set()); + + // Wait for the DisposeAsync to complete. + barrier.WaitOne(); // TODO: Timeout? + } + } + + Debug.Assert( + disposeTask.IsCompleted, + "DisposeAsync should have completed in order to pass to this line."); +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + disposeTask.GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + } + + /// + protected virtual ValueTask DisposeAsync(bool disposing) + { + return CompletedValueTaskSource.Completed; + } + + /// + public async ValueTask DisposeAsync() + { + await this.DisposeAsync(true).ConfigureAwait(false); + GC.SuppressFinalize(this); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs new file mode 100644 index 0000000000..6a1d3f8415 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// Provides services for subclasses. +/// +public interface IExecutionContext +{ + /// + /// . + /// + /// + Task MagicAsync(); +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs new file mode 100644 index 0000000000..d5ea990084 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs @@ -0,0 +1,275 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// A tag interface for objects that have a unique identifier within an appropriate namespace. +/// +public interface IIdentified +{ + /// + /// The unique identifier. + /// + string Id { get; } +} + +/// +/// . +/// +public record ExecutorCapabilities +{ + /// + /// . + /// + public string Id { get; init; } + /// + /// . + /// + public string Name { get; init; } + /// + /// . + /// + public Type ExecutorType { get; init; } + /// + /// . + /// + public ISet HandledMessageTypes { get; init; } + /// + /// . + /// + public bool IsInitialized { get; init; } + /// + /// . + /// + public ISet StateKeys { get; init; } + + /// + /// . + /// + public ExecutorCapabilities() + { + this.Id = string.Empty; + this.Name = string.Empty; + this.ExecutorType = typeof(Executor); + this.HandledMessageTypes = new HashSet(); + this.IsInitialized = false; + this.StateKeys = new HashSet(); + } + + /// + /// . + /// + /// + /// + /// + /// + /// + /// + public ExecutorCapabilities(string id, string name, Type executorType, ISet handledMessageTypes, bool isInitialized, ISet stateKeys) + { + this.Id = id; + this.Name = name; + this.ExecutorType = executorType; + this.HandledMessageTypes = handledMessageTypes; + this.IsInitialized = isInitialized; + this.StateKeys = stateKeys; + } +} + +/// +/// . +/// +[DebuggerDisplay("{GetType().Name}{Id}({Name})")] +public abstract class Executor : DisposableObject, IIdentified +{ + /// + /// . + /// + public string Id { get; } + + /// + /// . + /// + public string Name { get; } + + private MessageRouter MessageRouter { get; init; } + private Dictionary State { get; } = new(); + + /// + /// . + /// + /// + /// + protected Executor(string? id = null, string? name = null) + { + this.Name = name ?? this.GetType().Name; + this.Id = id ?? $"{this.Name}{Guid.NewGuid():N}"; + + this.MessageRouter = MessageRouter.BindMessageHandlers(this, checkType: true); + } + + /// + /// . + /// + /// + /// + /// + /// + /// + public async ValueTask ExecuteAsync(object message, IExecutionContext context) + { + CallResult? result = await this.MessageRouter.RouteMessageAsync(message, context, requireRoute: true) + .ConfigureAwait(false); + + if (result == null) + { + throw new NotSupportedException( + $"No handler found for message type {message.GetType().Name} in executor {this.GetType().Name}."); + } + + if (!result.IsSuccess) + { + throw new TargetInvocationException($"Error invoking handler for {message.GetType()}", result.Exception!); + } + + if (result.IsVoid) + { + return null; // Void result. + } + + return result.Result; + } + + private bool _initialized = false; + + /// + /// . + /// + public ISet InputTypes => this.MessageRouter.IncomingTypes; + + /// + /// . + /// + [SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "")] + public ISet OutputTypes => throw new NotImplementedException(); + + /// + /// . + /// + /// + /// + public bool CanHandle(Type messageType) => this.MessageRouter.CanHandle(messageType); + + /// + /// . + /// + /// + /// + public async ValueTask InitializeAsync(IExecutionContext context) + { + if (this._initialized) + { + return; + } + + await this.InitializeOverride(context).ConfigureAwait(false); + + this._initialized = true; + } + + /// + /// . + /// + public ExecutorCapabilities Capabilities + => new() + { + Id = this.Id, + Name = this.Name, + ExecutorType = this.GetType(), + HandledMessageTypes = new HashSet(this.InputTypes), + IsInitialized = this._initialized, + StateKeys = new HashSet(this.State.Keys) + }; + + /// + /// . + /// + /// + public ReadOnlyDictionary CurrentState => new(this.State); + + /// + /// . + /// + /// + /// + public void RestoreState(IDictionary state) + { + if (state == null) + { + throw new ArgumentNullException(nameof(state), "State cannot be null."); + } + + this.State.Clear(); + + foreach (KeyValuePair kvp in state) + { + this.State[kvp.Key] = kvp.Value; + } + } + + /// + /// . + /// + /// + protected virtual ValueTask PrepareForCheckpointAsync() + { + return CompletedValueTaskSource.Completed; + } + + /// + /// . + /// + /// + protected virtual ValueTask AfterCheckpointRestoreAsync() + { + return CompletedValueTaskSource.Completed; + } + + /// + /// . + /// + /// + /// + protected virtual ValueTask InitializeOverride(IExecutionContext context) + { + // Default implementation does nothing. + return CompletedValueTaskSource.Completed; + } + + private async ValueTask FlushReduceRemainingAsync() + { + return; + } + + /// + /// . + /// + /// + /// + protected override async ValueTask DisposeAsync(bool disposing = false) + { + this._initialized = false; + + await this.FlushReduceRemainingAsync().ConfigureAwait(false); + + await base.DisposeAsync(disposing).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs new file mode 100644 index 0000000000..3b8eda2482 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +using ExecutorId = string; +// TODO: Unclear whether this should be forcibly a serializable type. +using MetadataValueT = object; +using RetryExceptionT = System.InvalidOperationException; +using TopicId = string; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// . +/// +public record MessageMetadata +{ + /// + /// . + /// + public string CorrelationId { get; init; } = Guid.NewGuid().ToString(); + /// + /// . + /// + public ExecutorId? SourceId { get; init; } + /// + /// . + /// + public ExecutorId? TargetId { get; init; } + /// + /// . + /// + public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; + /// + /// . + /// + public string IsoTimestamp => this.Timestamp.ToString("o"); + /// + /// . + /// + public TopicId? Topic { get; init; } + /// + /// . + /// + public int Priority { get; init; } = 0; // Higher values indicate higher priority. + /// + /// . + /// + public TimeSpan? Timeout { get; init; } = null; + + /// + /// . + /// + public int Retries { get; init; } = 0; + /// + /// . + /// + public int MaxRetries { get; init; } = 3; + + /// + /// . + /// + public IDictionary CustomData { get; init; } = new Dictionary(); +} + +/// +/// . +/// +/// +public record Message +{ + /// + /// . + /// + public TContent Content { get; init; } + + /// + /// . + /// + public Type ContentType => typeof(TContent); + + /// + /// . + /// + public MessageMetadata Metadata { get; init; } + + /// + /// . + /// + /// + /// + /// + public Message(TContent content, MessageMetadata metadata) + { + this.Content = content ?? throw new ArgumentNullException(nameof(content)); + this.Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); + } + + /// + /// Creates a new message instance for a new target. + /// + /// The identifier of the target executor to associate with the message. + /// A new instance with the updated target identifier. + public Message WithTarget(ExecutorId targetId) + => this with { Metadata = this.Metadata with { TargetId = targetId } }; + + /// + /// Create a copy of this message for next retry attempt. + /// + /// A copy of this message with incremented retry count. + /// If the maximum number of retries has been exceeded. + public Message WithRetry() + => this.Metadata.Retries < this.Metadata.MaxRetries + ? this with { Metadata = this.Metadata with { Retries = this.Metadata.Retries + 1 } } + : throw new RetryExceptionT($"Maximum retries ({this.Metadata.MaxRetries}) exceeded for message with ID '{this.Metadata.CorrelationId}'."); +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs new file mode 100644 index 0000000000..009a5aabc6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// A message handler interface for handling messages of type . +/// +/// +public interface IMessageHandler +{ + /// + /// Handles the incoming message asynchronously. + /// + /// The message to handle. + /// The execution context. + /// A task that represents the asynchronous operation. + ValueTask HandleAsync(TMessage message, IExecutionContext context); +} + +/// +/// A message handler interface for handling messages of type and +/// returning a result. +/// +/// The type of message to handle. +/// The type of result returned after handling the message. +public interface IMessageHandler +{ + /// + /// Handles the incoming message asynchronously. + /// + /// The message to handle. + /// The execution context. + /// A task that represents the asynchronous operation. + ValueTask HandleAsync(TMessage message, IExecutionContext context); +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs new file mode 100644 index 0000000000..4fc27244d8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs @@ -0,0 +1,342 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +using HandlerInfosT = + System.Collections.Generic.Dictionary< + System.Type, + Microsoft.Agents.Orchestration.Workflows.Core.MessageHandlerInfo + >; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// This class represents the result of a call to a +/// or . +/// +public sealed class CallResult +{ + /// + /// Indicates whether the call was void (i.e., no result expected). This only applies to + /// calls to handlers. + /// + public bool IsVoid { get; init; } + + /// + /// If the call was successful, this property contains the result of the call. For calls to + /// void handlers, this will be null. + /// + public object? Result { get; init; } = null; + + /// + /// If the call failed, this property contains the exception that was raised during the call. + /// + public Exception? Exception { get; init; } = null; + + /// + /// Indicates whether the call was successful. A call is considered successful if it returned + /// without throwing an exception. + /// + public bool IsSuccess => this.Exception == null; + + private CallResult(bool isVoid = false) + { + // Private constructor to enforce use of static methods. + this.IsVoid = isVoid; + } + + /// + /// Create a indicating a successful that returned a result (non-void). + /// + /// The result to return. + /// A indicating the result of the call. + public static CallResult ReturnResult(object? result = null) + { + return new() { Result = result }; + } + + /// + /// Create a indicating a successful call that returned no result (void). + /// + /// A indicating the result of the call. + public static CallResult ReturnVoid() + { + return new(isVoid: true); + } + + /// + /// Create a indicating that an exception was raised during the call. + /// + /// A boolean specifying whether the call was void (was not expected to return + /// a value). + /// The exception that was raised during the call. + /// A indicating the result of the call. + /// Thrown when is null. + public static CallResult RaisedException(bool wasVoid, Exception exception) + { + if (exception == null) + { + throw new ArgumentNullException(nameof(exception), "Exception cannot be null."); + } + + return new(wasVoid) { Exception = exception }; + } +} + +internal struct MessageHandlerInfo +{ + public Type InType { get; init; } + public Type? OutType { get; init; } = null; + + public MethodInfo HandlerInfo { get; init; } + public Func>? Unwrapper { get; init; } = null; + + [SuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality " + + "when AOT compiling.", Justification = "")] + public MessageHandlerInfo(MethodInfo handlerInfo) + { + // The method is one of the following: + // - ValueTask HandleAsync(TMessage message, IExecutionContext context) + // - ValueTask HandleAsync(TMessage message, IExecutionContext context) + this.HandlerInfo = handlerInfo; + + ParameterInfo[] parameters = handlerInfo.GetParameters(); + if (parameters.Length != 2) + { + throw new ArgumentException("Handler method must have exactly two parameters: TMessage and IExecutionContext.", nameof(handlerInfo)); + } + + if (parameters[1].ParameterType != typeof(IExecutionContext)) + { + throw new ArgumentException("Handler method's second parameter must be of type IExecutionContext.", nameof(handlerInfo)); + } + + this.InType = parameters[0].ParameterType; + + Type decoratedReturnType = handlerInfo.ReturnType; + if (decoratedReturnType.IsGenericType && decoratedReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) + { + // If the return type is ValueTask, extract TResult. + Type[] returnRawTypes = decoratedReturnType.GetGenericArguments(); + Debug.Assert( + returnRawTypes.Length == 1, + "ValueTask should have exactly one generic argument."); + + this.OutType = returnRawTypes.Single(); + this.Unwrapper = ValueTaskTypeErasure.UnwrapperFor(this.OutType); + } + else if (decoratedReturnType == typeof(ValueTask)) + { + // If the return type is ValueTask, there is no output type. + this.OutType = null; + } + else + { + throw new ArgumentException("Handler method must return ValueTask or ValueTask.", nameof(handlerInfo)); + } + } + + public Func> Bind(Executor executor, bool checkType = false) + { + Type? resultType = this.OutType; + MethodInfo handlerMethod = this.HandlerInfo; + Func>? unwrapper = this.Unwrapper; + + return InvokeHandlerAsync; + + // Create a delegate that binds the handler to the executor. + async ValueTask InvokeHandlerAsync(object message) + { + bool expectingVoid = resultType == null || resultType == typeof(void); + + try + { + object? maybeValueTask = handlerMethod.Invoke(executor, new object[] { message, executor }); + + if (expectingVoid) + { + if (maybeValueTask is ValueTask vt) + { + await vt.ConfigureAwait(false); + return CallResult.ReturnVoid(); + } + + throw new InvalidOperationException( + $"Handler method {handlerMethod.Name} is expected to return ValueTask or ValueTask, but returned " + + $"{maybeValueTask?.GetType().Name ?? "null"}."); + } + + Debug.Assert(resultType != null, "Expected resultType to be non-null when not expecting void."); + if (unwrapper == null) + { + throw new InvalidOperationException( + $"Handler method {handlerMethod.Name} is expected to return ValueTask<{resultType!.Name}>, but no unwrapper is available."); + } + + if (maybeValueTask == null) + { + throw new InvalidOperationException( + $"Handler method {handlerMethod.Name} returned null, but a ValueTask<{resultType!.Name}> was expected."); + } + + object? result = await unwrapper(maybeValueTask).ConfigureAwait(false); + + if (checkType && result != null && !resultType.IsInstanceOfType(result)) + { + throw new InvalidOperationException( + $"Handler method {handlerMethod.Name} returned an incompatible type: expected {resultType.Name}, got {result.GetType().Name}."); + } + + return CallResult.ReturnResult(result); + } + catch (Exception ex) + { + // If the handler throws an exception, return it in the CallResult. + return CallResult.RaisedException(wasVoid: expectingVoid, exception: ex); + } + } + } +} + +internal class MessageRouter +{ + // TODO: The goal of the cache is to allow SourceGenerators to do the reflection to bind the handlers in the router. + internal static readonly Dictionary> s_routerFactoryCache = new(); + + private Dictionary>> BoundHandlers { get; init; } = new(); + + [SuppressMessage("Trimming", + "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + + "of the source method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + [SuppressMessage("Trimming", + "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of " + + "method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + private static HandlerInfosT ReflectHandlers(Type executorType) + { + // This method reflects over the methods of the executor type to find message handlers. + HandlerInfosT handlers = new(); + + // Get all implementations of IMessageHandler or IMessageHandler + // and create a MessageHandlerInfo for each. + if (!typeof(Executor).IsAssignableFrom(executorType)) + { + throw new ArgumentException($"Type {executorType.FullName} is not a valid Executor type.", nameof(executorType)); + } + + if (executorType.IsAbstract || executorType.IsInterface) + { + throw new ArgumentException($"Type {executorType.FullName} cannot be abstract or an interface.", nameof(executorType)); + } + + // Iterate all interfaces implemented by the executor type. + foreach (Type interfaceType in executorType.GetInterfaces()) + { + // Check if the interface is a message handler. + if (!interfaceType.IsGenericType) + { + continue; + } + + if (interfaceType.IsGenericType && (interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || + interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>))) + { + // Get the generic arguments of the interface. + Type[] genericArguments = interfaceType.GetGenericArguments(); + if (genericArguments.Length < 1 || genericArguments.Length > 2) + { + continue; // Invalid handler signature. + } + Type inType = genericArguments[0]; + Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; + MethodInfo? method = interfaceType.GetMethod("HandleAsync"); + if (method != null) + { + MessageHandlerInfo info = new(method) { InType = inType, OutType = outType }; + handlers[inType] = info; + } + } + } + + return handlers; + } + + [SuppressMessage("Trimming", + "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + + "of the source method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + internal static HandlerInfosT ReflectHandlers() where TExecutor : Executor + => ReflectHandlers(typeof(TExecutor)); + + internal static MessageRouter BindMessageHandlers(Executor executor, bool checkType) + { + if (s_routerFactoryCache.TryGetValue(executor.GetType(), out var factory)) + { + return factory(); + } + + // If no factory is found, reflect over the handlers + HandlerInfosT handlers = ReflectHandlers(executor.GetType()); + + Dictionary>> boundHandlers = new(); + foreach (Type inType in handlers.Keys) + { + MessageHandlerInfo handlerInfo = handlers[inType]; + Func> boundHandler = handlerInfo.Bind(executor, checkType); + boundHandlers.Add(inType, boundHandler); // TODO: Turn the error here into something more actionable. + } + + return new MessageRouter(boundHandlers); + } + + internal MessageRouter(Dictionary>> handlers) + { + this.BoundHandlers = handlers; + } + + /// + /// . + /// + /// + /// + /// + /// + /// + /// + public async ValueTask RouteMessageAsync(object message, IExecutionContext context, bool requireRoute = true) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message), "Message cannot be null."); + } + + // TODO: Implement base type delegation + CallResult? result = null; + if (this.BoundHandlers.TryGetValue(message.GetType(), out Func>? handler)) + { + result = await handler(message).ConfigureAwait(false); + } + + return result; + } + + public bool CanHandle(Type candidateType) + { + if (candidateType == null) + { + throw new ArgumentNullException(nameof(candidateType), "Candidate type cannot be null."); + } + + // Check if the router can handle the candidate type. + return this.BoundHandlers.ContainsKey(candidateType); + } + + public HashSet IncomingTypes => [.. this.BoundHandlers.Keys]; +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs new file mode 100644 index 0000000000..62edbb4d85 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Reflection; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +internal static class ValueTaskTypeErasure +{ + internal static Func> CreateErasingUnwrapper() + { + return UnwrapAndEraseAsync; + + static async ValueTask UnwrapAndEraseAsync(object maybeValueTask) + { + if (maybeValueTask is ValueTask vt) + { + // If the input is a ValueTask, unwrap it. + TResult result = await vt.ConfigureAwait(false); + return (object?)result; + } + + throw new InvalidOperationException($"Expected ValueTask or ValueTask<{typeof(TResult).Name}>, but got {maybeValueTask.GetType().Name}."); + } + } + +#if NET5_0_OR_GREATER + // This suppression is qualified because for some reason VS is not recognizing the attribute's presence, treating the + // import as an error (due to unnecessary using). + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode("Calls System.Reflection.MethodInfo.MakeGenericMethod(params Type[])")] +#endif + internal static Func> UnwrapperFor(Type resultType) + { + // This method creates a type-erased unwrapper for ValueTask. + // It uses reflection to create a delegate that can handle any TResult type. + + // TODO: AOT: This method is marked with RequiresDynamicCodeAttribute, which will not work well in NativeAOT + // scenarios; the solution is to break this up into a Cached/Reflector version (like the MessageRouter does + // with handlers), and SourceGenerate the UnwrapAndEraseAsync-equivalent method for each TResult type. + + // Note that this is only necessary because ValueTask is a class-generic, rather than an interface + // type, which means that the type cannot be co/contravariantly used (e.g. ValueTask is not a valid + // supertype of ValueTask or ValueTask, T != object?). + + MethodInfo createMethod = + typeof(ValueTaskTypeErasure) + .GetMethod(nameof(CreateErasingUnwrapper), BindingFlags.NonPublic | BindingFlags.Static) + !.MakeGenericMethod(resultType); + + // Invoke createMethod (as static) to get the delegate. + object? maybeUnwrapper = createMethod.Invoke(null, Array.Empty()); + if (maybeUnwrapper is not Func> unwrapper) + { + throw new InvalidOperationException($"Expected a Func> delegate, but got {maybeUnwrapper?.GetType().Name ?? "null"}."); + } + + return unwrapper; + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs new file mode 100644 index 0000000000..cc17f2b335 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs @@ -0,0 +1,353 @@ +// Copyright (c) Microsoft. All rights reserved. + +#pragma warning disable IDE0005 // Using directive is unnecessary. +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Workflows.Core; +#pragma warning restore IDE0005 // Using directive is unnecessary. + +using ConditionalT = System.Func; + +namespace Microsoft.Agents.Orchestration.Workflows; + +internal delegate TExecutor ExecutorProvider() + where TExecutor : Executor; + +internal struct EdgeKey : IEquatable +{ + public string SourceId { get; init; } + public string TargetId { get; init; } + + public EdgeKey(string sourceId, string targetId) + { + this.SourceId = sourceId ?? throw new ArgumentNullException(nameof(sourceId)); + this.TargetId = targetId ?? throw new ArgumentNullException(nameof(targetId)); + } + + public bool Equals(EdgeKey other) => this.SourceId == other.SourceId && this.TargetId == other.TargetId; + public override bool Equals(object? obj) => obj is EdgeKey other && this.Equals(other); + public override int GetHashCode() => HashCode.Combine(this.SourceId, this.TargetId); +} + +/// +/// . +/// +public class ExecutionResult +{ +} + +internal sealed class ExecutorIsh : + IIdentified, + IEquatable, + IEquatable, + IEquatable +{ + public enum Type + { + Unbound, + Executor, + //Function, + //Agent, + //ProcessStep + } + + public Type ExecutorType { get; init; } + + private readonly string? _idValue; + private readonly Executor? _executorValue; + //private readonly Func? _functionValue; + + public ExecutorIsh(Executor executor) + { + this.ExecutorType = Type.Executor; + this._executorValue = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public ExecutorIsh(string id) + { + this.ExecutorType = Type.Unbound; + this._idValue = id ?? throw new ArgumentNullException(nameof(id)); + } + + public bool IsUnbound => this.ExecutorType == Type.Unbound; + + public string Id => this.ExecutorType switch + { + Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), + Type.Executor => this._executorValue!.Id, + //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), + //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), + //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), + _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + }; + + public ExecutorProvider ExecutorProvider => this.ExecutorType switch + { + Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), + Type.Executor => () => this._executorValue!, + //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), + //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), + //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), + _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + }; + + //public ExecutorIsh(Func function) + //{ + // this.ExecutorType = Type.Function; + // this._functionValue = function ?? throw new ArgumentNullException(nameof(function)); + //} + + // Implicit conversions into ExecutorIsh + public static implicit operator ExecutorIsh(Executor executor) + { + return new ExecutorIsh(executor); + } + + // How do we AoT compile this? + //public static implicit operator ExecutorIsh(Func function) + //{ + // return new ExecutorIsh(function); + //} + + public static implicit operator ExecutorIsh(string id) + { + return new ExecutorIsh(id); + } + + public bool Equals(ExecutorIsh? other) + { + return other is not null && + other.Id == this.Id; + } + + public bool Equals(IIdentified? other) + { + return other is not null && + other.Id == this.Id; + } + + public bool Equals(string? other) + { + return other is not null && + other == this.Id; + } + + public override bool Equals(object? obj) + { + if (obj is null) + { + return false; + } + + if (obj is ExecutorIsh ish) + { + return this.Equals(ish); + } + else if (obj is IIdentified identified) + { + return this.Equals(identified); + } + else if (obj is string str) + { + return this.Equals(str); + } + + return false; + } + + public override int GetHashCode() + { + return this.Id.GetHashCode(); + } + + public override string ToString() + { + return this.ExecutorType switch + { + Type.Unbound => $"'{this.Id}':", + Type.Executor => $"'{this.Id}':{this._executorValue!.GetType()}", + //Type.Function => $"ExecutorIsh for Function with ID '{this.Id}'", + //Type.Agent => $"ExecutorIsh for Agent with ID '{this.Id}'", + //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", + _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + }; + } +} + +internal class FlowEdge(ExecutorIsh source, ExecutorIsh sink, ConditionalT? conditional) : IEquatable +{ + public ExecutorIsh Source { get; init; } = source ?? throw new ArgumentNullException(nameof(source)); + public ExecutorIsh Sink { get; } = sink ?? throw new ArgumentNullException(nameof(sink)); + public Func? Condition { get; } = conditional; + + public bool Equals(FlowEdge? other) + { + return other is null + ? false + : this.Source.Equals(other.Source) && this.Sink.Equals(other.Sink); + } + + public override bool Equals(object? obj) => obj is FlowEdge other && this.Equals(other); + public override int GetHashCode() => HashCode.Combine(this.Source.GetHashCode(), this.Sink.GetHashCode()); +} + +internal class Workflow +{ + public Dictionary> Executors { get; internal init; } = new(); + public Dictionary> Edges { get; internal init; } = new(); + +#if NET9_0_OR_GREATER + required +#endif + public string StartExecutorId + { get; init; } + +#if NET9_0_OR_GREATER + required +#endif + public Type InputType + { get; init; } = typeof(object); + + public Workflow(string startExecutorId, Type type) + { + this.StartExecutorId = startExecutorId ?? throw new ArgumentNullException(nameof(startExecutorId)); + this.InputType = type ?? throw new ArgumentNullException(nameof(type)); + + // TODO: How do we (1) ensure the types are happy, and (2) work under AOT? + } + +#if NET9_0_OR_GREATER + public Workflow() + { } +#endif +} + +// Just a decorator for the purposes of keeping type type where we can +internal class Workflow : Workflow +{ + public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) + { + } + +#if NET9_0_OR_GREATER + public Workflow() + { + this.InputType = typeof(T); + } +#endif +} + +internal class WorkflowBuilder +{ + private readonly Dictionary> _executors = new(); + private readonly Dictionary> _edges = new(); + private readonly HashSet _unboundExecutors = new(); + + private readonly string _startExecutorId; + + public WorkflowBuilder(ExecutorIsh start) + { + this._startExecutorId = this.Track(start).Id; + } + + private ExecutorIsh Track(ExecutorIsh executorish) + { + ExecutorProvider provider = executorish.ExecutorProvider; + + // If the executor is unbound, create an entry for it, unless it already exists. + // Otherwise, update the entry for it, and remove the unbound tag + if (executorish.IsUnbound && !this._executors.ContainsKey(executorish.Id)) + { + // If this is an unbound executor, we need to track it separately + this._unboundExecutors.Add(executorish.Id); + this._executors[executorish.Id] = provider; + } + else if (!executorish.IsUnbound) + { + // If we already have an executor with this ID, we need to update it (todo: should we throw on double binding?) + this._executors[executorish.Id] = provider; + } + + return executorish; + } + + private void UpdateExecutor(string id, ExecutorProvider provider) + { + this._executors[id] = provider; + } + + public WorkflowBuilder BindExecutor(Executor executor) + { + if (!this._unboundExecutors.Contains(executor.Id)) + { + throw new InvalidOperationException( + $"Executor with ID '{executor.Id}' is already bound or does not exist in the workflow."); + } + + this._executors[executor.Id] = () => executor; + this._unboundExecutors.Remove(executor.Id); + return this; + } + + public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func? condition = null) + { + // Add an edge from source to target with an optional condition. + // This is a low-level builder method that does not enforce any specific executor type. + // The condition can be used to determine if the edge should be followed based on the input. + + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (target == null) + { + throw new ArgumentNullException(nameof(target)); + } + + if (!this._edges.TryGetValue(source.Id, out HashSet? edges)) + { + edges = new HashSet(); + this._edges[source.Id] = edges; + } + + edges.Add(new FlowEdge(this.Track(source), this.Track(target), condition)); + return this; + } + + public Workflow Build() + { + if (this._unboundExecutors.Count > 0) + { + throw new InvalidOperationException( + $"Workflow cannot be built because there are unbound executors: {string.Join(", ", this._unboundExecutors)}."); + } + + // Grab the start node, and make sure it has the right type? + if (!this._executors.TryGetValue(this._startExecutorId, out ExecutorProvider? startProvider)) + { + // TODO: This should never be able to be hit + throw new InvalidOperationException($"Start executor with ID '{this._startExecutorId}' is not bound."); + } + + // TODO: Delay-instantiate the start executor, and ensure it is of type T. + Executor startExecutor = startProvider(); + + if (!startExecutor.InputTypes.Any(t => t.IsAssignableFrom(typeof(T)))) + { + // We have no handlers for the input type T, which means the built workflow will not be able to + // process messages of the desired type + } + + return new Workflow(this._startExecutorId) // Why does it not see the default ctor? + { + Executors = this._executors, + Edges = this._edges, + StartExecutorId = this._startExecutorId, + InputType = typeof(T) + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs new file mode 100644 index 0000000000..69f7af5712 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Workflows.Core; + +namespace Microsoft.Agents.Orchestration.Workflows; + +internal static class Check +{ + public static T NotNull(T? value, [CallerArgumentExpression(nameof(value))] string? paramExpr = null) where T : class + { + if (value is null) + { + throw new ArgumentNullException(nameof(value), $"Value cannot be null: {paramExpr}"); + } + + return value; + } +} + +internal enum Activation +{ + WhenAll, +} + +internal static class WorkflowBuilderExtensions +{ + public static WorkflowBuilder AddLoop(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh loopBody, Func condition) + { + Check.NotNull(builder); + Check.NotNull(source); + Check.NotNull(loopBody); + Check.NotNull(condition); + + builder.AddEdge(source, loopBody, condition); + builder.AddEdge(loopBody, source); + + return builder; + } + + public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors) + { + Check.NotNull(builder); + Check.NotNull(source); + + for (int i = 0; i < executors.Length; i++) + { + Check.NotNull(executors[i], nameof(executors) + $"[{i}]"); + builder.AddEdge(source, executors[i]); + source = executors[i]; + } + + return builder; + } + + private class FanOutMessage(object message) + { + public object Content = message ?? throw new ArgumentNullException(nameof(message), "Message cannot be null"); + } + + private class FanInMessage(IEnumerable? message = null) + { + public static readonly FanInMessage Pending = new(); + + public bool IsCompleted => this.Result is not null; + public IEnumerable? Result = message; + } + + private class FanOutExecutor : Executor, IMessageHandler + { + public ValueTask HandleAsync(object message, IExecutionContext context) + { + return new ValueTask(new FanOutMessage(message)); + } + } + + public static WorkflowBuilder AddFanOut(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] targets) + { + Check.NotNull(builder); + Check.NotNull(source); + + FanOutExecutor fanOut = new(); + builder.AddEdge(source, fanOut); + + foreach (var target in targets) + { + Check.NotNull(target); + builder.AddEdge(fanOut, target); + } + + return builder; + } + + private class FanInExecutor : Executor, + IMessageHandler + { +#if NET9_0_OR_GREATER + required +#endif + public int SourceCount + { get; init; } + + public Activation Activation { get; init; } = Activation.WhenAll; + + private readonly List _messages = []; + public ValueTask HandleAsync(FanOutMessage message, IExecutionContext context) + { + this._messages.Add(message.Content); + + if (this._messages.Count >= this.SourceCount) + { + return new ValueTask(new FanInMessage(this._messages.ToArray())); + } + + return CompletedValueTaskSource.FromResult(FanInMessage.Pending); + } + } + + private class FanInUnwrapper : Executor, + IMessageHandler> + { + public ValueTask> HandleAsync(FanInMessage message, IExecutionContext context) + { + return CompletedValueTaskSource.FromResult(message.Result!); + } + } + + public static WorkflowBuilder AddFanIn(this WorkflowBuilder builder, ExecutorIsh target, Activation activation = Activation.WhenAll, params ExecutorIsh[] sources) + { + Check.NotNull(builder); + Check.NotNull(target); + + FanInExecutor fanIn = new() + { + Activation = activation, + SourceCount = sources.Length + }; + FanInUnwrapper unwrapper = new(); + + builder.AddEdge(fanIn, unwrapper, IsFanInCompleted); + builder.AddEdge(unwrapper, target); + + foreach (var source in sources) + { + Check.NotNull(source); + builder.AddEdge(source, fanIn); + } + + return builder; + + static bool IsFanInCompleted(object? message) => message is FanInMessage fanIn && fanIn.IsCompleted; + } +} From 3811ae97485b88ee9e2529cb1f7a9f5d8f7904bc Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 28 Jul 2025 18:59:59 -0400 Subject: [PATCH 052/232] feat: Define IExecutionContext and Events --- .../Workflows/Core/Events.cs | 152 ++++++++++++++++++ .../Workflows/Core/ExecutionContext.cs | 44 ++++- .../Workflows/Core/MessageRouting.cs | 21 +++ .../Workflows/WorkflowBuilderExtensions.cs | 3 +- 4 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs new file mode 100644 index 0000000000..51a23842c8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// . +/// +public record WorkflowEvent(object? Data = null); + +/// +/// . +/// +public record WorkflowStartedEvent : WorkflowEvent; + +/// +/// . +/// +public record WorkflowCompletedEvent : WorkflowEvent; + +/// +/// . +/// +public record ExecutorEvent : WorkflowEvent +{ + /// + /// The identifier of the executor that generated this event. + /// +#if NET9_0_OR_GREATER + required +#endif + public string ExecutorId + { get; init; } + + /// + /// . + /// + public ExecutorEvent(string executorId, object? data = null) : base(data) + { + this.ExecutorId = executorId ?? throw new ArgumentNullException(nameof(executorId), "Executor ID cannot be null."); + } + +#if NET9_0_OR_GREATER + /// + /// . + /// + public ExecutorEvent() + { } +#endif +} + +/// +/// . +/// +public record ExecutorInvokeEvent : ExecutorEvent +{ + /// + /// . + /// + public ExecutorInvokeEvent(string executorId, object? data = null) : base(data) + { + this.ExecutorId = executorId ?? throw new ArgumentNullException(nameof(executorId), "Executor ID cannot be null."); + } + +#if NET9_0_OR_GREATER + /// + /// . + /// + public ExecutorInvokeEvent() + { } +#endif +} + +/// +/// . +/// +public record ExecutorCompleteEvent : ExecutorEvent +{ + /// + /// . + /// + public ExecutorCompleteEvent(string executorId, object? data = null) : base(data) + { + this.ExecutorId = executorId ?? throw new ArgumentNullException(nameof(executorId), "Executor ID cannot be null."); + } + +#if NET9_0_OR_GREATER + /// + /// . + /// + public ExecutorCompleteEvent() + { } +#endif +} + +// TODO: This is a placeholder for streaming chat message content. +/// +/// . +/// +public class StreamingChatMessageContent +{ } + +/// +/// . +/// +public record AgentRunStreamingEvent : ExecutorEvent +{ + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the executor that generated this event. + /// + public AgentRunStreamingEvent(string executorId, StreamingChatMessageContent? content = null) : base(executorId, data: content) + { + this.Content = content; + } + + /// + /// Gets the content of the streaming chat message. + /// + public StreamingChatMessageContent? Content { get; } +} + +// TODO: This is a placeholder for non-streaming chat message content. +/// +/// . +/// +public class ChatMessageContent +{ +} + +/// +/// . +/// +public record AgentRunEvent : ExecutorEvent +{ + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the executor that generated this event. + /// + public AgentRunEvent(string executorId, ChatMessageContent? content = null) : base(executorId, data: content) + { + this.Content = content; + } + + /// + /// Gets the content of the chat message. + /// + public ChatMessageContent? Content { get; } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs index 6a1d3f8415..60beaa88ee 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; using System.Threading.Tasks; namespace Microsoft.Agents.Orchestration.Workflows.Core; @@ -10,8 +11,45 @@ namespace Microsoft.Agents.Orchestration.Workflows.Core; public interface IExecutionContext { /// - /// . + /// Send a message from the executor to the context. /// - /// - Task MagicAsync(); + /// The id of the sender of the message. + /// The message to be sent. + /// A representing the asynchronous operation. + ValueTask SendMessageAsync(string sourceId, object message); + + /// + /// Drain all messages from the context. + /// + /// A representing the asynchronous operation, containing + /// a dictionary mapping executor IDs to lists of messages. + ValueTask>> DrainMessagesAsync(); + + /// + /// Check if there are any message in the context. + /// + /// A representing the asynchronous operation, containing + /// true if there are messages. false if there are not. + ValueTask HasMessagesAsync(); + + /// + /// Add an event to the execution context. + /// + /// The event to be added. + /// A representing the asynchronous operation. + ValueTask AddEventAsync(WorkflowEvent workflowEvent); + + /// + /// Drain all events from the context. + /// + /// A representing the asynchronous operation, containing + /// a list of all events. + ValueTask> DrainEventsAsync(); + + /// + /// Check if there are any events in the context. + /// + /// A representing the asynchronous operation, containing + /// true if there are events. false if there are not. + ValueTask HasEventsAsync(); } diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs index 4fc27244d8..53a22b88d4 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs @@ -16,6 +16,27 @@ namespace Microsoft.Agents.Orchestration.Workflows.Core; +/// +/// This attribute indicates that a message handler streams messages during its execution. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public sealed class StreamsMessageAttribute : Attribute +{ + /// + /// The type of the message that the handler yields. + /// + public Type Type { get; } + + /// + /// Indicates that the message handler yields streaming messages during the course of execution. + /// + public StreamsMessageAttribute(Type type) + { + // This attribute is used to mark executors that yield messages. + this.Type = type ?? throw new ArgumentNullException(nameof(type), "Type cannot be null."); + } +} + /// /// This class represents the result of a call to a /// or . diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs index 69f7af5712..a0084486b9 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs @@ -28,12 +28,11 @@ internal enum Activation internal static class WorkflowBuilderExtensions { - public static WorkflowBuilder AddLoop(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh loopBody, Func condition) + public static WorkflowBuilder AddLoop(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh loopBody, Func? condition = null) { Check.NotNull(builder); Check.NotNull(source); Check.NotNull(loopBody); - Check.NotNull(condition); builder.AddEdge(source, loopBody, condition); builder.AddEdge(loopBody, source); From 47c564845337ec75bbbba31f0a3253693cbdff1c Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 28 Jul 2025 19:00:15 -0400 Subject: [PATCH 053/232] feat: Simple Workflow Demos --- .../Sample/02_Simple_Workflow_Sequential.cs | 42 +++++++++ .../Sample/02a_Simple_Workflow_Condition.cs | 85 +++++++++++++++++ .../Sample/02b_Simple_Workflow_Loop.cs | 93 +++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs new file mode 100644 index 0000000000..66fc4c5c85 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Workflows.Core; + +namespace Microsoft.Agents.Orchestration.Workflows.Sample; + +internal static class Step2EntryPoint +{ + public static ValueTask RunAsync() + { + UppercaseExecutor uppercase = new(); + ReverseTextExecutor reverse = new(); + + WorkflowBuilder builder = new(uppercase); + builder.AddEdge(uppercase, reverse); + + Workflow workflow = builder.Build(); + // async foreach (var event in workflow.RunAsync("hello world")) + // await Console.Out.WriteLineAsync(event); + + return CompletedValueTaskSource.Completed; + } +} + +internal class UppercaseExecutor : Executor, IMessageHandler +{ + public ValueTask HandleAsync(string message, IExecutionContext context) + { + return CompletedValueTaskSource.FromResult(message.ToUpperInvariant()); + } +} + +internal class ReverseTextExecutor : Executor, IMessageHandler +{ + public ValueTask HandleAsync(string message, IExecutionContext context) + { + char[] charArray = message.ToCharArray(); + System.Array.Reverse(charArray); + return CompletedValueTaskSource.FromResult(new string(charArray)); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs new file mode 100644 index 0000000000..47c8620ade --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Workflows.Core; + +namespace Microsoft.Agents.Orchestration.Workflows.Sample; + +internal static class Step2aEntryPoint +{ + public static ValueTask RunAsync() + { + string[] spamKeywords = { "spam", "advertisement", "offer" }; + + DetectSpamExecutor detectSpam = new(spamKeywords); + RespondToMessageExecutor respondToMessage = new(); + RemoveSpamExecutor removeSpam = new(); + + Workflow workflow = new WorkflowBuilder(detectSpam) + .AddEdge(detectSpam, respondToMessage, isSpam => isSpam is true) // If not spam, respond + .AddEdge(detectSpam, removeSpam, isSpam => isSpam is false) // If spam, remove + .Build(); + + // async foreach (var event in workflow.RunAsync("This is a spam message.")) + // await Console.Out.WriteLineAsync(event); + + return CompletedValueTaskSource.Completed; + } +} + +internal class DetectSpamExecutor : Executor, IMessageHandler +{ + public string[] SpamKeywords { get; } + + public DetectSpamExecutor(params string[] spamKeywords) + { + this.SpamKeywords = spamKeywords; + } + + public ValueTask HandleAsync(string message, IExecutionContext context) + { +#if NET5_0_OR_GREATER + bool isSpam = this.SpamKeywords.Any(keyword => message.Contains(keyword, StringComparison.OrdinalIgnoreCase)); +#else + bool isSpam = this.SpamKeywords.Any(keyword => message.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0); +#endif + + return CompletedValueTaskSource.FromResult(isSpam); + } +} + +internal class RespondToMessageExecutor : Executor, IMessageHandler +{ + public async ValueTask HandleAsync(bool message, IExecutionContext context) + { + if (message) + { + // This is SPAM, and should not have been routed here + throw new InvalidOperationException("Received a spam message that should not be getting a reply."); + } + + await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay + + await context.AddEventAsync(new WorkflowCompletedEvent { Data = "Message processed successfully." }) + .ConfigureAwait(false); + } +} + +internal class RemoveSpamExecutor : Executor, IMessageHandler +{ + public async ValueTask HandleAsync(bool message, IExecutionContext context) + { + if (!message) + { + // This is NOT SPAM, and should not have been routed here + throw new InvalidOperationException("Received a non-spam message that should not be getting removed."); + } + + await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay + + await context.AddEventAsync(new WorkflowCompletedEvent { Data = "Spam message removed." }) + .ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs new file mode 100644 index 0000000000..328b20702f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Workflows.Core; + +namespace Microsoft.Agents.Orchestration.Workflows.Sample; + +internal static class Step2bEntryPoint +{ + public static ValueTask RunAsync() + { + GuessNumberExecutor guessNumber = new(1, 100); + JudgeExecutor judge = new(42); // Let's say the target number is 42 + + Workflow workflow = new WorkflowBuilder(guessNumber) + .AddLoop(guessNumber, judge) + .Build(); + + // async foreach (var event in workflow.RunAsync(NumberSignal.Init)) + // await Console.Out.WriteLineAsync(event); + + return CompletedValueTaskSource.Completed; + } +} + +internal enum NumberSignal +{ + Init, + Above, + Below, + Matched +} + +internal class GuessNumberExecutor : Executor, IMessageHandler +{ + public int LowerBound { get; private set; } + public int UpperBound { get; private set; } + + public GuessNumberExecutor(int lowerBound, int upperBound) + { + this.LowerBound = lowerBound; + this.UpperBound = upperBound; + } + + private int NextGuess => (this.LowerBound + this.UpperBound) / 2; + + private int _currGuess = -1; + public async ValueTask HandleAsync(NumberSignal message, IExecutionContext context) + { + switch (message) + { + case NumberSignal.Matched: + await context.AddEventAsync(new WorkflowCompletedEvent { Data = $"Guessed the number: {this._currGuess}" }) + .ConfigureAwait(false); + break; + + case NumberSignal.Above: + this.UpperBound = this._currGuess - 1; + break; + case NumberSignal.Below: + this.LowerBound = this._currGuess + 1; + break; + } + + return this._currGuess = this.NextGuess; + } +} + +internal class JudgeExecutor : Executor, IMessageHandler +{ + private readonly int _targetNumber; + + public JudgeExecutor(int targetNumber) + { + this._targetNumber = targetNumber; + } + + public ValueTask HandleAsync(int message, IExecutionContext context) + { + if (message == this._targetNumber) + { + return CompletedValueTaskSource.FromResult(NumberSignal.Matched); + } + else if (message < this._targetNumber) + { + return CompletedValueTaskSource.FromResult(NumberSignal.Below); + } + else + { + return CompletedValueTaskSource.FromResult(NumberSignal.Above); + } + } +} From dbf337c042d198d50ea1839048c322b825bfa482 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 29 Jul 2025 15:28:41 -0400 Subject: [PATCH 054/232] refactor: Move Workflows classes to separate assembly --- dotnet/agent-framework-dotnet.slnx | 2 ++ .../Core/CompletedValueTaskSource.cs | 2 +- .../Core/DisposableObject.cs | 2 +- .../Core/Events.cs | 12 ++----- .../Core/ExecutionContext.cs | 2 +- .../Core/Executor.cs | 2 +- .../Core/Message.cs | 2 +- .../Core/MessageHandler.cs | 2 +- .../Core/MessageRouting.cs | 4 +-- .../Core/TypeErasure.cs | 2 +- .../Microsoft.Agents.Workflow.csproj | 32 +++++++++++++++++++ .../WorkflowBuilder.cs | 4 +-- .../WorkflowBuilderExtensions.cs | 4 +-- ...Microsoft.Agents.Workflow.UnitTests.csproj | 17 ++++++++++ .../Sample/02_Simple_Workflow_Sequential.cs | 8 ++--- .../Sample/02a_Simple_Workflow_Condition.cs | 10 +++--- .../Sample/02b_Simple_Workflow_Loop.cs | 8 ++--- 17 files changed, 80 insertions(+), 35 deletions(-) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/CompletedValueTaskSource.cs (91%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/DisposableObject.cs (97%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/Events.cs (89%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/ExecutionContext.cs (97%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/Executor.cs (99%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/Message.cs (98%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/MessageHandler.cs (96%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/MessageRouting.cs (99%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/TypeErasure.cs (97%) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/WorkflowBuilder.cs (99%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/WorkflowBuilderExtensions.cs (97%) create mode 100644 dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj rename dotnet/{src/Microsoft.Agents.Orchestration/Workflows => tests/Microsoft.Agents.Workflow.UnitTests}/Sample/02_Simple_Workflow_Sequential.cs (79%) rename dotnet/{src/Microsoft.Agents.Orchestration/Workflows => tests/Microsoft.Agents.Workflow.UnitTests}/Sample/02a_Simple_Workflow_Condition.cs (88%) rename dotnet/{src/Microsoft.Agents.Orchestration/Workflows => tests/Microsoft.Agents.Workflow.UnitTests}/Sample/02b_Simple_Workflow_Loop.cs (89%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 6420da7f0a..91cbf5db1f 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -116,6 +116,7 @@ + @@ -128,6 +129,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs similarity index 91% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs index e33b614d20..543ead1e98 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs @@ -2,7 +2,7 @@ using System.Threading.Tasks; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// Helper class to work around lack of proper ValueTask support in .NET Framework. diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs similarity index 97% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs index d147b7d78d..a754870660 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs @@ -5,7 +5,7 @@ using System.Threading; using System.Threading.Tasks; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// Provides a base class implementing the interface using diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs similarity index 89% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs index 51a23842c8..041944ea36 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs @@ -2,7 +2,7 @@ using System; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// . @@ -58,10 +58,7 @@ public record ExecutorInvokeEvent : ExecutorEvent /// /// . /// - public ExecutorInvokeEvent(string executorId, object? data = null) : base(data) - { - this.ExecutorId = executorId ?? throw new ArgumentNullException(nameof(executorId), "Executor ID cannot be null."); - } + public ExecutorInvokeEvent(string executorId, object? data = null) : base(executorId, data) { } #if NET9_0_OR_GREATER /// @@ -80,10 +77,7 @@ public record ExecutorCompleteEvent : ExecutorEvent /// /// . /// - public ExecutorCompleteEvent(string executorId, object? data = null) : base(data) - { - this.ExecutorId = executorId ?? throw new ArgumentNullException(nameof(executorId), "Executor ID cannot be null."); - } + public ExecutorCompleteEvent(string executorId, object? data = null) : base(executorId, data) { } #if NET9_0_OR_GREATER /// diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs similarity index 97% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs index 60beaa88ee..2c165505eb 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Threading.Tasks; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// Provides services for subclasses. diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs similarity index 99% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index d5ea990084..46084d5afc 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -8,7 +8,7 @@ using System.Reflection; using System.Threading.Tasks; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// A tag interface for objects that have a unique identifier within an appropriate namespace. diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs similarity index 98% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs index 3b8eda2482..c3c6bd2bbe 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs @@ -9,7 +9,7 @@ using RetryExceptionT = System.InvalidOperationException; using TopicId = string; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// . diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs similarity index 96% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs index 009a5aabc6..a02c1fa042 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs @@ -2,7 +2,7 @@ using System.Threading.Tasks; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// A message handler interface for handling messages of type . diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs similarity index 99% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs index 53a22b88d4..9818599161 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs @@ -11,10 +11,10 @@ using HandlerInfosT = System.Collections.Generic.Dictionary< System.Type, - Microsoft.Agents.Orchestration.Workflows.Core.MessageHandlerInfo + Microsoft.Agents.Workflows.Core.MessageHandlerInfo >; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// This attribute indicates that a message handler streams messages during its execution. diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/TypeErasure.cs similarity index 97% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/TypeErasure.cs index 62edbb4d85..8166a7a5c6 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/TypeErasure.cs @@ -4,7 +4,7 @@ using System.Reflection; using System.Threading.Tasks; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; internal static class ValueTaskTypeErasure { diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj new file mode 100644 index 0000000000..8d7a3265b0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj @@ -0,0 +1,32 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + Microsoft.Agents.Workflow + alpha + + + + true + true + + + + + + + Microsoft Agent Workflow Framework + Contains the Microsoft Agent Workflow Framework. + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs similarity index 99% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs rename to dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index cc17f2b335..d7167c53c6 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -6,12 +6,12 @@ using System.Linq; using System.Text; using System.Threading.Tasks; -using Microsoft.Agents.Orchestration.Workflows.Core; +using Microsoft.Agents.Workflows.Core; #pragma warning restore IDE0005 // Using directive is unnecessary. using ConditionalT = System.Func; -namespace Microsoft.Agents.Orchestration.Workflows; +namespace Microsoft.Agents.Workflows; internal delegate TExecutor ExecutorProvider() where TExecutor : Executor; diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs similarity index 97% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs rename to dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index a0084486b9..b0a14f0f0e 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -4,9 +4,9 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Threading.Tasks; -using Microsoft.Agents.Orchestration.Workflows.Core; +using Microsoft.Agents.Workflows.Core; -namespace Microsoft.Agents.Orchestration.Workflows; +namespace Microsoft.Agents.Workflows; internal static class Check { diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj new file mode 100644 index 0000000000..f4e4b48d84 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj @@ -0,0 +1,17 @@ + + + + $(ProjectsTargetFrameworks) + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs similarity index 79% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs rename to dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs index 66fc4c5c85..d9af8f9f3a 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System.Threading.Tasks; -using Microsoft.Agents.Orchestration.Workflows.Core; +using Microsoft.Agents.Workflows.Core; -namespace Microsoft.Agents.Orchestration.Workflows.Sample; +namespace Microsoft.Agents.Workflows.Sample; internal static class Step2EntryPoint { @@ -23,7 +23,7 @@ public static ValueTask RunAsync() } } -internal class UppercaseExecutor : Executor, IMessageHandler +internal sealed class UppercaseExecutor : Executor, IMessageHandler { public ValueTask HandleAsync(string message, IExecutionContext context) { @@ -31,7 +31,7 @@ public ValueTask HandleAsync(string message, IExecutionContext context) } } -internal class ReverseTextExecutor : Executor, IMessageHandler +internal sealed class ReverseTextExecutor : Executor, IMessageHandler { public ValueTask HandleAsync(string message, IExecutionContext context) { diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs similarity index 88% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs rename to dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs index 47c8620ade..42f23a7dd4 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs @@ -3,9 +3,9 @@ using System; using System.Linq; using System.Threading.Tasks; -using Microsoft.Agents.Orchestration.Workflows.Core; +using Microsoft.Agents.Workflows.Core; -namespace Microsoft.Agents.Orchestration.Workflows.Sample; +namespace Microsoft.Agents.Workflows.Sample; internal static class Step2aEntryPoint { @@ -29,7 +29,7 @@ public static ValueTask RunAsync() } } -internal class DetectSpamExecutor : Executor, IMessageHandler +internal sealed class DetectSpamExecutor : Executor, IMessageHandler { public string[] SpamKeywords { get; } @@ -50,7 +50,7 @@ public ValueTask HandleAsync(string message, IExecutionContext context) } } -internal class RespondToMessageExecutor : Executor, IMessageHandler +internal sealed class RespondToMessageExecutor : Executor, IMessageHandler { public async ValueTask HandleAsync(bool message, IExecutionContext context) { @@ -67,7 +67,7 @@ await context.AddEventAsync(new WorkflowCompletedEvent { Data = "Message process } } -internal class RemoveSpamExecutor : Executor, IMessageHandler +internal sealed class RemoveSpamExecutor : Executor, IMessageHandler { public async ValueTask HandleAsync(bool message, IExecutionContext context) { diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs similarity index 89% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs rename to dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs index 328b20702f..c235352725 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System.Threading.Tasks; -using Microsoft.Agents.Orchestration.Workflows.Core; +using Microsoft.Agents.Workflows.Core; -namespace Microsoft.Agents.Orchestration.Workflows.Sample; +namespace Microsoft.Agents.Workflows.Sample; internal static class Step2bEntryPoint { @@ -31,7 +31,7 @@ internal enum NumberSignal Matched } -internal class GuessNumberExecutor : Executor, IMessageHandler +internal sealed class GuessNumberExecutor : Executor, IMessageHandler { public int LowerBound { get; private set; } public int UpperBound { get; private set; } @@ -66,7 +66,7 @@ await context.AddEventAsync(new WorkflowCompletedEvent { Data = $"Guessed the nu } } -internal class JudgeExecutor : Executor, IMessageHandler +internal sealed class JudgeExecutor : Executor, IMessageHandler { private readonly int _targetNumber; From 4b74ce3e32af7c5a93c1784954cfa107f3a40912 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 30 Jul 2025 12:34:40 -0400 Subject: [PATCH 055/232] feat: Move FanOut/In to LowLevel API with new semantics --- .../WorkflowBuilder.cs | 165 +++++++++++++++--- .../WorkflowBuilderExtensions.cs | 133 +------------- 2 files changed, 143 insertions(+), 155 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index d7167c53c6..f62008f15f 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -7,6 +7,8 @@ using System.Text; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; + #pragma warning restore IDE0005 // Using directive is unnecessary. using ConditionalT = System.Func; @@ -16,21 +18,21 @@ namespace Microsoft.Agents.Workflows; internal delegate TExecutor ExecutorProvider() where TExecutor : Executor; -internal struct EdgeKey : IEquatable -{ - public string SourceId { get; init; } - public string TargetId { get; init; } +//internal struct EdgeKey : IEquatable +//{ +// public string SourceId { get; init; } +// public string TargetId { get; init; } - public EdgeKey(string sourceId, string targetId) - { - this.SourceId = sourceId ?? throw new ArgumentNullException(nameof(sourceId)); - this.TargetId = targetId ?? throw new ArgumentNullException(nameof(targetId)); - } +// public EdgeKey(string sourceId, string targetId) +// { +// this.SourceId = sourceId ?? throw new ArgumentNullException(nameof(sourceId)); +// this.TargetId = targetId ?? throw new ArgumentNullException(nameof(targetId)); +// } - public bool Equals(EdgeKey other) => this.SourceId == other.SourceId && this.TargetId == other.TargetId; - public override bool Equals(object? obj) => obj is EdgeKey other && this.Equals(other); - public override int GetHashCode() => HashCode.Combine(this.SourceId, this.TargetId); -} +// public bool Equals(EdgeKey other) => this.SourceId == other.SourceId && this.TargetId == other.TargetId; +// public override bool Equals(object? obj) => obj is EdgeKey other && this.Equals(other); +// public override int GetHashCode() => HashCode.Combine(this.SourceId, this.TargetId); +//} /// /// . @@ -177,6 +179,83 @@ public override string ToString() } } +internal record DirectEdgeData( + ExecutorIsh Source, + ExecutorIsh Sink, + Func? Condition) +{ + public static implicit operator FlowEdgeEx(DirectEdgeData data) + { + return new FlowEdgeEx(data); + } +} + +internal record FanOutEdgeData( + ExecutorIsh Source, + IEnumerable Sinks, + Func>? Partitioner) // TODO: Should this be IList (to imply an ordering?)? +{ + public static implicit operator FlowEdgeEx(FanOutEdgeData data) + { + return new FlowEdgeEx(data); + } +} + +internal enum FanInTrigger +{ + WhenAll, + WhenAny +} + +internal record FanInEdgeData( + IEnumerable Sources, + ExecutorIsh Sink, + FanInTrigger Trigger = FanInTrigger.WhenAll) +{ + public static implicit operator FlowEdgeEx(FanInEdgeData data) + { + return new FlowEdgeEx(data); + } +} + +internal class FlowEdgeEx +{ + public enum Type + { + Direct, + FanOut, + FanIn + } + + public Type EdgeType { get; init; } + public object Data { get; init; } + + public FlowEdgeEx(DirectEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.Direct; + } + + public FlowEdgeEx(FanOutEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.FanOut; + } + + public FlowEdgeEx(FanInEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.FanIn; + } + + public DirectEdgeData? DirectEdgeData => this.Data as DirectEdgeData; + public FanOutEdgeData? FanOutEdgeData => this.Data as FanOutEdgeData; + public FanInEdgeData? FanInEdgeData => this.Data as FanInEdgeData; +} + internal class FlowEdge(ExecutorIsh source, ExecutorIsh sink, ConditionalT? conditional) : IEquatable { public ExecutorIsh Source { get; init; } = source ?? throw new ArgumentNullException(nameof(source)); @@ -197,7 +276,7 @@ public bool Equals(FlowEdge? other) internal class Workflow { public Dictionary> Executors { get; internal init; } = new(); - public Dictionary> Edges { get; internal init; } = new(); + public Dictionary> Edges { get; internal init; } = new(); #if NET9_0_OR_GREATER required @@ -243,7 +322,7 @@ public Workflow() internal class WorkflowBuilder { private readonly Dictionary> _executors = new(); - private readonly Dictionary> _edges = new(); + private readonly Dictionary> _edges = new(); private readonly HashSet _unboundExecutors = new(); private readonly string _startExecutorId; @@ -292,29 +371,57 @@ public WorkflowBuilder BindExecutor(Executor executor) return this; } + private HashSet EnsureEdgesFor(string sourceId) + { + // Ensure that there is a set of edges for the given source ID. + // If it does not exist, create a new one. + if (!this._edges.TryGetValue(sourceId, out HashSet? edges)) + { + this._edges[sourceId] = edges = new HashSet(); + } + + return edges; + } + public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func? condition = null) { // Add an edge from source to target with an optional condition. // This is a low-level builder method that does not enforce any specific executor type. // The condition can be used to determine if the edge should be followed based on the input. + Throw.IfNull(source); + Throw.IfNull(target); - if (source == null) - { - throw new ArgumentNullException(nameof(source)); - } + this.EnsureEdgesFor(source.Id) + .Add(new DirectEdgeData(this.Track(source), this.Track(target), condition)); - if (target == null) - { - throw new ArgumentNullException(nameof(target)); - } + return this; + } - if (!this._edges.TryGetValue(source.Id, out HashSet? edges)) - { - edges = new HashSet(); - this._edges[source.Id] = edges; - } + public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func>? partitioner = null, params ExecutorIsh[] targets) + { + Throw.IfNull(source); + Throw.IfNullOrEmpty(targets); + + this.EnsureEdgesFor(source.Id) + .Add(new FanOutEdgeData( + this.Track(source), + targets.Select(target => this.Track(target)), + partitioner)); + + return this; + } + + public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = default, params ExecutorIsh[] sources) + { + Throw.IfNull(target); + Throw.IfNullOrEmpty(sources); + + this.EnsureEdgesFor(target.Id) + .Add(new FanInEdgeData( + sources.Select(source => this.Track(source)), + this.Track(target), + trigger)); - edges.Add(new FlowEdge(this.Track(source), this.Track(target), condition)); return this; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index b0a14f0f0e..1ce2649e2e 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -1,38 +1,17 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; -internal static class Check -{ - public static T NotNull(T? value, [CallerArgumentExpression(nameof(value))] string? paramExpr = null) where T : class - { - if (value is null) - { - throw new ArgumentNullException(nameof(value), $"Value cannot be null: {paramExpr}"); - } - - return value; - } -} - -internal enum Activation -{ - WhenAll, -} - internal static class WorkflowBuilderExtensions { public static WorkflowBuilder AddLoop(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh loopBody, Func? condition = null) { - Check.NotNull(builder); - Check.NotNull(source); - Check.NotNull(loopBody); + Throw.IfNull(builder); + Throw.IfNull(source); + Throw.IfNull(loopBody); builder.AddEdge(source, loopBody, condition); builder.AddEdge(loopBody, source); @@ -42,114 +21,16 @@ public static WorkflowBuilder AddLoop(this WorkflowBuilder builder, ExecutorIsh public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors) { - Check.NotNull(builder); - Check.NotNull(source); + Throw.IfNull(builder); + Throw.IfNull(source); for (int i = 0; i < executors.Length; i++) { - Check.NotNull(executors[i], nameof(executors) + $"[{i}]"); + Throw.IfNull(executors[i], nameof(executors) + $"[{i}]"); builder.AddEdge(source, executors[i]); source = executors[i]; } return builder; } - - private class FanOutMessage(object message) - { - public object Content = message ?? throw new ArgumentNullException(nameof(message), "Message cannot be null"); - } - - private class FanInMessage(IEnumerable? message = null) - { - public static readonly FanInMessage Pending = new(); - - public bool IsCompleted => this.Result is not null; - public IEnumerable? Result = message; - } - - private class FanOutExecutor : Executor, IMessageHandler - { - public ValueTask HandleAsync(object message, IExecutionContext context) - { - return new ValueTask(new FanOutMessage(message)); - } - } - - public static WorkflowBuilder AddFanOut(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] targets) - { - Check.NotNull(builder); - Check.NotNull(source); - - FanOutExecutor fanOut = new(); - builder.AddEdge(source, fanOut); - - foreach (var target in targets) - { - Check.NotNull(target); - builder.AddEdge(fanOut, target); - } - - return builder; - } - - private class FanInExecutor : Executor, - IMessageHandler - { -#if NET9_0_OR_GREATER - required -#endif - public int SourceCount - { get; init; } - - public Activation Activation { get; init; } = Activation.WhenAll; - - private readonly List _messages = []; - public ValueTask HandleAsync(FanOutMessage message, IExecutionContext context) - { - this._messages.Add(message.Content); - - if (this._messages.Count >= this.SourceCount) - { - return new ValueTask(new FanInMessage(this._messages.ToArray())); - } - - return CompletedValueTaskSource.FromResult(FanInMessage.Pending); - } - } - - private class FanInUnwrapper : Executor, - IMessageHandler> - { - public ValueTask> HandleAsync(FanInMessage message, IExecutionContext context) - { - return CompletedValueTaskSource.FromResult(message.Result!); - } - } - - public static WorkflowBuilder AddFanIn(this WorkflowBuilder builder, ExecutorIsh target, Activation activation = Activation.WhenAll, params ExecutorIsh[] sources) - { - Check.NotNull(builder); - Check.NotNull(target); - - FanInExecutor fanIn = new() - { - Activation = activation, - SourceCount = sources.Length - }; - FanInUnwrapper unwrapper = new(); - - builder.AddEdge(fanIn, unwrapper, IsFanInCompleted); - builder.AddEdge(unwrapper, target); - - foreach (var source in sources) - { - Check.NotNull(source); - builder.AddEdge(source, fanIn); - } - - return builder; - - static bool IsFanInCompleted(object? message) => message is FanInMessage fanIn && fanIn.IsCompleted; - } } From 5de0bf71fc24f030087ef18087156a5dd9b7190f Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 4 Aug 2025 13:51:54 -0400 Subject: [PATCH 056/232] feat: Implement Local Execution --- .../Core/CompletedValueTaskSource.cs | 4 - .../Microsoft.Agents.Workflow/Core/Edges.cs | 89 +++++ .../Core/ExecutionContext.cs | 55 --- .../Core/Executor.cs | 8 +- .../Core/IWorkflowContext.cs | 27 ++ .../Core/MessageHandler.cs | 4 +- .../Core/MessageRouting.cs | 25 +- .../Core/Workflow.cs | 78 ++++ .../Execution/EdgeRunner.cs | 171 +++++++++ .../Execution/IRunnerContext.cs | 18 + .../Execution/Identity.cs | 60 ++++ .../Execution/LocalRunner.cs | 191 ++++++++++ .../Execution/LocalRunnerContext.cs | 79 ++++ .../Execution/StepContext.cs | 24 ++ .../ExecutionResult.cs | 10 + .../Microsoft.Agents.Workflow/ExecutorIsh.cs | 144 ++++++++ .../Microsoft.Agents.Workflow.csproj | 7 + .../OutputCollectorExecutor.cs | 30 ++ .../StreamingAggregators.cs | 64 ++++ .../WorkflowBuilder.cs | 339 ++---------------- .../WorkflowBuilderExtensions.cs | 36 ++ .../Sample/02_Simple_Workflow_Sequential.cs | 4 +- .../Sample/02a_Simple_Workflow_Condition.cs | 6 +- .../Sample/02b_Simple_Workflow_Loop.cs | 4 +- 24 files changed, 1077 insertions(+), 400 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs index 543ead1e98..2c8cec1e81 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs @@ -18,10 +18,6 @@ internal static class CompletedValueTaskSource internal static ValueTask FromResult(T result) { -#if NET5_0_OR_GREATER return new ValueTask(result); -#else - return new ValueTask(Task.FromResult(result)); -#endif } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs new file mode 100644 index 0000000000..b17d8ebb54 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +using PredicateT = System.Func; +using PartitionerT = System.Func>; +using System; + +namespace Microsoft.Agents.Workflows.Core; + +internal record DirectEdgeData( + string SourceId, + string SinkId, + PredicateT? Condition = null) +{ + public static implicit operator FlowEdge(DirectEdgeData data) + { + return new FlowEdge(data); + } +} + +internal record FanOutEdgeData( + string SourceId, + List SinkIds, + PartitionerT? Partitioner = null) +{ + public static implicit operator FlowEdge(FanOutEdgeData data) + { + return new FlowEdge(data); + } +} + +internal enum FanInTrigger +{ + WhenAll, + WhenAny +} + +internal record FanInEdgeData( + IEnumerable SourceIds, + string SinkId, + FanInTrigger Trigger = FanInTrigger.WhenAll) +{ + internal Guid UniqueKey { get; } = Guid.NewGuid(); + + public static implicit operator FlowEdge(FanInEdgeData data) + { + return new FlowEdge(data); + } +} + +internal class FlowEdge +{ + public enum Type + { + Direct, + FanOut, + FanIn + } + + public Type EdgeType { get; init; } + public object Data { get; init; } + + public FlowEdge(DirectEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.Direct; + } + + public FlowEdge(FanOutEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.FanOut; + } + + public FlowEdge(FanInEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.FanIn; + } + + public DirectEdgeData? DirectEdgeData => this.Data as DirectEdgeData; + public FanOutEdgeData? FanOutEdgeData => this.Data as FanOutEdgeData; + public FanInEdgeData? FanInEdgeData => this.Data as FanInEdgeData; +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs deleted file mode 100644 index 2c165505eb..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// Provides services for subclasses. -/// -public interface IExecutionContext -{ - /// - /// Send a message from the executor to the context. - /// - /// The id of the sender of the message. - /// The message to be sent. - /// A representing the asynchronous operation. - ValueTask SendMessageAsync(string sourceId, object message); - - /// - /// Drain all messages from the context. - /// - /// A representing the asynchronous operation, containing - /// a dictionary mapping executor IDs to lists of messages. - ValueTask>> DrainMessagesAsync(); - - /// - /// Check if there are any message in the context. - /// - /// A representing the asynchronous operation, containing - /// true if there are messages. false if there are not. - ValueTask HasMessagesAsync(); - - /// - /// Add an event to the execution context. - /// - /// The event to be added. - /// A representing the asynchronous operation. - ValueTask AddEventAsync(WorkflowEvent workflowEvent); - - /// - /// Drain all events from the context. - /// - /// A representing the asynchronous operation, containing - /// a list of all events. - ValueTask> DrainEventsAsync(); - - /// - /// Check if there are any events in the context. - /// - /// A representing the asynchronous operation, containing - /// true if there are events. false if there are not. - ValueTask HasEventsAsync(); -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 46084d5afc..1767c0cbaa 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -100,7 +100,7 @@ public abstract class Executor : DisposableObject, IIdentified /// public string Name { get; } - private MessageRouter MessageRouter { get; init; } + internal MessageRouter MessageRouter { get; init; } private Dictionary State { get; } = new(); /// @@ -124,7 +124,7 @@ protected Executor(string? id = null, string? name = null) /// /// /// - public async ValueTask ExecuteAsync(object message, IExecutionContext context) + public async ValueTask ExecuteAsync(object message, IWorkflowContext context) { CallResult? result = await this.MessageRouter.RouteMessageAsync(message, context, requireRoute: true) .ConfigureAwait(false); @@ -173,7 +173,7 @@ protected Executor(string? id = null, string? name = null) /// /// /// - public async ValueTask InitializeAsync(IExecutionContext context) + public async ValueTask InitializeAsync(IWorkflowContext context) { if (this._initialized) { @@ -248,7 +248,7 @@ protected virtual ValueTask AfterCheckpointRestoreAsync() /// /// /// - protected virtual ValueTask InitializeOverride(IExecutionContext context) + protected virtual ValueTask InitializeOverride(IWorkflowContext context) { // Default implementation does nothing. return CompletedValueTaskSource.Completed; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs new file mode 100644 index 0000000000..decf8ce8d4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// Provides services for an during the execution of a workflow. +/// +public interface IWorkflowContext +{ + /// + /// . + /// + /// + /// + ValueTask AddEventAsync(WorkflowEvent workflowEvent); + + /// + /// . + /// + /// + /// + ValueTask SendMessageAsync(object message); + + // TODO: State management +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs index a02c1fa042..1da548d9ba 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs @@ -16,7 +16,7 @@ public interface IMessageHandler /// The message to handle. /// The execution context. /// A task that represents the asynchronous operation. - ValueTask HandleAsync(TMessage message, IExecutionContext context); + ValueTask HandleAsync(TMessage message, IWorkflowContext context); } /// @@ -33,5 +33,5 @@ public interface IMessageHandler /// The message to handle. /// The execution context. /// A task that represents the asynchronous operation. - ValueTask HandleAsync(TMessage message, IExecutionContext context); + ValueTask HandleAsync(TMessage message, IWorkflowContext context); } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs index 9818599161..f7335c99e9 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; using HandlerInfosT = System.Collections.Generic.Dictionary< @@ -133,7 +134,7 @@ public MessageHandlerInfo(MethodInfo handlerInfo) throw new ArgumentException("Handler method must have exactly two parameters: TMessage and IExecutionContext.", nameof(handlerInfo)); } - if (parameters[1].ParameterType != typeof(IExecutionContext)) + if (parameters[1].ParameterType != typeof(IWorkflowContext)) { throw new ArgumentException("Handler method's second parameter must be of type IExecutionContext.", nameof(handlerInfo)); } @@ -163,7 +164,7 @@ public MessageHandlerInfo(MethodInfo handlerInfo) } } - public Func> Bind(Executor executor, bool checkType = false) + public Func> Bind(Executor executor, bool checkType = false) { Type? resultType = this.OutType; MethodInfo handlerMethod = this.HandlerInfo; @@ -172,13 +173,13 @@ public Func> Bind(Executor executor, bool checkTyp return InvokeHandlerAsync; // Create a delegate that binds the handler to the executor. - async ValueTask InvokeHandlerAsync(object message) + async ValueTask InvokeHandlerAsync(object message, IWorkflowContext workflowContext) { bool expectingVoid = resultType == null || resultType == typeof(void); try { - object? maybeValueTask = handlerMethod.Invoke(executor, new object[] { message, executor }); + object? maybeValueTask = handlerMethod.Invoke(executor, new object[] { message, workflowContext }); if (expectingVoid) { @@ -230,7 +231,7 @@ internal class MessageRouter // TODO: The goal of the cache is to allow SourceGenerators to do the reflection to bind the handlers in the router. internal static readonly Dictionary> s_routerFactoryCache = new(); - private Dictionary>> BoundHandlers { get; init; } = new(); + private Dictionary>> BoundHandlers { get; init; } = new(); [SuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + @@ -306,18 +307,18 @@ internal static MessageRouter BindMessageHandlers(Executor executor, bool checkT // If no factory is found, reflect over the handlers HandlerInfosT handlers = ReflectHandlers(executor.GetType()); - Dictionary>> boundHandlers = new(); + Dictionary>> boundHandlers = new(); foreach (Type inType in handlers.Keys) { MessageHandlerInfo handlerInfo = handlers[inType]; - Func> boundHandler = handlerInfo.Bind(executor, checkType); + Func> boundHandler = handlerInfo.Bind(executor, checkType); boundHandlers.Add(inType, boundHandler); // TODO: Turn the error here into something more actionable. } return new MessageRouter(boundHandlers); } - internal MessageRouter(Dictionary>> handlers) + internal MessageRouter(Dictionary>> handlers) { this.BoundHandlers = handlers; } @@ -331,7 +332,7 @@ internal MessageRouter(Dictionary>> han /// /// /// - public async ValueTask RouteMessageAsync(object message, IExecutionContext context, bool requireRoute = true) + public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) { if (message == null) { @@ -340,14 +341,16 @@ internal MessageRouter(Dictionary>> han // TODO: Implement base type delegation CallResult? result = null; - if (this.BoundHandlers.TryGetValue(message.GetType(), out Func>? handler)) + if (this.BoundHandlers.TryGetValue(message.GetType(), out Func>? handler)) { - result = await handler(message).ConfigureAwait(false); + result = await handler(message, context).ConfigureAwait(false); } return result; } + public bool CanHandle(object message) => this.CanHandle(Throw.IfNull(message).GetType()); + public bool CanHandle(Type candidateType) { if (candidateType == null) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs new file mode 100644 index 0000000000..bb92518d7b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Core; + +internal class Workflow +{ + public Dictionary> ExecutorProviders { get; internal init; } = new(); + public Dictionary> Edges { get; internal init; } = new(); + +#if NET9_0_OR_GREATER + required +#endif + public string StartExecutorId + { get; init; } + +#if NET9_0_OR_GREATER + required +#endif + public Type InputType + { get; init; } = typeof(object); + + public Workflow(string startExecutorId, Type type) + { + this.StartExecutorId = startExecutorId ?? throw new ArgumentNullException(nameof(startExecutorId)); + this.InputType = type ?? throw new ArgumentNullException(nameof(type)); + + // TODO: How do we (1) ensure the types are happy, and (2) work under AOT? + } + +#if NET9_0_OR_GREATER + public Workflow() + { } +#endif +} + +internal class Workflow : Workflow +{ + public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) + { + } + +#if NET9_0_OR_GREATER + public Workflow() + { + this.InputType = typeof(T); + } +#endif + + internal Workflow Promote(OutputSink outputSource) + { + Throw.IfNull(outputSource); + + return new Workflow(this.StartExecutorId, outputSource) + { + StartExecutorId = this.StartExecutorId, + ExecutorProviders = this.ExecutorProviders, + Edges = this.Edges, + InputType = this.InputType, + }; + } +} + +internal class Workflow : Workflow +{ + private readonly OutputSink _output; + + internal Workflow(string startExecutorId, OutputSink outputSource) + : base(startExecutorId) + { + this._output = Throw.IfNull(outputSource); + } + + public TResult? RunningOutput => this._output.Result; +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs new file mode 100644 index 0000000000..05f6f6f887 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Execution; + +internal abstract class EdgeRunner( + IRunnerContext runContext, TEdgeData edgeData) +{ + protected IRunnerContext RunContext { get; } = Throw.IfNull(runContext); + protected TEdgeData EdgeData { get; } = Throw.IfNull(edgeData); +} + +internal class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData edgeData) : + EdgeRunner(runContext, edgeData) +{ + public IWorkflowContext WorkflowContext { get; } = runContext.Bind(edgeData.SinkId); + + private async ValueTask FindRouterAsync() + { + Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); + + return sink.MessageRouter; + } + + public async ValueTask> ChaseAsync(object message) + { + if (this.EdgeData.Condition != null && !this.EdgeData.Condition(message)) + { + return []; + } + + MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); + if (router.CanHandle(message)) + { + return [await router.RouteMessageAsync(message, this.WorkflowContext) + .ConfigureAwait(false)]; + } + + return []; + } +} + +internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeData) : + EdgeRunner(runContext, edgeData) +{ + private Dictionary BoundContexts { get; } + = edgeData.SinkIds.ToDictionary( + sinkId => sinkId, + sinkId => runContext.Bind(sinkId)); + + public async ValueTask> ChaseAsync(object message) + { + List targets = + this.EdgeData.Partitioner == null + ? this.EdgeData.SinkIds + : this.EdgeData.Partitioner(message, this.BoundContexts.Count).Select(i => this.EdgeData.SinkIds[i]).ToList(); + + CallResult?[] result = await Task.WhenAll(targets.Select(ProcessTargetAsync)).ConfigureAwait(false); + return result.Where(r => r is not null); + + async Task ProcessTargetAsync(string targetId) + { + Executor executor = await this.RunContext.EnsureExecutorAsync(targetId) + .ConfigureAwait(false); + + MessageRouter router = executor.MessageRouter; + if (router.CanHandle(message)) + { + return await router.RouteMessageAsync(message, this.BoundContexts[targetId]) + .ConfigureAwait(false); + } + + return null; + } + } +} + +internal record FanInEdgeState(FanInEdgeData EdgeData) +{ + private List? _pendingMessages + = EdgeData.Trigger == FanInTrigger.WhenAll ? [] : null; + + private HashSet? _unseen + = EdgeData.Trigger == FanInTrigger.WhenAll ? new(EdgeData.SourceIds) : null; + + public IEnumerable? ProcessMessage(string sourceId, object message) + { + if (this.EdgeData.Trigger == FanInTrigger.WhenAll) + { + this._pendingMessages!.Add(message); + this._unseen!.Remove(sourceId); + + if (this._unseen.Count == 0) + { + List result = this._pendingMessages; + + this._pendingMessages = []; + this._unseen = new(this.EdgeData.SourceIds); + + return result; + } + + return null; + } + + return [message]; + } +} + +internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData) : + EdgeRunner(runContext, edgeData) +{ + private IWorkflowContext BoundContext { get; } = runContext.Bind(edgeData.SinkId); + + public FanInEdgeState CreateState() => new(this.EdgeData); + + public async ValueTask ChaseAsync(string sourceId, object message, FanInEdgeState state) + { + IEnumerable? releasedMessages = state.ProcessMessage(sourceId, message); + if (releasedMessages is null) + { + // Not ready to process yet. + return null; + } + + Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); + + MessageRouter router = sink.MessageRouter; + if (router.CanHandle(message)) + { + return await router.RouteMessageAsync(message, this.BoundContext) + .ConfigureAwait(false); + } + return null; + } +} + +internal class InputEdgeRuner(IRunnerContext runContext, string sinkId) + : EdgeRunner(runContext, sinkId) +{ + public IWorkflowContext WorkflowContext { get; } = runContext.Bind(sinkId); + + private async ValueTask FindRouterAsync() + { + Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData) + .ConfigureAwait(false); + + return sink.MessageRouter; + } + + public async ValueTask ChaseAsync(object message) + { + MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); + if (router.CanHandle(message)) + { + return await router.RouteMessageAsync(message, this.WorkflowContext) + .ConfigureAwait(false); + } + + // TODO: Throw instead? + + return null; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs new file mode 100644 index 0000000000..78770036a9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal interface IRunnerContext +{ + ValueTask AddEventAsync(string executorId, WorkflowEvent workflowEvent); + ValueTask SendMessageAsync(string executorId, object message); + + // TODO: State Management + + StepContext Advance(); + IWorkflowContext Bind(string executorId); + ValueTask EnsureExecutorAsync(string executorId); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs new file mode 100644 index 0000000000..4c99a29cea --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Agents.Workflows.Execution; + +internal readonly struct Identity : IEquatable +{ + public static Identity None { get; } = new Identity(); + + public string? Id { get; init; } + + public bool Equals(Identity other) + { + return this.Id == null + ? other.Id == null + : other.Id != null && StringComparer.OrdinalIgnoreCase.Equals(this.Id, other.Id); + } + + public override bool Equals([NotNullWhen(true)] object? obj) + { + if (this.Id == null) + { + return obj == null; + } + + if (obj == null) + { + return false; + } + + if (obj is Identity id) + { + return id.Equals(this); + } + + if (obj is string idStr) + { + return StringComparer.OrdinalIgnoreCase.Equals(this.Id, idStr); + } + + return false; + } + + public override int GetHashCode() + { + return this.Id == null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(this.Id); + } + + public static implicit operator Identity(string? id) + { + return new Identity { Id = id }; + } + + public static implicit operator string?(Identity identity) + { + return identity.Id; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs new file mode 100644 index 0000000000..18e252c274 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class EdgeMap +{ + private readonly Dictionary _edgeRunners = new(); + private readonly Dictionary _fanInState = new(); + private readonly InputEdgeRuner _inputRunner; + + public EdgeMap(IRunnerContext runContext, Dictionary> workflowEdges, string startExecutorId) + { + foreach (FlowEdge edge in workflowEdges.Values.SelectMany(e => e)) + { + object edgeRunner = edge.EdgeType switch + { + FlowEdge.Type.Direct => new DirectEdgeRunner(runContext, edge.DirectEdgeData!), + FlowEdge.Type.FanOut => new FanOutEdgeRunner(runContext, edge.FanOutEdgeData!), + FlowEdge.Type.FanIn => new FanInEdgeRunner(runContext, edge.FanInEdgeData!), + _ => throw new NotSupportedException($"Unsupported edge type: {edge.EdgeType}") + }; + + this._edgeRunners[edge] = edgeRunner; + } + + this._inputRunner = new InputEdgeRuner(runContext, startExecutorId); + } + + public async ValueTask> InvokeEdgeAsync(FlowEdge edge, string sourceId, object message) + { + if (!this._edgeRunners.TryGetValue(edge, out object? edgeRunner)) + { + throw new InvalidOperationException($"Edge {edge} not found in the edge map."); + } + + IEnumerable edgeResults; + switch (edge.EdgeType) + { + // We know the corresponding EdgeRunner type given the FlowEdge EdgeType, as + // established in the EdgeMap() ctor; this avoid doing an as-cast inside of + // the depths of the message delivery loop for every edges (multiplicity N, + // in FanIn/Out cases) + // TODO: Once we have a fixed interface, if it is reasonably generalizable + // between the Runners, we can normalize it behind an IFace. + case FlowEdge.Type.Direct: + { + DirectEdgeRunner runner = (DirectEdgeRunner)this._edgeRunners[edge]; + edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); + break; + } + + case FlowEdge.Type.FanOut: + { + FanOutEdgeRunner runner = (FanOutEdgeRunner)this._edgeRunners[edge]; + edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); + break; + } + + case FlowEdge.Type.FanIn: + { + FanInEdgeState state = this._fanInState[edge]; + FanInEdgeRunner runner = (FanInEdgeRunner)this._edgeRunners[edge]; + edgeResults = [await runner.ChaseAsync(sourceId, message, state).ConfigureAwait(false)]; + break; + } + + default: + throw new InvalidOperationException("Unknown edge type"); + + } + + return edgeResults; + } + + // TODO: Should we promote Input to a true "FlowEdge" type? + public async ValueTask> InvokeInputAsync(object inputMessage) + { + return [await this._inputRunner.ChaseAsync(inputMessage).ConfigureAwait(false)]; + } + + public ValueTask> InvokeResponseAsync(object externalResponse) + { + throw new NotImplementedException(); + } +} + +internal class LocalRunner +{ + public LocalRunner(Workflow workflow) + { + this.Workflow = workflow ?? throw new ArgumentNullException(nameof(workflow)); + this.RunContext = new LocalRunnerContext(workflow); + + // 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.StartExecutorId); + } + + protected Workflow Workflow { get; init; } + protected LocalRunnerContext RunContext { get; init; } + protected EdgeMap EdgeMap { get; init; } + + // TODO: Better signature? + public event EventHandler? WorkflowEvent; + + private void RaiseWorkflowEvent(WorkflowEvent workflowEvent) + { + this.WorkflowEvent?.Invoke(this, workflowEvent); + } + + private bool IsResponse(object message) + { + return false; + } + + private ValueTask> RouteExternalMessageAsync(object message) + { +#pragma warning disable CS0219 // Variable is assigned but its value is never used + bool isHil = false; +#pragma warning restore CS0219 // Variable is assigned but its value is never used + + return this.IsResponse(message) + ? this.EdgeMap.InvokeResponseAsync(message) + : this.EdgeMap.InvokeInputAsync(message); + } + + public async Task RunAsync(TInput input) + { + await this.RunContext.AddExternalMessageAsync(input).ConfigureAwait(false); + + // Kick everything off by sending the first message to the start executor. + Executor startExecutor = await this.RunContext.EnsureExecutorAsync(this.Workflow.StartExecutorId) + .ConfigureAwait(false); + + for (StepContext currentStep = this.RunContext.Advance(); currentStep.HasMessages; currentStep = this.RunContext.Advance()) + { + // Deliver the messages and queue the next step + List>> edgeTasks = new(); + foreach (Identity sender in currentStep.QueuedMessages.Keys) + { + IEnumerable senderMessages = currentStep.QueuedMessages[sender]; + if (sender.Id is null) + { + edgeTasks.AddRange(senderMessages.Select(message => this.RouteExternalMessageAsync(message).AsTask())); + } + else + { + HashSet outgoingEdges = this.Workflow.Edges[sender.Id!]; // Id is not null when Identity is not .None + foreach (FlowEdge outgoingEdge in outgoingEdges) + { + edgeTasks.AddRange(senderMessages.Select(message => this.EdgeMap.InvokeEdgeAsync(outgoingEdge, sender.Id, message).AsTask())); + } + } + } + + IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); + + // After the message handler invocations, we may have some events to deliver + foreach (WorkflowEvent @event in this.RunContext.QueuedEvents) + { + // TODO + } + } + } +} + +internal class LocalRunner +{ + private readonly Workflow _workflow; + private readonly LocalRunner _innerRunner; + + public LocalRunner(Workflow workflow) + { + this._workflow = Throw.IfNull(workflow); + this._innerRunner = new LocalRunner(workflow); + } + + public async Task RunAsync(TInput input) + { + await this._innerRunner.RunAsync(input).ConfigureAwait(false); + } + + public TResult? RunningOutput => this._workflow.RunningOutput; +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs new file mode 100644 index 0000000000..450ae763e1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class LocalRunnerContext : IRunnerContext +{ + private StepContext _nextStep = new(); + private readonly Dictionary> _executorProviders; + private readonly Dictionary _executors = new(); + + public LocalRunnerContext(Workflow workflow, ILogger? logger = null) + { + this._executorProviders = Throw.IfNull(workflow).ExecutorProviders; + } + + public async ValueTask EnsureExecutorAsync(string executorId) + { + if (!this._executors.TryGetValue(executorId, out var executor)) + { + if (!this._executorProviders.TryGetValue(executorId, out var provider)) + { + throw new InvalidOperationException($"Executor with ID '{executorId}' is not registered."); + } + + this._executors[executorId] = executor = provider(); + + await executor.InitializeAsync(this.Bind(executor.Id)).ConfigureAwait(false); + } + + return executor; + } + + public ValueTask AddExternalMessageAsync([NotNull] TExternalInput message) + { + Throw.IfNull(message); + + this._nextStep.MessagesFor(Identity.None).Add(message); + return CompletedValueTaskSource.Completed; + } + + public StepContext Advance() + { + return Interlocked.Exchange(ref this._nextStep, new StepContext()); + } + + public ValueTask AddEventAsync(string executorId, WorkflowEvent workflowEvent) + { + this.QueuedEvents.Add(workflowEvent); + return CompletedValueTaskSource.Completed; + } + + public ValueTask SendMessageAsync(string executorId, object message) + { + this._nextStep.MessagesFor(message.GetType().Name).Add(message); + return CompletedValueTaskSource.Completed; + } + + public IWorkflowContext Bind(string executorId) + { + return new BoundContext(this, executorId); + } + + public readonly List QueuedEvents = new(); + + private class BoundContext(LocalRunnerContext RunnerContext, string ExecutorId) : IWorkflowContext + { + public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => RunnerContext.AddEventAsync(ExecutorId, workflowEvent); + public ValueTask SendMessageAsync(object message) => RunnerContext.SendMessageAsync(ExecutorId, message); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs new file mode 100644 index 0000000000..ba305a4b46 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class StepContext +{ + public Dictionary> QueuedMessages { get; } = new(); + + public bool HasMessages => this.QueuedMessages.Values.Any(messageList => messageList.Count > 0); + + public List MessagesFor(string? executorId) + { + if (!this.QueuedMessages.TryGetValue(executorId, out var messages)) + { + messages = new List(); + this.QueuedMessages[executorId] = messages; + } + + return messages; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs new file mode 100644 index 0000000000..a73bef38f1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows; + +/// +/// . +/// +public class ExecutionResult +{ +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs new file mode 100644 index 0000000000..f40fbf606f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows; + +internal sealed class ExecutorIsh : + IIdentified, + IEquatable, + IEquatable, + IEquatable +{ + public enum Type + { + Unbound, + Executor, + //Function, + //Agent, + //ProcessStep + } + + public Type ExecutorType { get; init; } + + private readonly string? _idValue; + private readonly Executor? _executorValue; + //private readonly Func? _functionValue; + + public ExecutorIsh(Executor executor) + { + this.ExecutorType = Type.Executor; + this._executorValue = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public ExecutorIsh(string id) + { + this.ExecutorType = Type.Unbound; + this._idValue = id ?? throw new ArgumentNullException(nameof(id)); + } + + public bool IsUnbound => this.ExecutorType == Type.Unbound; + + public string Id => this.ExecutorType switch + { + Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), + Type.Executor => this._executorValue!.Id, + //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), + //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), + //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), + _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + }; + + public ExecutorProvider ExecutorProvider => this.ExecutorType switch + { + Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), + Type.Executor => () => this._executorValue!, + //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), + //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), + //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), + _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + }; + + //public ExecutorIsh(Func function) + //{ + // this.ExecutorType = Type.Function; + // this._functionValue = function ?? throw new ArgumentNullException(nameof(function)); + //} + + // Implicit conversions into ExecutorIsh + public static implicit operator ExecutorIsh(Executor executor) + { + return new ExecutorIsh(executor); + } + + // How do we AoT compile this? + //public static implicit operator ExecutorIsh(Func function) + //{ + // return new ExecutorIsh(function); + //} + + public static implicit operator ExecutorIsh(string id) + { + return new ExecutorIsh(id); + } + + public bool Equals(ExecutorIsh? other) + { + return other is not null && + other.Id == this.Id; + } + + public bool Equals(IIdentified? other) + { + return other is not null && + other.Id == this.Id; + } + + public bool Equals(string? other) + { + return other is not null && + other == this.Id; + } + + public override bool Equals(object? obj) + { + if (obj is null) + { + return false; + } + + if (obj is ExecutorIsh ish) + { + return this.Equals(ish); + } + else if (obj is IIdentified identified) + { + return this.Equals(identified); + } + else if (obj is string str) + { + return this.Equals(str); + } + + return false; + } + + public override int GetHashCode() + { + return this.Id.GetHashCode(); + } + + public override string ToString() + { + return this.ExecutorType switch + { + Type.Unbound => $"'{this.Id}':", + Type.Executor => $"'{this.Id}':{this._executorValue!.GetType()}", + //Type.Function => $"ExecutorIsh for Function with ID '{this.Id}'", + //Type.Agent => $"ExecutorIsh for Agent with ID '{this.Id}'", + //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", + _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj index 8d7a3265b0..df4590d832 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj +++ b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj @@ -18,9 +18,11 @@ Microsoft Agent Workflow Framework Contains the Microsoft Agent Workflow Framework. + Microsoft.Agents.Workflows + @@ -29,4 +31,9 @@ + + + + + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs new file mode 100644 index 0000000000..3417b36161 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows; + +internal class OutputSink : Executor +{ + public TResult? Result { get; protected set; } = default; + + internal OutputSink(string? id = null) : base(id) + { } +} + +internal class OutputCollectorExecutor : OutputSink, IMessageHandler +{ + private readonly StreamingAggregator _aggregator; + public OutputCollectorExecutor(StreamingAggregator aggregator, string? id = null) : base(id) + { + this._aggregator = Throw.IfNull(aggregator); + } + + public ValueTask HandleAsync(TInput message, IWorkflowContext context) + { + this.Result = this._aggregator(message); + return CompletedValueTaskSource.Completed; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs b/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs new file mode 100644 index 0000000000..bd212f1856 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Agents.Workflows; + +internal delegate TResult? StreamingAggregator(TInput input); + +internal static class StreamingAggregators +{ + public static StreamingAggregator First(Func conversion, TResult? defaultValue = default) + { + bool hasRun = false; + TResult? local = defaultValue; + + return Aggregate; + + TResult? Aggregate(TInput input) + { + if (!hasRun) + { + local = conversion(input); + } + + return local; + } + } + + public static StreamingAggregator First(TInput? defaultValue = default) + => First(input => input, defaultValue); + + public static StreamingAggregator Last(Func conversion, TResult? defaultValue = default) + { + TResult? local = defaultValue; + + return Aggregate; + + TResult? Aggregate(TInput input) + { + local = conversion(input); + return local; + } + } + + public static StreamingAggregator Last(TInput? defaultValue = default) + => Last(input => input, defaultValue); + + public static StreamingAggregator> Union(Func conversion) + { + List results = new(); + + return Aggregate; + + IEnumerable Aggregate(TInput input) + { + results.Add(conversion(input)); + return results; + } + } + + public static StreamingAggregator> Union() + => Union(input => input); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index f62008f15f..d9932b0bca 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -6,323 +6,22 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Execution; using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; +using System.Collections.Concurrent; #pragma warning restore IDE0005 // Using directive is unnecessary. -using ConditionalT = System.Func; - namespace Microsoft.Agents.Workflows; internal delegate TExecutor ExecutorProvider() where TExecutor : Executor; -//internal struct EdgeKey : IEquatable -//{ -// public string SourceId { get; init; } -// public string TargetId { get; init; } - -// public EdgeKey(string sourceId, string targetId) -// { -// this.SourceId = sourceId ?? throw new ArgumentNullException(nameof(sourceId)); -// this.TargetId = targetId ?? throw new ArgumentNullException(nameof(targetId)); -// } - -// public bool Equals(EdgeKey other) => this.SourceId == other.SourceId && this.TargetId == other.TargetId; -// public override bool Equals(object? obj) => obj is EdgeKey other && this.Equals(other); -// public override int GetHashCode() => HashCode.Combine(this.SourceId, this.TargetId); -//} - -/// -/// . -/// -public class ExecutionResult -{ -} - -internal sealed class ExecutorIsh : - IIdentified, - IEquatable, - IEquatable, - IEquatable -{ - public enum Type - { - Unbound, - Executor, - //Function, - //Agent, - //ProcessStep - } - - public Type ExecutorType { get; init; } - - private readonly string? _idValue; - private readonly Executor? _executorValue; - //private readonly Func? _functionValue; - - public ExecutorIsh(Executor executor) - { - this.ExecutorType = Type.Executor; - this._executorValue = executor ?? throw new ArgumentNullException(nameof(executor)); - } - - public ExecutorIsh(string id) - { - this.ExecutorType = Type.Unbound; - this._idValue = id ?? throw new ArgumentNullException(nameof(id)); - } - - public bool IsUnbound => this.ExecutorType == Type.Unbound; - - public string Id => this.ExecutorType switch - { - Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), - Type.Executor => this._executorValue!.Id, - //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), - //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), - //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), - _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") - }; - - public ExecutorProvider ExecutorProvider => this.ExecutorType switch - { - Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), - Type.Executor => () => this._executorValue!, - //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), - //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), - //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), - _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") - }; - - //public ExecutorIsh(Func function) - //{ - // this.ExecutorType = Type.Function; - // this._functionValue = function ?? throw new ArgumentNullException(nameof(function)); - //} - - // Implicit conversions into ExecutorIsh - public static implicit operator ExecutorIsh(Executor executor) - { - return new ExecutorIsh(executor); - } - - // How do we AoT compile this? - //public static implicit operator ExecutorIsh(Func function) - //{ - // return new ExecutorIsh(function); - //} - - public static implicit operator ExecutorIsh(string id) - { - return new ExecutorIsh(id); - } - - public bool Equals(ExecutorIsh? other) - { - return other is not null && - other.Id == this.Id; - } - - public bool Equals(IIdentified? other) - { - return other is not null && - other.Id == this.Id; - } - - public bool Equals(string? other) - { - return other is not null && - other == this.Id; - } - - public override bool Equals(object? obj) - { - if (obj is null) - { - return false; - } - - if (obj is ExecutorIsh ish) - { - return this.Equals(ish); - } - else if (obj is IIdentified identified) - { - return this.Equals(identified); - } - else if (obj is string str) - { - return this.Equals(str); - } - - return false; - } - - public override int GetHashCode() - { - return this.Id.GetHashCode(); - } - - public override string ToString() - { - return this.ExecutorType switch - { - Type.Unbound => $"'{this.Id}':", - Type.Executor => $"'{this.Id}':{this._executorValue!.GetType()}", - //Type.Function => $"ExecutorIsh for Function with ID '{this.Id}'", - //Type.Agent => $"ExecutorIsh for Agent with ID '{this.Id}'", - //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", - _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") - }; - } -} - -internal record DirectEdgeData( - ExecutorIsh Source, - ExecutorIsh Sink, - Func? Condition) -{ - public static implicit operator FlowEdgeEx(DirectEdgeData data) - { - return new FlowEdgeEx(data); - } -} - -internal record FanOutEdgeData( - ExecutorIsh Source, - IEnumerable Sinks, - Func>? Partitioner) // TODO: Should this be IList (to imply an ordering?)? -{ - public static implicit operator FlowEdgeEx(FanOutEdgeData data) - { - return new FlowEdgeEx(data); - } -} - -internal enum FanInTrigger -{ - WhenAll, - WhenAny -} - -internal record FanInEdgeData( - IEnumerable Sources, - ExecutorIsh Sink, - FanInTrigger Trigger = FanInTrigger.WhenAll) -{ - public static implicit operator FlowEdgeEx(FanInEdgeData data) - { - return new FlowEdgeEx(data); - } -} - -internal class FlowEdgeEx -{ - public enum Type - { - Direct, - FanOut, - FanIn - } - - public Type EdgeType { get; init; } - public object Data { get; init; } - - public FlowEdgeEx(DirectEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.Direct; - } - - public FlowEdgeEx(FanOutEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.FanOut; - } - - public FlowEdgeEx(FanInEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.FanIn; - } - - public DirectEdgeData? DirectEdgeData => this.Data as DirectEdgeData; - public FanOutEdgeData? FanOutEdgeData => this.Data as FanOutEdgeData; - public FanInEdgeData? FanInEdgeData => this.Data as FanInEdgeData; -} - -internal class FlowEdge(ExecutorIsh source, ExecutorIsh sink, ConditionalT? conditional) : IEquatable -{ - public ExecutorIsh Source { get; init; } = source ?? throw new ArgumentNullException(nameof(source)); - public ExecutorIsh Sink { get; } = sink ?? throw new ArgumentNullException(nameof(sink)); - public Func? Condition { get; } = conditional; - - public bool Equals(FlowEdge? other) - { - return other is null - ? false - : this.Source.Equals(other.Source) && this.Sink.Equals(other.Sink); - } - - public override bool Equals(object? obj) => obj is FlowEdge other && this.Equals(other); - public override int GetHashCode() => HashCode.Combine(this.Source.GetHashCode(), this.Sink.GetHashCode()); -} - -internal class Workflow -{ - public Dictionary> Executors { get; internal init; } = new(); - public Dictionary> Edges { get; internal init; } = new(); - -#if NET9_0_OR_GREATER - required -#endif - public string StartExecutorId - { get; init; } - -#if NET9_0_OR_GREATER - required -#endif - public Type InputType - { get; init; } = typeof(object); - - public Workflow(string startExecutorId, Type type) - { - this.StartExecutorId = startExecutorId ?? throw new ArgumentNullException(nameof(startExecutorId)); - this.InputType = type ?? throw new ArgumentNullException(nameof(type)); - - // TODO: How do we (1) ensure the types are happy, and (2) work under AOT? - } - -#if NET9_0_OR_GREATER - public Workflow() - { } -#endif -} - -// Just a decorator for the purposes of keeping type type where we can -internal class Workflow : Workflow -{ - public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) - { - } - -#if NET9_0_OR_GREATER - public Workflow() - { - this.InputType = typeof(T); - } -#endif -} - internal class WorkflowBuilder { private readonly Dictionary> _executors = new(); - private readonly Dictionary> _edges = new(); + private readonly Dictionary> _edges = new(); private readonly HashSet _unboundExecutors = new(); private readonly string _startExecutorId; @@ -371,13 +70,13 @@ public WorkflowBuilder BindExecutor(Executor executor) return this; } - private HashSet EnsureEdgesFor(string sourceId) + private HashSet EnsureEdgesFor(string sourceId) { // Ensure that there is a set of edges for the given source ID. // If it does not exist, create a new one. - if (!this._edges.TryGetValue(sourceId, out HashSet? edges)) + if (!this._edges.TryGetValue(sourceId, out HashSet? edges)) { - this._edges[sourceId] = edges = new HashSet(); + this._edges[sourceId] = edges = new HashSet(); } return edges; @@ -392,20 +91,22 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func>? partitioner = null, params ExecutorIsh[] targets) + // output int strictly element-of [0, count) + + public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func>? partitioner = null, params ExecutorIsh[] targets) { Throw.IfNull(source); Throw.IfNullOrEmpty(targets); this.EnsureEdgesFor(source.Id) .Add(new FanOutEdgeData( - this.Track(source), - targets.Select(target => this.Track(target)), + this.Track(source).Id, + targets.Select(target => this.Track(target).Id).ToList(), partitioner)); return this; @@ -416,11 +117,15 @@ public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = d Throw.IfNull(target); Throw.IfNullOrEmpty(sources); - this.EnsureEdgesFor(target.Id) - .Add(new FanInEdgeData( - sources.Select(source => this.Track(source)), - this.Track(target), - trigger)); + FanInEdgeData edgeData = new( + sources.Select(source => this.Track(source).Id).ToList(), + this.Track(target).Id, + trigger); + + foreach (string sourceId in edgeData.SourceIds) + { + this.EnsureEdgesFor(sourceId).Add(edgeData); + } return this; } @@ -451,7 +156,7 @@ public Workflow Build() return new Workflow(this._startExecutorId) // Why does it not see the default ctor? { - Executors = this._executors, + ExecutorProviders = this._executors, Edges = this._edges, StartExecutorId = this._startExecutorId, InputType = typeof(T) diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index 1ce2649e2e..188ecb2d14 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; @@ -33,4 +34,39 @@ public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh return builder; } + + public static Workflow BuildWithOutput(this WorkflowBuilder builder, ExecutorIsh outputSource, StreamingAggregator aggregator) + => builder.BuildWithOutput(outputSource, aggregator); + + public static Workflow BuildWithOutput(this WorkflowBuilder builder, ExecutorIsh outputSource, StreamingAggregator aggregator) + { + Throw.IfNull(outputSource); + Throw.IfNull(aggregator); + + OutputCollectorExecutor outputSink = new(aggregator); + + // TODO: Check taht the outputSource has a TResult output? + builder.AddEdge(outputSource, outputSink); + + Workflow workflow = builder.Build(); + return workflow.Promote(outputSink); + } + + //public static WorkflowBuilder AddMapReduce } + +//class T +//{ +// async Task A() +// { +// WorkflowBuilder b; + +// Workflow> wf = +// WorkflowBuilderExtensions.BuildWithOutput>(b, "my_last_node", StreamingAggregators.Union()); + +// LocalRunner> runner = new(wf); + +// await runner.RunAsync(42).ConfigureAwait(false); +// var result = runner.RunningOutput; +// } +//} diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs index d9af8f9f3a..33004f8170 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs @@ -25,7 +25,7 @@ public static ValueTask RunAsync() internal sealed class UppercaseExecutor : Executor, IMessageHandler { - public ValueTask HandleAsync(string message, IExecutionContext context) + public ValueTask HandleAsync(string message, IWorkflowContext context) { return CompletedValueTaskSource.FromResult(message.ToUpperInvariant()); } @@ -33,7 +33,7 @@ public ValueTask HandleAsync(string message, IExecutionContext context) internal sealed class ReverseTextExecutor : Executor, IMessageHandler { - public ValueTask HandleAsync(string message, IExecutionContext context) + public ValueTask HandleAsync(string message, IWorkflowContext context) { char[] charArray = message.ToCharArray(); System.Array.Reverse(charArray); diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs index 42f23a7dd4..cb5d950b6d 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs @@ -38,7 +38,7 @@ public DetectSpamExecutor(params string[] spamKeywords) this.SpamKeywords = spamKeywords; } - public ValueTask HandleAsync(string message, IExecutionContext context) + public ValueTask HandleAsync(string message, IWorkflowContext context) { #if NET5_0_OR_GREATER bool isSpam = this.SpamKeywords.Any(keyword => message.Contains(keyword, StringComparison.OrdinalIgnoreCase)); @@ -52,7 +52,7 @@ public ValueTask HandleAsync(string message, IExecutionContext context) internal sealed class RespondToMessageExecutor : Executor, IMessageHandler { - public async ValueTask HandleAsync(bool message, IExecutionContext context) + public async ValueTask HandleAsync(bool message, IWorkflowContext context) { if (message) { @@ -69,7 +69,7 @@ await context.AddEventAsync(new WorkflowCompletedEvent { Data = "Message process internal sealed class RemoveSpamExecutor : Executor, IMessageHandler { - public async ValueTask HandleAsync(bool message, IExecutionContext context) + public async ValueTask HandleAsync(bool message, IWorkflowContext context) { if (!message) { diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs index c235352725..6ab2b232e5 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs @@ -45,7 +45,7 @@ public GuessNumberExecutor(int lowerBound, int upperBound) private int NextGuess => (this.LowerBound + this.UpperBound) / 2; private int _currGuess = -1; - public async ValueTask HandleAsync(NumberSignal message, IExecutionContext context) + public async ValueTask HandleAsync(NumberSignal message, IWorkflowContext context) { switch (message) { @@ -75,7 +75,7 @@ public JudgeExecutor(int targetNumber) this._targetNumber = targetNumber; } - public ValueTask HandleAsync(int message, IExecutionContext context) + public ValueTask HandleAsync(int message, IWorkflowContext context) { if (message == this._targetNumber) { From 82b1cf5e9e1965ccb291633b6ab1869b20bc1838 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 4 Aug 2025 14:02:48 -0400 Subject: [PATCH 057/232] refactor: Assembly name .Workflow => .Workflows --- dotnet/agent-framework-dotnet.slnx | 2 +- ...Agents.Workflow.csproj => Microsoft.Agents.Workflows.csproj} | 2 -- .../Microsoft.Agents.Workflow.UnitTests.csproj | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) rename dotnet/src/Microsoft.Agents.Workflow/{Microsoft.Agents.Workflow.csproj => Microsoft.Agents.Workflows.csproj} (91%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 91cbf5db1f..e79922db8d 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -116,7 +116,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj similarity index 91% rename from dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj rename to dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj index df4590d832..851213dbe7 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj +++ b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj @@ -3,7 +3,6 @@ $(ProjectsTargetFrameworks) $(ProjectsDebugTargetFrameworks) - Microsoft.Agents.Workflow alpha @@ -18,7 +17,6 @@ Microsoft Agent Workflow Framework Contains the Microsoft Agent Workflow Framework. - Microsoft.Agents.Workflows diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj index f4e4b48d84..d384557d8f 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj @@ -6,7 +6,7 @@ - + From a7fcd0974de401e7d7fffb509e9a66c860078e0f Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 12:49:32 -0400 Subject: [PATCH 058/232] feat: Enable Default Message Handling * also lifts Bind in MessageHandlerInfo to better be able to direclty invoke handlers (for AOT, later) --- .../Core/MessageHandler.cs | 17 ++++++ .../Core/MessageRouting.cs | 60 ++++++++++++------- 2 files changed, 57 insertions(+), 20 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs index 1da548d9ba..a607736f40 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs @@ -4,6 +4,23 @@ namespace Microsoft.Agents.Workflows.Core; +/// +/// A default message handler interface for handling messages that do not have a specific handler registered. +/// +public interface IDefaultMessageHandler +{ + /// + /// Handles the incoming message asynchronously. + /// + /// + /// This is used as a fallback handler for messages that do not have a specific handler registered. + /// + /// The message to handle. + /// The execution context. + /// A task that represents the asynchronous operation. + ValueTask HandleAsync(object message, IWorkflowContext context); +} + /// /// A message handler interface for handling messages of type . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs index f7335c99e9..8d899bff94 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs @@ -164,22 +164,17 @@ public MessageHandlerInfo(MethodInfo handlerInfo) } } - public Func> Bind(Executor executor, bool checkType = false) + public static Func> Bind(Func handlerAsync, bool checkType, Type? resultType = null, Func>? unwrapper = null) { - Type? resultType = this.OutType; - MethodInfo handlerMethod = this.HandlerInfo; - Func>? unwrapper = this.Unwrapper; - return InvokeHandlerAsync; - // Create a delegate that binds the handler to the executor. async ValueTask InvokeHandlerAsync(object message, IWorkflowContext workflowContext) { bool expectingVoid = resultType == null || resultType == typeof(void); try { - object? maybeValueTask = handlerMethod.Invoke(executor, new object[] { message, workflowContext }); + object? maybeValueTask = handlerAsync(message, workflowContext); if (expectingVoid) { @@ -190,7 +185,7 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext } throw new InvalidOperationException( - $"Handler method {handlerMethod.Name} is expected to return ValueTask or ValueTask, but returned " + + "Handler method is expected to return ValueTask or ValueTask, but returned " + $"{maybeValueTask?.GetType().Name ?? "null"}."); } @@ -198,13 +193,13 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext if (unwrapper == null) { throw new InvalidOperationException( - $"Handler method {handlerMethod.Name} is expected to return ValueTask<{resultType!.Name}>, but no unwrapper is available."); + $"Handler method is expected to return ValueTask<{resultType!.Name}>, but no unwrapper is available."); } if (maybeValueTask == null) { throw new InvalidOperationException( - $"Handler method {handlerMethod.Name} returned null, but a ValueTask<{resultType!.Name}> was expected."); + $"Handler method returned null, but a ValueTask<{resultType!.Name}> was expected."); } object? result = await unwrapper(maybeValueTask).ConfigureAwait(false); @@ -212,7 +207,7 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext if (checkType && result != null && !resultType.IsInstanceOfType(result)) { throw new InvalidOperationException( - $"Handler method {handlerMethod.Name} returned an incompatible type: expected {resultType.Name}, got {result.GetType().Name}."); + $"Handler method returned an incompatible type: expected {resultType.Name}, got {result.GetType().Name}."); } return CallResult.ReturnResult(result); @@ -224,6 +219,17 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext } } } + + public Func> Bind(Executor executor, bool checkType = false) + { + MethodInfo handlerMethod = this.HandlerInfo; + return MessageHandlerInfo.Bind(InvokeHandler, checkType, this.OutType, this.Unwrapper); + + object? InvokeHandler(object message, IWorkflowContext workflowContext) + { + return handlerMethod.Invoke(executor, new object[] { message, workflowContext }); + } + } } internal class MessageRouter @@ -232,6 +238,7 @@ internal class MessageRouter internal static readonly Dictionary> s_routerFactoryCache = new(); private Dictionary>> BoundHandlers { get; init; } = new(); + private IDefaultMessageHandler? DefaultHandler { get; init; } = null; [SuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + @@ -315,12 +322,13 @@ internal static MessageRouter BindMessageHandlers(Executor executor, bool checkT boundHandlers.Add(inType, boundHandler); // TODO: Turn the error here into something more actionable. } - return new MessageRouter(boundHandlers); + return new MessageRouter(boundHandlers, executor as IDefaultMessageHandler); } - internal MessageRouter(Dictionary>> handlers) + internal MessageRouter(Dictionary>> handlers, IDefaultMessageHandler? defaultHandler = null) { this.BoundHandlers = handlers; + this.DefaultHandler = defaultHandler; } /// @@ -339,12 +347,24 @@ internal MessageRouter(Dictionary>? handler)) { result = await handler(message, context).ConfigureAwait(false); } + else if (this.DefaultHandler != null) + { + try + { + await this.DefaultHandler.HandleAsync(message, context).ConfigureAwait(false); + result = CallResult.ReturnVoid(); + } + catch (Exception e) + { + result = CallResult.RaisedException(wasVoid: true, e); + } + } return result; } @@ -353,14 +373,14 @@ internal MessageRouter(Dictionary IncomingTypes => [.. this.BoundHandlers.Keys]; + public HashSet IncomingTypes + => this.DefaultHandler != null + ? [.. this.BoundHandlers.Keys, typeof(object)] + : [.. this.BoundHandlers.Keys]; } From 3f16426162f84164b368cd267b6ae0de76ca0b03 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 12:55:35 -0400 Subject: [PATCH 059/232] feat: Implement StreamingHandle APIs This allows the user to respond to WorkflowEvents with external messages, enabling HIL. --- .../Execution/ExecutionHandle.cs | 170 ++++++++++++++++++ .../Execution/LocalRunner.cs | 95 +++++++--- .../Execution/LocalRunnerContext.cs | 2 +- .../ExecutionResult.cs | 10 -- .../Microsoft.Agents.Workflows.csproj | 1 - .../WorkflowBuilder.cs | 2 + .../Sample/02_Simple_Workflow_Sequential.cs | 9 +- .../Sample/02a_Simple_Workflow_Condition.cs | 9 +- .../Sample/02b_Simple_Workflow_Loop.cs | 10 +- 9 files changed, 254 insertions(+), 54 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs new file mode 100644 index 0000000000..6bab2c0aea --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Execution; + +internal interface ISuperStepRunner +{ + ValueTask EnqueueMessageAsync(object message); + + event EventHandler? WorkflowEvent; + + ValueTask RunSuperStepAsync(CancellationToken cancellation); +} + +internal interface IRunnerWithResult +{ + ISuperStepRunner StepRunner { get; } + + ValueTask GetResultAsync(CancellationToken cancellation = default); +} + +/// +/// . +/// +public class StreamingExecutionHandle +{ + private readonly ISuperStepRunner _stepRunner; + + internal StreamingExecutionHandle(ISuperStepRunner stepRunner) + { + this._stepRunner = Throw.IfNull(stepRunner); + } + + /// + /// . + /// + /// + /// + /// + public ValueTask SendResponseAsync(object response) + { + return this._stepRunner.EnqueueMessageAsync(response); + } + + /// + /// . + /// + /// + /// + /// + public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancellation] CancellationToken cancellation) + { + List eventSink = new(); + + this._stepRunner.WorkflowEvent += OnWorkflowEvent; + + try + { + while (await this._stepRunner.RunSuperStepAsync(cancellation).ConfigureAwait(false)) + { + List outputEvents = Interlocked.Exchange(ref eventSink, new()); + foreach (WorkflowEvent raisedEvent in outputEvents) + { + yield return raisedEvent; + } + } + } + finally + { + this._stepRunner.WorkflowEvent -= OnWorkflowEvent; + } + + void OnWorkflowEvent(object? sender, WorkflowEvent e) + { + eventSink.Add(e); + } + } +} + +/// +/// . +/// +/// +public class StreamingExecutionHandle : StreamingExecutionHandle +{ + private readonly IRunnerWithResult _resultSource; + + internal StreamingExecutionHandle(IRunnerWithResult runner) + : base(Throw.IfNull(runner.StepRunner)) + { + this._resultSource = runner; + } + + /// + /// . + /// + /// + /// + /// + public ValueTask GetResultAsync(CancellationToken cancellation = default) + { + return this._resultSource.GetResultAsync(cancellation); + } +} + +/// +/// . +/// +public static class ExecutionHandleExtensions +{ + /// + /// Processes all events from the workflow execution stream until completion. + /// + /// This method continuously monitors the workflow execution stream provided by and invokes the for each event. If the callback returns a + /// non- response, the response is sent back to the workflow using the handle. + /// The representing the workflow execution stream to monitor. + /// An optional callback function invoked for each received from the stream. The + /// callback can return a response object to be sent back to the workflow, or if no response + /// is required. + /// A to observe while waiting for events. Defaults to . + /// A that represents the asynchronous operation. The task completes when the workflow + /// execution stream is fully processed. + public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) + { + Throw.IfNull(handle); + + await foreach (WorkflowEvent @event in handle.WatchStreamAsync(cancellation).ConfigureAwait(false)) + { + object? maybeResponse = eventCallback?.Invoke(@event); + if (maybeResponse != null) + { + await handle.SendResponseAsync(maybeResponse).ConfigureAwait(false); + } + } + } + + /// + /// Executes the workflow associated with the specified until it + /// completes and returns the final result. + /// + /// This method ensures that the workflow runs to completion before returning the result. If an + /// is provided, it will be invoked for each event emitted during the workflow's + /// execution, allowing for custom event handling. + /// The type of the result produced by the workflow. + /// The representing the workflow to execute. This parameter cannot + /// be . + /// An optional callback function that is invoked for each emitted during the workflow + /// execution. The callback can process the event and return an object, or if no processing + /// is required. + /// A that can be used to cancel the workflow execution. The default value is . + /// A that represents the asynchronous operation. The task's result is the final + /// result of the workflow execution. + public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) + { + Throw.IfNull(handle); + + await handle.RunToCompletionAsync(eventCallback, cancellation).ConfigureAwait(false); + return await handle.GetResultAsync(cancellation).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index 18e252c274..c0533a1087 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; @@ -91,7 +92,7 @@ public EdgeMap(IRunnerContext runContext, Dictionary> } } -internal class LocalRunner +internal class LocalRunner : ISuperStepRunner where TInput : notnull { public LocalRunner(Workflow workflow) { @@ -103,6 +104,11 @@ public LocalRunner(Workflow workflow) this.EdgeMap = new EdgeMap(this.RunContext, this.Workflow.Edges, this.Workflow.StartExecutorId); } + ValueTask ISuperStepRunner.EnqueueMessageAsync(object message) + { + return this.RunContext.AddExternalMessageAsync(message); + } + protected Workflow Workflow { get; init; } protected LocalRunnerContext RunContext { get; init; } protected EdgeMap EdgeMap { get; init; } @@ -131,47 +137,68 @@ private bool IsResponse(object message) : this.EdgeMap.InvokeInputAsync(message); } - public async Task RunAsync(TInput input) + public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) { await this.RunContext.AddExternalMessageAsync(input).ConfigureAwait(false); - // Kick everything off by sending the first message to the start executor. - Executor startExecutor = await this.RunContext.EnsureExecutorAsync(this.Workflow.StartExecutorId) - .ConfigureAwait(false); + return new StreamingExecutionHandle(this); + } + + private StepContext? _currentStep = null; + public async ValueTask RunSuperStepAsync(CancellationToken cancellation) + { + cancellation.ThrowIfCancellationRequested(); - for (StepContext currentStep = this.RunContext.Advance(); currentStep.HasMessages; currentStep = this.RunContext.Advance()) + if (this._currentStep == null) { - // Deliver the messages and queue the next step - List>> edgeTasks = new(); - foreach (Identity sender in currentStep.QueuedMessages.Keys) + this._currentStep = this.RunContext.Advance(); + } + + if (this._currentStep.HasMessages) + { + await this.RunSuperstepAsync(this._currentStep).ConfigureAwait(false); + return true; + } + + return false; + } + + private async ValueTask RunSuperstepAsync(StepContext currentStep) + { + // Deliver the messages and queue the next step + List>> edgeTasks = new(); + foreach (Identity sender in currentStep.QueuedMessages.Keys) + { + IEnumerable senderMessages = currentStep.QueuedMessages[sender]; + if (sender.Id is null) { - IEnumerable senderMessages = currentStep.QueuedMessages[sender]; - if (sender.Id is null) - { - edgeTasks.AddRange(senderMessages.Select(message => this.RouteExternalMessageAsync(message).AsTask())); - } - else + edgeTasks.AddRange(senderMessages.Select(message => this.RouteExternalMessageAsync(message).AsTask())); + } + else + { + HashSet outgoingEdges = this.Workflow.Edges[sender.Id!]; // Id is not null when Identity is not .None + foreach (FlowEdge outgoingEdge in outgoingEdges) { - HashSet outgoingEdges = this.Workflow.Edges[sender.Id!]; // Id is not null when Identity is not .None - foreach (FlowEdge outgoingEdge in outgoingEdges) - { - edgeTasks.AddRange(senderMessages.Select(message => this.EdgeMap.InvokeEdgeAsync(outgoingEdge, sender.Id, message).AsTask())); - } + edgeTasks.AddRange(senderMessages.Select(message => this.EdgeMap.InvokeEdgeAsync(outgoingEdge, sender.Id, message).AsTask())); } } + } - IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); + // TODO: Should we let the user specify that they want strictly turn-based execution of the edges, vs. concurrent? + // (Simply substitute a strategy that replaces Task.WhenAll with a loop with an await in the middle. Difficulty is + // that we would need to avoid firing the tasks when we call InvokeEdgeAsync, or RouteExternalMessageAsync. + IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); - // After the message handler invocations, we may have some events to deliver - foreach (WorkflowEvent @event in this.RunContext.QueuedEvents) - { - // TODO - } + // TODO: Commit the state updates (so they are visible to the next step) + // After the message handler invocations, we may have some events to deliver + foreach (WorkflowEvent @event in this.RunContext.QueuedEvents) + { + this.RaiseWorkflowEvent(@event); } } } -internal class LocalRunner +internal class LocalRunner : IRunnerWithResult where TInput : notnull { private readonly Workflow _workflow; private readonly LocalRunner _innerRunner; @@ -182,10 +209,20 @@ public LocalRunner(Workflow workflow) this._innerRunner = new LocalRunner(workflow); } - public async Task RunAsync(TInput input) + public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) { - await this._innerRunner.RunAsync(input).ConfigureAwait(false); + await this.StepRunner.EnqueueMessageAsync(input).ConfigureAwait(false); + + return new StreamingExecutionHandle(this._innerRunner); + } + + public ValueTask GetResultAsync(CancellationToken cancellation = default) + { + // TODO: Block on finishing consuming StreamAsync()? + return CompletedValueTaskSource.FromResult(this.RunningOutput!); } public TResult? RunningOutput => this._workflow.RunningOutput; + + public ISuperStepRunner StepRunner => this._innerRunner; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index 450ae763e1..0275b32f30 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -39,7 +39,7 @@ public async ValueTask EnsureExecutorAsync(string executorId) return executor; } - public ValueTask AddExternalMessageAsync([NotNull] TExternalInput message) + public ValueTask AddExternalMessageAsync([NotNull] object message) { Throw.IfNull(message); diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs deleted file mode 100644 index a73bef38f1..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.Workflows; - -/// -/// . -/// -public class ExecutionResult -{ -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj index 851213dbe7..a9023a799c 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj @@ -30,7 +30,6 @@ - diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index d9932b0bca..ada872384e 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -152,6 +152,8 @@ public Workflow Build() { // We have no handlers for the input type T, which means the built workflow will not be able to // process messages of the desired type + throw new InvalidOperationException( + $"Workflow cannot be built because the starting executor {this._startExecutorId} does not contain a handler for the desired input type {typeof(T).Name}"); } return new Workflow(this._startExecutorId) // Why does it not see the default ctor? diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs index 33004f8170..af50478e26 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs @@ -2,12 +2,13 @@ using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; namespace Microsoft.Agents.Workflows.Sample; internal static class Step2EntryPoint { - public static ValueTask RunAsync() + public static async ValueTask RunAsync() { UppercaseExecutor uppercase = new(); ReverseTextExecutor reverse = new(); @@ -16,10 +17,10 @@ public static ValueTask RunAsync() builder.AddEdge(uppercase, reverse); Workflow workflow = builder.Build(); - // async foreach (var event in workflow.RunAsync("hello world")) - // await Console.Out.WriteLineAsync(event); + LocalRunner runner = new(workflow); - return CompletedValueTaskSource.Completed; + var handle = await runner.StreamAsync("Hello, World!").ConfigureAwait(false); + await handle.RunToCompletionAsync().ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs index cb5d950b6d..d782783404 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs @@ -4,12 +4,13 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; namespace Microsoft.Agents.Workflows.Sample; internal static class Step2aEntryPoint { - public static ValueTask RunAsync() + public static async ValueTask RunAsync() { string[] spamKeywords = { "spam", "advertisement", "offer" }; @@ -22,10 +23,10 @@ public static ValueTask RunAsync() .AddEdge(detectSpam, removeSpam, isSpam => isSpam is false) // If spam, remove .Build(); - // async foreach (var event in workflow.RunAsync("This is a spam message.")) - // await Console.Out.WriteLineAsync(event); + LocalRunner runner = new(workflow); - return CompletedValueTaskSource.Completed; + StreamingExecutionHandle handle = await runner.StreamAsync("This is a spam message.").ConfigureAwait(false); + await handle.RunToCompletionAsync().ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs index 6ab2b232e5..3ce581bf8b 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs @@ -2,12 +2,13 @@ using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; namespace Microsoft.Agents.Workflows.Sample; internal static class Step2bEntryPoint { - public static ValueTask RunAsync() + public static async ValueTask RunAsync() { GuessNumberExecutor guessNumber = new(1, 100); JudgeExecutor judge = new(42); // Let's say the target number is 42 @@ -16,10 +17,9 @@ public static ValueTask RunAsync() .AddLoop(guessNumber, judge) .Build(); - // async foreach (var event in workflow.RunAsync(NumberSignal.Init)) - // await Console.Out.WriteLineAsync(event); - - return CompletedValueTaskSource.Completed; + LocalRunner runner = new(workflow); + StreamingExecutionHandle handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); + await handle.RunToCompletionAsync(); } } From d8a88d17d52cf4e27f078f7af8a81fa1d39ef60f Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 13:10:30 -0400 Subject: [PATCH 060/232] feat: Add checks for duplicate edges and chain cycles --- .../Microsoft.Agents.Workflows.csproj | 10 ++++++---- .../Microsoft.Agents.Workflow/WorkflowBuilder.cs | 14 ++++++++++++++ .../WorkflowBuilderExtensions.cs | 12 ++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj index a9023a799c..478396f484 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj @@ -19,6 +19,12 @@ Contains the Microsoft Agent Workflow Framework. + + + + + + @@ -29,8 +35,4 @@ - - - - \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index ada872384e..28416db074 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -18,11 +18,17 @@ namespace Microsoft.Agents.Workflows; internal delegate TExecutor ExecutorProvider() where TExecutor : Executor; +internal record struct EdgeId(string SourceId, string TargetId) +{ + public override string ToString() => $"{this.SourceId} -> {this.TargetId}"; +} + internal class WorkflowBuilder { private readonly Dictionary> _executors = new(); private readonly Dictionary> _edges = new(); private readonly HashSet _unboundExecutors = new(); + private readonly HashSet _conditionlessEdges = new(); private readonly string _startExecutorId; @@ -90,6 +96,14 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func seenExecutors = new(); + seenExecutors.Add(source.Id); + for (int i = 0; i < executors.Length; i++) { Throw.IfNull(executors[i], nameof(executors) + $"[{i}]"); + + if (seenExecutors.Contains(executors[i].Id)) + { + throw new ArgumentException($"Executor '{executors[i].Id}' is already in the chain.", nameof(executors)); + } + seenExecutors.Add(executors[i].Id); + builder.AddEdge(source, executors[i]); source = executors[i]; } From 45d525f28e48b1e77f308a8165495be3aed21723 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 13:48:33 -0400 Subject: [PATCH 061/232] feat: Add built-in WorkflowEvents --- .../Microsoft.Agents.Workflow/Core/Events.cs | 38 +++---------------- .../Core/Executor.cs | 4 ++ .../Execution/LocalRunner.cs | 6 +++ .../Execution/LocalRunnerContext.cs | 2 + 4 files changed, 18 insertions(+), 32 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs index 041944ea36..6ce2a05702 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -using System; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; @@ -27,27 +27,15 @@ public record ExecutorEvent : WorkflowEvent /// /// The identifier of the executor that generated this event. /// -#if NET9_0_OR_GREATER - required -#endif - public string ExecutorId - { get; init; } + public string ExecutorId { get; } /// /// . /// public ExecutorEvent(string executorId, object? data = null) : base(data) { - this.ExecutorId = executorId ?? throw new ArgumentNullException(nameof(executorId), "Executor ID cannot be null."); + this.ExecutorId = Throw.IfNull(executorId); } - -#if NET9_0_OR_GREATER - /// - /// . - /// - public ExecutorEvent() - { } -#endif } /// @@ -58,15 +46,9 @@ public record ExecutorInvokeEvent : ExecutorEvent /// /// . /// - public ExecutorInvokeEvent(string executorId, object? data = null) : base(executorId, data) { } - -#if NET9_0_OR_GREATER - /// - /// . - /// - public ExecutorInvokeEvent() - { } -#endif + public ExecutorInvokeEvent(string executorId, object? data = null) : base(executorId, data) + { + } } /// @@ -78,14 +60,6 @@ public record ExecutorCompleteEvent : ExecutorEvent /// . /// public ExecutorCompleteEvent(string executorId, object? data = null) : base(executorId, data) { } - -#if NET9_0_OR_GREATER - /// - /// . - /// - public ExecutorCompleteEvent() - { } -#endif } // TODO: This is a placeholder for streaming chat message content. diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 1767c0cbaa..451be5135b 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -126,9 +126,13 @@ protected Executor(string? id = null, string? name = null) /// public async ValueTask ExecuteAsync(object message, IWorkflowContext context) { + await context.AddEventAsync(new ExecutorInvokeEvent(this.Id)).ConfigureAwait(false); + CallResult? result = await this.MessageRouter.RouteMessageAsync(message, context, requireRoute: true) .ConfigureAwait(false); + await context.AddEventAsync(new ExecutorCompleteEvent(this.Id)).ConfigureAwait(false); + if (result == null) { throw new NotSupportedException( diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index c0533a1087..1605c77c5a 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -109,6 +109,7 @@ ValueTask ISuperStepRunner.EnqueueMessageAsync(object message) return this.RunContext.AddExternalMessageAsync(message); } + protected Dictionary PendingCalls { get; } = new(); protected Workflow Workflow { get; init; } protected LocalRunnerContext RunContext { get; init; } protected EdgeMap EdgeMap { get; init; } @@ -151,12 +152,16 @@ public async ValueTask RunSuperStepAsync(CancellationToken cancellation) if (this._currentStep == null) { + // TODO: Python-side does not raise this event. + // await this.RunContext.AddEventAsync(this.Workflow.StartExecutorId, new WorkflowStartedEvent()).ConfigureAwait(false); this._currentStep = this.RunContext.Advance(); } if (this._currentStep.HasMessages) { await this.RunSuperstepAsync(this._currentStep).ConfigureAwait(false); + this._currentStep = this.RunContext.Advance(); + return true; } @@ -190,6 +195,7 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); // TODO: Commit the state updates (so they are visible to the next step) + // After the message handler invocations, we may have some events to deliver foreach (WorkflowEvent @event in this.RunContext.QueuedEvents) { diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index 0275b32f30..c58a891e7f 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -47,6 +47,8 @@ public ValueTask AddExternalMessageAsync([NotNull] object message) return CompletedValueTaskSource.Completed; } + public bool NextStepHasActions => this._nextStep.HasMessages; + public StepContext Advance() { return Interlocked.Exchange(ref this._nextStep, new StepContext()); From 7df87ce67146cde7e0e90a6e41ea194111b3aa88 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 13:55:59 -0400 Subject: [PATCH 062/232] refactor: Pull classes into own files --- .../Core/CallResult.cs | 78 ++++ .../Core/Executor.cs | 74 ---- .../Core/ExecutorCapabilities.cs | 69 ++++ .../Core/IDefaultMessageHandler.cs | 22 + .../Core/IIdentified.cs | 14 + .../{MessageHandler.cs => IMessageHandler.cs} | 17 - .../Core/MessageHandlerInfo.cs | 131 ++++++ .../Core/MessageRouter.cs | 169 ++++++++ .../Core/MessageRouting.cs | 386 ------------------ .../Core/StreamsMessageAttribute.cs | 26 ++ ...TypeErasure.cs => ValueTaskTypeErasure.cs} | 0 .../Execution/EdgeMap.cs | 91 +++++ .../{Identity.cs => ExecutorIdentity.cs} | 14 +- .../Execution/IRunnerWithResult.cs | 13 + .../Execution/ISuperStepRunner.cs | 17 + .../Execution/LocalRunner.cs | 84 +--- .../Execution/LocalRunnerContext.cs | 2 +- .../Execution/StepContext.cs | 2 +- ...nHandle.cs => StreamingExecutionHandle.cs} | 16 - 19 files changed, 640 insertions(+), 585 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/IIdentified.cs rename dotnet/src/Microsoft.Agents.Workflow/Core/{MessageHandler.cs => IMessageHandler.cs} (68%) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandlerInfo.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs rename dotnet/src/Microsoft.Agents.Workflow/Core/{TypeErasure.cs => ValueTaskTypeErasure.cs} (100%) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs rename dotnet/src/Microsoft.Agents.Workflow/Execution/{Identity.cs => ExecutorIdentity.cs} (70%) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs rename dotnet/src/Microsoft.Agents.Workflow/Execution/{ExecutionHandle.cs => StreamingExecutionHandle.cs} (94%) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs new file mode 100644 index 0000000000..9b484610b0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// This class represents the result of a call to a +/// or . +/// +public sealed class CallResult +{ + /// + /// Indicates whether the call was void (i.e., no result expected). This only applies to + /// calls to handlers. + /// + public bool IsVoid { get; init; } + + /// + /// If the call was successful, this property contains the result of the call. For calls to + /// void handlers, this will be null. + /// + public object? Result { get; init; } = null; + + /// + /// If the call failed, this property contains the exception that was raised during the call. + /// + public Exception? Exception { get; init; } = null; + + /// + /// Indicates whether the call was successful. A call is considered successful if it returned + /// without throwing an exception. + /// + public bool IsSuccess => this.Exception == null; + + private CallResult(bool isVoid = false) + { + // Private constructor to enforce use of static methods. + this.IsVoid = isVoid; + } + + /// + /// Create a indicating a successful that returned a result (non-void). + /// + /// The result to return. + /// A indicating the result of the call. + public static CallResult ReturnResult(object? result = null) + { + return new() { Result = result }; + } + + /// + /// Create a indicating a successful call that returned no result (void). + /// + /// A indicating the result of the call. + public static CallResult ReturnVoid() + { + return new(isVoid: true); + } + + /// + /// Create a indicating that an exception was raised during the call. + /// + /// A boolean specifying whether the call was void (was not expected to return + /// a value). + /// The exception that was raised during the call. + /// A indicating the result of the call. + /// Thrown when is null. + public static CallResult RaisedException(bool wasVoid, Exception exception) + { + if (exception == null) + { + throw new ArgumentNullException(nameof(exception), "Exception cannot be null."); + } + + return new(wasVoid) { Exception = exception }; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 451be5135b..1f82cb0b5d 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -10,80 +10,6 @@ namespace Microsoft.Agents.Workflows.Core; -/// -/// A tag interface for objects that have a unique identifier within an appropriate namespace. -/// -public interface IIdentified -{ - /// - /// The unique identifier. - /// - string Id { get; } -} - -/// -/// . -/// -public record ExecutorCapabilities -{ - /// - /// . - /// - public string Id { get; init; } - /// - /// . - /// - public string Name { get; init; } - /// - /// . - /// - public Type ExecutorType { get; init; } - /// - /// . - /// - public ISet HandledMessageTypes { get; init; } - /// - /// . - /// - public bool IsInitialized { get; init; } - /// - /// . - /// - public ISet StateKeys { get; init; } - - /// - /// . - /// - public ExecutorCapabilities() - { - this.Id = string.Empty; - this.Name = string.Empty; - this.ExecutorType = typeof(Executor); - this.HandledMessageTypes = new HashSet(); - this.IsInitialized = false; - this.StateKeys = new HashSet(); - } - - /// - /// . - /// - /// - /// - /// - /// - /// - /// - public ExecutorCapabilities(string id, string name, Type executorType, ISet handledMessageTypes, bool isInitialized, ISet stateKeys) - { - this.Id = id; - this.Name = name; - this.ExecutorType = executorType; - this.HandledMessageTypes = handledMessageTypes; - this.IsInitialized = isInitialized; - this.StateKeys = stateKeys; - } -} - /// /// . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs new file mode 100644 index 0000000000..7f6ab5aebd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +public record ExecutorCapabilities +{ + /// + /// . + /// + public string Id { get; init; } + /// + /// . + /// + public string Name { get; init; } + /// + /// . + /// + public Type ExecutorType { get; init; } + /// + /// . + /// + public ISet HandledMessageTypes { get; init; } + /// + /// . + /// + public bool IsInitialized { get; init; } + /// + /// . + /// + public ISet StateKeys { get; init; } + + /// + /// . + /// + public ExecutorCapabilities() + { + this.Id = string.Empty; + this.Name = string.Empty; + this.ExecutorType = typeof(Executor); + this.HandledMessageTypes = new HashSet(); + this.IsInitialized = false; + this.StateKeys = new HashSet(); + } + + /// + /// . + /// + /// + /// + /// + /// + /// + /// + public ExecutorCapabilities(string id, string name, Type executorType, ISet handledMessageTypes, bool isInitialized, ISet stateKeys) + { + this.Id = id; + this.Name = name; + this.ExecutorType = executorType; + this.HandledMessageTypes = handledMessageTypes; + this.IsInitialized = isInitialized; + this.StateKeys = stateKeys; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs new file mode 100644 index 0000000000..bd8de4e48b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// A default message handler interface for handling messages that do not have a specific handler registered. +/// +public interface IDefaultMessageHandler +{ + /// + /// Handles the incoming message asynchronously. + /// + /// + /// This is used as a fallback handler for messages that do not have a specific handler registered. + /// + /// The message to handle. + /// The execution context. + /// A task that represents the asynchronous operation. + ValueTask HandleAsync(object message, IWorkflowContext context); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IIdentified.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IIdentified.cs new file mode 100644 index 0000000000..3b58e89665 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/IIdentified.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// A tag interface for objects that have a unique identifier within an appropriate namespace. +/// +public interface IIdentified +{ + /// + /// The unique identifier. + /// + string Id { get; } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageHandler.cs similarity index 68% rename from dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/IMessageHandler.cs index a607736f40..1da548d9ba 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageHandler.cs @@ -4,23 +4,6 @@ namespace Microsoft.Agents.Workflows.Core; -/// -/// A default message handler interface for handling messages that do not have a specific handler registered. -/// -public interface IDefaultMessageHandler -{ - /// - /// Handles the incoming message asynchronously. - /// - /// - /// This is used as a fallback handler for messages that do not have a specific handler registered. - /// - /// The message to handle. - /// The execution context. - /// A task that represents the asynchronous operation. - ValueTask HandleAsync(object message, IWorkflowContext context); -} - /// /// A message handler interface for handling messages of type . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandlerInfo.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandlerInfo.cs new file mode 100644 index 0000000000..da55f649c2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandlerInfo.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows.Core; + +internal struct MessageHandlerInfo +{ + public Type InType { get; init; } + public Type? OutType { get; init; } = null; + + public MethodInfo HandlerInfo { get; init; } + public Func>? Unwrapper { get; init; } = null; + + [SuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality " + + "when AOT compiling.", Justification = "")] + public MessageHandlerInfo(MethodInfo handlerInfo) + { + // The method is one of the following: + // - ValueTask HandleAsync(TMessage message, IExecutionContext context) + // - ValueTask HandleAsync(TMessage message, IExecutionContext context) + this.HandlerInfo = handlerInfo; + + ParameterInfo[] parameters = handlerInfo.GetParameters(); + if (parameters.Length != 2) + { + throw new ArgumentException("Handler method must have exactly two parameters: TMessage and IExecutionContext.", nameof(handlerInfo)); + } + + if (parameters[1].ParameterType != typeof(IWorkflowContext)) + { + throw new ArgumentException("Handler method's second parameter must be of type IExecutionContext.", nameof(handlerInfo)); + } + + this.InType = parameters[0].ParameterType; + + Type decoratedReturnType = handlerInfo.ReturnType; + if (decoratedReturnType.IsGenericType && decoratedReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) + { + // If the return type is ValueTask, extract TResult. + Type[] returnRawTypes = decoratedReturnType.GetGenericArguments(); + Debug.Assert( + returnRawTypes.Length == 1, + "ValueTask should have exactly one generic argument."); + + this.OutType = returnRawTypes.Single(); + this.Unwrapper = ValueTaskTypeErasure.UnwrapperFor(this.OutType); + } + else if (decoratedReturnType == typeof(ValueTask)) + { + // If the return type is ValueTask, there is no output type. + this.OutType = null; + } + else + { + throw new ArgumentException("Handler method must return ValueTask or ValueTask.", nameof(handlerInfo)); + } + } + + public static Func> Bind(Func handlerAsync, bool checkType, Type? resultType = null, Func>? unwrapper = null) + { + return InvokeHandlerAsync; + + async ValueTask InvokeHandlerAsync(object message, IWorkflowContext workflowContext) + { + bool expectingVoid = resultType == null || resultType == typeof(void); + + try + { + object? maybeValueTask = handlerAsync(message, workflowContext); + + if (expectingVoid) + { + if (maybeValueTask is ValueTask vt) + { + await vt.ConfigureAwait(false); + return CallResult.ReturnVoid(); + } + + throw new InvalidOperationException( + "Handler method is expected to return ValueTask or ValueTask, but returned " + + $"{maybeValueTask?.GetType().Name ?? "null"}."); + } + + Debug.Assert(resultType != null, "Expected resultType to be non-null when not expecting void."); + if (unwrapper == null) + { + throw new InvalidOperationException( + $"Handler method is expected to return ValueTask<{resultType!.Name}>, but no unwrapper is available."); + } + + if (maybeValueTask == null) + { + throw new InvalidOperationException( + $"Handler method returned null, but a ValueTask<{resultType!.Name}> was expected."); + } + + object? result = await unwrapper(maybeValueTask).ConfigureAwait(false); + + if (checkType && result != null && !resultType.IsInstanceOfType(result)) + { + throw new InvalidOperationException( + $"Handler method returned an incompatible type: expected {resultType.Name}, got {result.GetType().Name}."); + } + + return CallResult.ReturnResult(result); + } + catch (Exception ex) + { + // If the handler throws an exception, return it in the CallResult. + return CallResult.RaisedException(wasVoid: expectingVoid, exception: ex); + } + } + } + + public Func> Bind(Executor executor, bool checkType = false) + { + MethodInfo handlerMethod = this.HandlerInfo; + return MessageHandlerInfo.Bind(InvokeHandler, checkType, this.OutType, this.Unwrapper); + + object? InvokeHandler(object message, IWorkflowContext workflowContext) + { + return handlerMethod.Invoke(executor, new object[] { message, workflowContext }); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs new file mode 100644 index 0000000000..43e4ef4d74 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +using HandlerInfosT = + System.Collections.Generic.Dictionary< + System.Type, + Microsoft.Agents.Workflows.Core.MessageHandlerInfo + >; + +namespace Microsoft.Agents.Workflows.Core; + +internal class MessageRouter +{ + // TODO: The goal of the cache is to allow SourceGenerators to do the reflection to bind the handlers in the router. + internal static readonly Dictionary> s_routerFactoryCache = new(); + + private Dictionary>> BoundHandlers { get; init; } = new(); + private IDefaultMessageHandler? DefaultHandler { get; init; } = null; + + [SuppressMessage("Trimming", + "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + + "of the source method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + [SuppressMessage("Trimming", + "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of " + + "method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + private static HandlerInfosT ReflectHandlers(Type executorType) + { + // This method reflects over the methods of the executor type to find message handlers. + HandlerInfosT handlers = new(); + + // Get all implementations of IMessageHandler or IMessageHandler + // and create a MessageHandlerInfo for each. + if (!typeof(Executor).IsAssignableFrom(executorType)) + { + throw new ArgumentException($"Type {executorType.FullName} is not a valid Executor type.", nameof(executorType)); + } + + if (executorType.IsAbstract || executorType.IsInterface) + { + throw new ArgumentException($"Type {executorType.FullName} cannot be abstract or an interface.", nameof(executorType)); + } + + // Iterate all interfaces implemented by the executor type. + foreach (Type interfaceType in executorType.GetInterfaces()) + { + // Check if the interface is a message handler. + if (!interfaceType.IsGenericType) + { + continue; + } + + if (interfaceType.IsGenericType && (interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || + interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>))) + { + // Get the generic arguments of the interface. + Type[] genericArguments = interfaceType.GetGenericArguments(); + if (genericArguments.Length < 1 || genericArguments.Length > 2) + { + continue; // Invalid handler signature. + } + Type inType = genericArguments[0]; + Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; + MethodInfo? method = interfaceType.GetMethod("HandleAsync"); + if (method != null) + { + MessageHandlerInfo info = new(method) { InType = inType, OutType = outType }; + handlers[inType] = info; + } + } + } + + return handlers; + } + + [SuppressMessage("Trimming", + "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + + "of the source method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + internal static HandlerInfosT ReflectHandlers() where TExecutor : Executor + => ReflectHandlers(typeof(TExecutor)); + + internal static MessageRouter BindMessageHandlers(Executor executor, bool checkType) + { + if (s_routerFactoryCache.TryGetValue(executor.GetType(), out var factory)) + { + return factory(); + } + + // If no factory is found, reflect over the handlers + HandlerInfosT handlers = ReflectHandlers(executor.GetType()); + + Dictionary>> boundHandlers = new(); + foreach (Type inType in handlers.Keys) + { + MessageHandlerInfo handlerInfo = handlers[inType]; + Func> boundHandler = handlerInfo.Bind(executor, checkType); + boundHandlers.Add(inType, boundHandler); // TODO: Turn the error here into something more actionable. + } + + return new MessageRouter(boundHandlers, executor as IDefaultMessageHandler); + } + + internal MessageRouter(Dictionary>> handlers, IDefaultMessageHandler? defaultHandler = null) + { + this.BoundHandlers = handlers; + this.DefaultHandler = defaultHandler; + } + + /// + /// . + /// + /// + /// + /// + /// + /// + /// + public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message), "Message cannot be null."); + } + + // TODO: Implement base type delegation? + CallResult? result = null; + if (this.BoundHandlers.TryGetValue(message.GetType(), out Func>? handler)) + { + result = await handler(message, context).ConfigureAwait(false); + } + else if (this.DefaultHandler != null) + { + try + { + await this.DefaultHandler.HandleAsync(message, context).ConfigureAwait(false); + result = CallResult.ReturnVoid(); + } + catch (Exception e) + { + result = CallResult.RaisedException(wasVoid: true, e); + } + } + + return result; + } + + public bool CanHandle(object message) => this.CanHandle(Throw.IfNull(message).GetType()); + + public bool CanHandle(Type candidateType) + { + Throw.IfNull(candidateType); + + // Check if the router can handle the candidate type. + return this.DefaultHandler != null || this.BoundHandlers.ContainsKey(candidateType); + } + + public HashSet IncomingTypes + => this.DefaultHandler != null + ? [.. this.BoundHandlers.Keys, typeof(object)] + : [.. this.BoundHandlers.Keys]; +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs deleted file mode 100644 index 8d899bff94..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs +++ /dev/null @@ -1,386 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Reflection; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -using HandlerInfosT = - System.Collections.Generic.Dictionary< - System.Type, - Microsoft.Agents.Workflows.Core.MessageHandlerInfo - >; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// This attribute indicates that a message handler streams messages during its execution. -/// -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] -public sealed class StreamsMessageAttribute : Attribute -{ - /// - /// The type of the message that the handler yields. - /// - public Type Type { get; } - - /// - /// Indicates that the message handler yields streaming messages during the course of execution. - /// - public StreamsMessageAttribute(Type type) - { - // This attribute is used to mark executors that yield messages. - this.Type = type ?? throw new ArgumentNullException(nameof(type), "Type cannot be null."); - } -} - -/// -/// This class represents the result of a call to a -/// or . -/// -public sealed class CallResult -{ - /// - /// Indicates whether the call was void (i.e., no result expected). This only applies to - /// calls to handlers. - /// - public bool IsVoid { get; init; } - - /// - /// If the call was successful, this property contains the result of the call. For calls to - /// void handlers, this will be null. - /// - public object? Result { get; init; } = null; - - /// - /// If the call failed, this property contains the exception that was raised during the call. - /// - public Exception? Exception { get; init; } = null; - - /// - /// Indicates whether the call was successful. A call is considered successful if it returned - /// without throwing an exception. - /// - public bool IsSuccess => this.Exception == null; - - private CallResult(bool isVoid = false) - { - // Private constructor to enforce use of static methods. - this.IsVoid = isVoid; - } - - /// - /// Create a indicating a successful that returned a result (non-void). - /// - /// The result to return. - /// A indicating the result of the call. - public static CallResult ReturnResult(object? result = null) - { - return new() { Result = result }; - } - - /// - /// Create a indicating a successful call that returned no result (void). - /// - /// A indicating the result of the call. - public static CallResult ReturnVoid() - { - return new(isVoid: true); - } - - /// - /// Create a indicating that an exception was raised during the call. - /// - /// A boolean specifying whether the call was void (was not expected to return - /// a value). - /// The exception that was raised during the call. - /// A indicating the result of the call. - /// Thrown when is null. - public static CallResult RaisedException(bool wasVoid, Exception exception) - { - if (exception == null) - { - throw new ArgumentNullException(nameof(exception), "Exception cannot be null."); - } - - return new(wasVoid) { Exception = exception }; - } -} - -internal struct MessageHandlerInfo -{ - public Type InType { get; init; } - public Type? OutType { get; init; } = null; - - public MethodInfo HandlerInfo { get; init; } - public Func>? Unwrapper { get; init; } = null; - - [SuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality " + - "when AOT compiling.", Justification = "")] - public MessageHandlerInfo(MethodInfo handlerInfo) - { - // The method is one of the following: - // - ValueTask HandleAsync(TMessage message, IExecutionContext context) - // - ValueTask HandleAsync(TMessage message, IExecutionContext context) - this.HandlerInfo = handlerInfo; - - ParameterInfo[] parameters = handlerInfo.GetParameters(); - if (parameters.Length != 2) - { - throw new ArgumentException("Handler method must have exactly two parameters: TMessage and IExecutionContext.", nameof(handlerInfo)); - } - - if (parameters[1].ParameterType != typeof(IWorkflowContext)) - { - throw new ArgumentException("Handler method's second parameter must be of type IExecutionContext.", nameof(handlerInfo)); - } - - this.InType = parameters[0].ParameterType; - - Type decoratedReturnType = handlerInfo.ReturnType; - if (decoratedReturnType.IsGenericType && decoratedReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) - { - // If the return type is ValueTask, extract TResult. - Type[] returnRawTypes = decoratedReturnType.GetGenericArguments(); - Debug.Assert( - returnRawTypes.Length == 1, - "ValueTask should have exactly one generic argument."); - - this.OutType = returnRawTypes.Single(); - this.Unwrapper = ValueTaskTypeErasure.UnwrapperFor(this.OutType); - } - else if (decoratedReturnType == typeof(ValueTask)) - { - // If the return type is ValueTask, there is no output type. - this.OutType = null; - } - else - { - throw new ArgumentException("Handler method must return ValueTask or ValueTask.", nameof(handlerInfo)); - } - } - - public static Func> Bind(Func handlerAsync, bool checkType, Type? resultType = null, Func>? unwrapper = null) - { - return InvokeHandlerAsync; - - async ValueTask InvokeHandlerAsync(object message, IWorkflowContext workflowContext) - { - bool expectingVoid = resultType == null || resultType == typeof(void); - - try - { - object? maybeValueTask = handlerAsync(message, workflowContext); - - if (expectingVoid) - { - if (maybeValueTask is ValueTask vt) - { - await vt.ConfigureAwait(false); - return CallResult.ReturnVoid(); - } - - throw new InvalidOperationException( - "Handler method is expected to return ValueTask or ValueTask, but returned " + - $"{maybeValueTask?.GetType().Name ?? "null"}."); - } - - Debug.Assert(resultType != null, "Expected resultType to be non-null when not expecting void."); - if (unwrapper == null) - { - throw new InvalidOperationException( - $"Handler method is expected to return ValueTask<{resultType!.Name}>, but no unwrapper is available."); - } - - if (maybeValueTask == null) - { - throw new InvalidOperationException( - $"Handler method returned null, but a ValueTask<{resultType!.Name}> was expected."); - } - - object? result = await unwrapper(maybeValueTask).ConfigureAwait(false); - - if (checkType && result != null && !resultType.IsInstanceOfType(result)) - { - throw new InvalidOperationException( - $"Handler method returned an incompatible type: expected {resultType.Name}, got {result.GetType().Name}."); - } - - return CallResult.ReturnResult(result); - } - catch (Exception ex) - { - // If the handler throws an exception, return it in the CallResult. - return CallResult.RaisedException(wasVoid: expectingVoid, exception: ex); - } - } - } - - public Func> Bind(Executor executor, bool checkType = false) - { - MethodInfo handlerMethod = this.HandlerInfo; - return MessageHandlerInfo.Bind(InvokeHandler, checkType, this.OutType, this.Unwrapper); - - object? InvokeHandler(object message, IWorkflowContext workflowContext) - { - return handlerMethod.Invoke(executor, new object[] { message, workflowContext }); - } - } -} - -internal class MessageRouter -{ - // TODO: The goal of the cache is to allow SourceGenerators to do the reflection to bind the handlers in the router. - internal static readonly Dictionary> s_routerFactoryCache = new(); - - private Dictionary>> BoundHandlers { get; init; } = new(); - private IDefaultMessageHandler? DefaultHandler { get; init; } = null; - - [SuppressMessage("Trimming", - "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + - "of the source method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - [SuppressMessage("Trimming", - "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of " + - "method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - private static HandlerInfosT ReflectHandlers(Type executorType) - { - // This method reflects over the methods of the executor type to find message handlers. - HandlerInfosT handlers = new(); - - // Get all implementations of IMessageHandler or IMessageHandler - // and create a MessageHandlerInfo for each. - if (!typeof(Executor).IsAssignableFrom(executorType)) - { - throw new ArgumentException($"Type {executorType.FullName} is not a valid Executor type.", nameof(executorType)); - } - - if (executorType.IsAbstract || executorType.IsInterface) - { - throw new ArgumentException($"Type {executorType.FullName} cannot be abstract or an interface.", nameof(executorType)); - } - - // Iterate all interfaces implemented by the executor type. - foreach (Type interfaceType in executorType.GetInterfaces()) - { - // Check if the interface is a message handler. - if (!interfaceType.IsGenericType) - { - continue; - } - - if (interfaceType.IsGenericType && (interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || - interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>))) - { - // Get the generic arguments of the interface. - Type[] genericArguments = interfaceType.GetGenericArguments(); - if (genericArguments.Length < 1 || genericArguments.Length > 2) - { - continue; // Invalid handler signature. - } - Type inType = genericArguments[0]; - Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; - MethodInfo? method = interfaceType.GetMethod("HandleAsync"); - if (method != null) - { - MessageHandlerInfo info = new(method) { InType = inType, OutType = outType }; - handlers[inType] = info; - } - } - } - - return handlers; - } - - [SuppressMessage("Trimming", - "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + - "of the source method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - internal static HandlerInfosT ReflectHandlers() where TExecutor : Executor - => ReflectHandlers(typeof(TExecutor)); - - internal static MessageRouter BindMessageHandlers(Executor executor, bool checkType) - { - if (s_routerFactoryCache.TryGetValue(executor.GetType(), out var factory)) - { - return factory(); - } - - // If no factory is found, reflect over the handlers - HandlerInfosT handlers = ReflectHandlers(executor.GetType()); - - Dictionary>> boundHandlers = new(); - foreach (Type inType in handlers.Keys) - { - MessageHandlerInfo handlerInfo = handlers[inType]; - Func> boundHandler = handlerInfo.Bind(executor, checkType); - boundHandlers.Add(inType, boundHandler); // TODO: Turn the error here into something more actionable. - } - - return new MessageRouter(boundHandlers, executor as IDefaultMessageHandler); - } - - internal MessageRouter(Dictionary>> handlers, IDefaultMessageHandler? defaultHandler = null) - { - this.BoundHandlers = handlers; - this.DefaultHandler = defaultHandler; - } - - /// - /// . - /// - /// - /// - /// - /// - /// - /// - public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) - { - if (message == null) - { - throw new ArgumentNullException(nameof(message), "Message cannot be null."); - } - - // TODO: Implement base type delegation? - CallResult? result = null; - if (this.BoundHandlers.TryGetValue(message.GetType(), out Func>? handler)) - { - result = await handler(message, context).ConfigureAwait(false); - } - else if (this.DefaultHandler != null) - { - try - { - await this.DefaultHandler.HandleAsync(message, context).ConfigureAwait(false); - result = CallResult.ReturnVoid(); - } - catch (Exception e) - { - result = CallResult.RaisedException(wasVoid: true, e); - } - } - - return result; - } - - public bool CanHandle(object message) => this.CanHandle(Throw.IfNull(message).GetType()); - - public bool CanHandle(Type candidateType) - { - Throw.IfNull(candidateType); - - // Check if the router can handle the candidate type. - return this.DefaultHandler != null || this.BoundHandlers.ContainsKey(candidateType); - } - - public HashSet IncomingTypes - => this.DefaultHandler != null - ? [.. this.BoundHandlers.Keys, typeof(object)] - : [.. this.BoundHandlers.Keys]; -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs new file mode 100644 index 0000000000..52e1afb457 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// This attribute indicates that a message handler streams messages during its execution. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public sealed class StreamsMessageAttribute : Attribute +{ + /// + /// The type of the message that the handler yields. + /// + public Type Type { get; } + + /// + /// Indicates that the message handler yields streaming messages during the course of execution. + /// + public StreamsMessageAttribute(Type type) + { + // This attribute is used to mark executors that yield messages. + this.Type = type ?? throw new ArgumentNullException(nameof(type), "Type cannot be null."); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/TypeErasure.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ValueTaskTypeErasure.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/TypeErasure.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/ValueTaskTypeErasure.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs new file mode 100644 index 0000000000..0b84cba03a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class EdgeMap +{ + private readonly Dictionary _edgeRunners = new(); + private readonly Dictionary _fanInState = new(); + private readonly InputEdgeRuner _inputRunner; + + public EdgeMap(IRunnerContext runContext, Dictionary> workflowEdges, string startExecutorId) + { + foreach (FlowEdge edge in workflowEdges.Values.SelectMany(e => e)) + { + object edgeRunner = edge.EdgeType switch + { + FlowEdge.Type.Direct => new DirectEdgeRunner(runContext, edge.DirectEdgeData!), + FlowEdge.Type.FanOut => new FanOutEdgeRunner(runContext, edge.FanOutEdgeData!), + FlowEdge.Type.FanIn => new FanInEdgeRunner(runContext, edge.FanInEdgeData!), + _ => throw new NotSupportedException($"Unsupported edge type: {edge.EdgeType}") + }; + + this._edgeRunners[edge] = edgeRunner; + } + + this._inputRunner = new InputEdgeRuner(runContext, startExecutorId); + } + + public async ValueTask> InvokeEdgeAsync(FlowEdge edge, string sourceId, object message) + { + if (!this._edgeRunners.TryGetValue(edge, out object? edgeRunner)) + { + throw new InvalidOperationException($"Edge {edge} not found in the edge map."); + } + + IEnumerable edgeResults; + switch (edge.EdgeType) + { + // We know the corresponding EdgeRunner type given the FlowEdge EdgeType, as + // established in the EdgeMap() ctor; this avoid doing an as-cast inside of + // the depths of the message delivery loop for every edges (multiplicity N, + // in FanIn/Out cases) + // TODO: Once we have a fixed interface, if it is reasonably generalizable + // between the Runners, we can normalize it behind an IFace. + case FlowEdge.Type.Direct: + { + DirectEdgeRunner runner = (DirectEdgeRunner)this._edgeRunners[edge]; + edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); + break; + } + + case FlowEdge.Type.FanOut: + { + FanOutEdgeRunner runner = (FanOutEdgeRunner)this._edgeRunners[edge]; + edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); + break; + } + + case FlowEdge.Type.FanIn: + { + FanInEdgeState state = this._fanInState[edge]; + FanInEdgeRunner runner = (FanInEdgeRunner)this._edgeRunners[edge]; + edgeResults = [await runner.ChaseAsync(sourceId, message, state).ConfigureAwait(false)]; + break; + } + + default: + throw new InvalidOperationException("Unknown edge type"); + + } + + return edgeResults; + } + + // TODO: Should we promote Input to a true "FlowEdge" type? + public async ValueTask> InvokeInputAsync(object inputMessage) + { + return [await this._inputRunner.ChaseAsync(inputMessage).ConfigureAwait(false)]; + } + + public ValueTask> InvokeResponseAsync(object externalResponse) + { + throw new NotImplementedException(); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutorIdentity.cs similarity index 70% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs rename to dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutorIdentity.cs index 4c99a29cea..b612a735bb 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutorIdentity.cs @@ -5,13 +5,13 @@ namespace Microsoft.Agents.Workflows.Execution; -internal readonly struct Identity : IEquatable +internal readonly struct ExecutorIdentity : IEquatable { - public static Identity None { get; } = new Identity(); + public static ExecutorIdentity None { get; } = new ExecutorIdentity(); public string? Id { get; init; } - public bool Equals(Identity other) + public bool Equals(ExecutorIdentity other) { return this.Id == null ? other.Id == null @@ -30,7 +30,7 @@ public override bool Equals([NotNullWhen(true)] object? obj) return false; } - if (obj is Identity id) + if (obj is ExecutorIdentity id) { return id.Equals(this); } @@ -48,12 +48,12 @@ public override int GetHashCode() return this.Id == null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(this.Id); } - public static implicit operator Identity(string? id) + public static implicit operator ExecutorIdentity(string? id) { - return new Identity { Id = id }; + return new ExecutorIdentity { Id = id }; } - public static implicit operator string?(Identity identity) + public static implicit operator string?(ExecutorIdentity identity) { return identity.Id; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs new file mode 100644 index 0000000000..0d8a8ff422 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows.Execution; + +internal interface IRunnerWithResult +{ + ISuperStepRunner StepRunner { get; } + + ValueTask GetResultAsync(CancellationToken cancellation = default); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs new file mode 100644 index 0000000000..f2c6b5f929 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal interface ISuperStepRunner +{ + ValueTask EnqueueMessageAsync(object message); + + event EventHandler? WorkflowEvent; + + ValueTask RunSuperStepAsync(CancellationToken cancellation); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index 1605c77c5a..89e18037cf 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -10,88 +10,6 @@ namespace Microsoft.Agents.Workflows.Execution; -internal class EdgeMap -{ - private readonly Dictionary _edgeRunners = new(); - private readonly Dictionary _fanInState = new(); - private readonly InputEdgeRuner _inputRunner; - - public EdgeMap(IRunnerContext runContext, Dictionary> workflowEdges, string startExecutorId) - { - foreach (FlowEdge edge in workflowEdges.Values.SelectMany(e => e)) - { - object edgeRunner = edge.EdgeType switch - { - FlowEdge.Type.Direct => new DirectEdgeRunner(runContext, edge.DirectEdgeData!), - FlowEdge.Type.FanOut => new FanOutEdgeRunner(runContext, edge.FanOutEdgeData!), - FlowEdge.Type.FanIn => new FanInEdgeRunner(runContext, edge.FanInEdgeData!), - _ => throw new NotSupportedException($"Unsupported edge type: {edge.EdgeType}") - }; - - this._edgeRunners[edge] = edgeRunner; - } - - this._inputRunner = new InputEdgeRuner(runContext, startExecutorId); - } - - public async ValueTask> InvokeEdgeAsync(FlowEdge edge, string sourceId, object message) - { - if (!this._edgeRunners.TryGetValue(edge, out object? edgeRunner)) - { - throw new InvalidOperationException($"Edge {edge} not found in the edge map."); - } - - IEnumerable edgeResults; - switch (edge.EdgeType) - { - // We know the corresponding EdgeRunner type given the FlowEdge EdgeType, as - // established in the EdgeMap() ctor; this avoid doing an as-cast inside of - // the depths of the message delivery loop for every edges (multiplicity N, - // in FanIn/Out cases) - // TODO: Once we have a fixed interface, if it is reasonably generalizable - // between the Runners, we can normalize it behind an IFace. - case FlowEdge.Type.Direct: - { - DirectEdgeRunner runner = (DirectEdgeRunner)this._edgeRunners[edge]; - edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); - break; - } - - case FlowEdge.Type.FanOut: - { - FanOutEdgeRunner runner = (FanOutEdgeRunner)this._edgeRunners[edge]; - edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); - break; - } - - case FlowEdge.Type.FanIn: - { - FanInEdgeState state = this._fanInState[edge]; - FanInEdgeRunner runner = (FanInEdgeRunner)this._edgeRunners[edge]; - edgeResults = [await runner.ChaseAsync(sourceId, message, state).ConfigureAwait(false)]; - break; - } - - default: - throw new InvalidOperationException("Unknown edge type"); - - } - - return edgeResults; - } - - // TODO: Should we promote Input to a true "FlowEdge" type? - public async ValueTask> InvokeInputAsync(object inputMessage) - { - return [await this._inputRunner.ChaseAsync(inputMessage).ConfigureAwait(false)]; - } - - public ValueTask> InvokeResponseAsync(object externalResponse) - { - throw new NotImplementedException(); - } -} - internal class LocalRunner : ISuperStepRunner where TInput : notnull { public LocalRunner(Workflow workflow) @@ -172,7 +90,7 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) { // Deliver the messages and queue the next step List>> edgeTasks = new(); - foreach (Identity sender in currentStep.QueuedMessages.Keys) + foreach (ExecutorIdentity sender in currentStep.QueuedMessages.Keys) { IEnumerable senderMessages = currentStep.QueuedMessages[sender]; if (sender.Id is null) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index c58a891e7f..e915d16157 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -43,7 +43,7 @@ public ValueTask AddExternalMessageAsync([NotNull] object message) { Throw.IfNull(message); - this._nextStep.MessagesFor(Identity.None).Add(message); + this._nextStep.MessagesFor(ExecutorIdentity.None).Add(message); return CompletedValueTaskSource.Completed; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs index ba305a4b46..07d30267ed 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.Workflows.Execution; internal class StepContext { - public Dictionary> QueuedMessages { get; } = new(); + public Dictionary> QueuedMessages { get; } = new(); public bool HasMessages => this.QueuedMessages.Values.Any(messageList => messageList.Count > 0); diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs similarity index 94% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs rename to dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs index 6bab2c0aea..ca915e24e7 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs @@ -10,22 +10,6 @@ namespace Microsoft.Agents.Workflows.Execution; -internal interface ISuperStepRunner -{ - ValueTask EnqueueMessageAsync(object message); - - event EventHandler? WorkflowEvent; - - ValueTask RunSuperStepAsync(CancellationToken cancellation); -} - -internal interface IRunnerWithResult -{ - ISuperStepRunner StepRunner { get; } - - ValueTask GetResultAsync(CancellationToken cancellation = default); -} - /// /// . /// From 3b048f1bca0f4a3c8919d360cd7066c0c65ca3ca Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 13:59:16 -0400 Subject: [PATCH 063/232] refactor: Simplify Disposal pattern in Executor --- .../Core/DisposableObject.cs | 59 ------------------- .../Core/Executor.cs | 13 ++-- 2 files changed, 9 insertions(+), 63 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs deleted file mode 100644 index a754870660..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Diagnostics; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// Provides a base class implementing the interface using -/// the virtual Dispose pattern. -/// -public class DisposableObject : IAsyncDisposable -{ - /// - /// Implements invocation of the DisposeAsync method when the object is finalized to - /// dispose unmanaged resources properly. - /// - ~DisposableObject() - { - // Finalizer calls DisposeAsync to ensure resources are released. - // This is a safety net in case DisposeAsync was not called. -#pragma warning disable CA2012 // Use ValueTasks correctly: Uses OnCompleted to properly handle the ValueTask return. - ValueTask disposeTask = this.DisposeAsync(false); -#pragma warning restore CA2012 // Use ValueTasks correctly - - if (!disposeTask.IsCompleted) - { - using (ManualResetEvent barrier = new(false)) - { - disposeTask.GetAwaiter().OnCompleted(() => barrier.Set()); - - // Wait for the DisposeAsync to complete. - barrier.WaitOne(); // TODO: Timeout? - } - } - - Debug.Assert( - disposeTask.IsCompleted, - "DisposeAsync should have completed in order to pass to this line."); -#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits - disposeTask.GetAwaiter().GetResult(); -#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits - } - - /// - protected virtual ValueTask DisposeAsync(bool disposing) - { - return CompletedValueTaskSource.Completed; - } - - /// - public async ValueTask DisposeAsync() - { - await this.DisposeAsync(true).ConfigureAwait(false); - GC.SuppressFinalize(this); - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 1f82cb0b5d..1a95663f57 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -14,7 +14,7 @@ namespace Microsoft.Agents.Workflows.Core; /// . /// [DebuggerDisplay("{GetType().Name}{Id}({Name})")] -public abstract class Executor : DisposableObject, IIdentified +public abstract class Executor : IIdentified, IAsyncDisposable { /// /// . @@ -192,14 +192,19 @@ private async ValueTask FlushReduceRemainingAsync() /// /// . /// - /// /// - protected override async ValueTask DisposeAsync(bool disposing = false) + protected virtual async ValueTask DisposeAsync() { this._initialized = false; await this.FlushReduceRemainingAsync().ConfigureAwait(false); + } + + ValueTask IAsyncDisposable.DisposeAsync() + { + GC.SuppressFinalize(this); // Should we be suppressing the finalizer here? CodeAnalysis seems to want it (CA1816) - await base.DisposeAsync(disposing).ConfigureAwait(false); + // Chain to the virtual call to DisposeAsync. + return this.DisposeAsync(); } } From eed3a7671c98ad11e6b94eeb637e92538b46890c Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 14:01:06 -0400 Subject: [PATCH 064/232] refactor: Break EdgeRunner file into per-type files --- .../Execution/DirectEdgeRunner.cs | 38 +++++ .../Execution/EdgeRunner.cs | 159 ------------------ .../Execution/FanInEdgeRunner.cs | 36 ++++ .../Execution/FanInEdgeState.cs | 38 +++++ .../Execution/FanOutEdgeRunner.cs | 43 +++++ .../Execution/InputEdgeRuner.cs | 34 ++++ 6 files changed, 189 insertions(+), 159 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeState.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs new file mode 100644 index 0000000000..29b1b2dc83 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData edgeData) : + EdgeRunner(runContext, edgeData) +{ + public IWorkflowContext WorkflowContext { get; } = runContext.Bind(edgeData.SinkId); + + private async ValueTask FindRouterAsync() + { + Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); + + return sink.MessageRouter; + } + + public async ValueTask> ChaseAsync(object message) + { + if (this.EdgeData.Condition != null && !this.EdgeData.Condition(message)) + { + return []; + } + + MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); + if (router.CanHandle(message)) + { + return [await router.RouteMessageAsync(message, this.WorkflowContext) + .ConfigureAwait(false)]; + } + + return []; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs index 05f6f6f887..8871f9d8bb 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs @@ -1,9 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Execution; @@ -14,158 +10,3 @@ internal abstract class EdgeRunner( protected IRunnerContext RunContext { get; } = Throw.IfNull(runContext); protected TEdgeData EdgeData { get; } = Throw.IfNull(edgeData); } - -internal class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData edgeData) : - EdgeRunner(runContext, edgeData) -{ - public IWorkflowContext WorkflowContext { get; } = runContext.Bind(edgeData.SinkId); - - private async ValueTask FindRouterAsync() - { - Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) - .ConfigureAwait(false); - - return sink.MessageRouter; - } - - public async ValueTask> ChaseAsync(object message) - { - if (this.EdgeData.Condition != null && !this.EdgeData.Condition(message)) - { - return []; - } - - MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); - if (router.CanHandle(message)) - { - return [await router.RouteMessageAsync(message, this.WorkflowContext) - .ConfigureAwait(false)]; - } - - return []; - } -} - -internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeData) : - EdgeRunner(runContext, edgeData) -{ - private Dictionary BoundContexts { get; } - = edgeData.SinkIds.ToDictionary( - sinkId => sinkId, - sinkId => runContext.Bind(sinkId)); - - public async ValueTask> ChaseAsync(object message) - { - List targets = - this.EdgeData.Partitioner == null - ? this.EdgeData.SinkIds - : this.EdgeData.Partitioner(message, this.BoundContexts.Count).Select(i => this.EdgeData.SinkIds[i]).ToList(); - - CallResult?[] result = await Task.WhenAll(targets.Select(ProcessTargetAsync)).ConfigureAwait(false); - return result.Where(r => r is not null); - - async Task ProcessTargetAsync(string targetId) - { - Executor executor = await this.RunContext.EnsureExecutorAsync(targetId) - .ConfigureAwait(false); - - MessageRouter router = executor.MessageRouter; - if (router.CanHandle(message)) - { - return await router.RouteMessageAsync(message, this.BoundContexts[targetId]) - .ConfigureAwait(false); - } - - return null; - } - } -} - -internal record FanInEdgeState(FanInEdgeData EdgeData) -{ - private List? _pendingMessages - = EdgeData.Trigger == FanInTrigger.WhenAll ? [] : null; - - private HashSet? _unseen - = EdgeData.Trigger == FanInTrigger.WhenAll ? new(EdgeData.SourceIds) : null; - - public IEnumerable? ProcessMessage(string sourceId, object message) - { - if (this.EdgeData.Trigger == FanInTrigger.WhenAll) - { - this._pendingMessages!.Add(message); - this._unseen!.Remove(sourceId); - - if (this._unseen.Count == 0) - { - List result = this._pendingMessages; - - this._pendingMessages = []; - this._unseen = new(this.EdgeData.SourceIds); - - return result; - } - - return null; - } - - return [message]; - } -} - -internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData) : - EdgeRunner(runContext, edgeData) -{ - private IWorkflowContext BoundContext { get; } = runContext.Bind(edgeData.SinkId); - - public FanInEdgeState CreateState() => new(this.EdgeData); - - public async ValueTask ChaseAsync(string sourceId, object message, FanInEdgeState state) - { - IEnumerable? releasedMessages = state.ProcessMessage(sourceId, message); - if (releasedMessages is null) - { - // Not ready to process yet. - return null; - } - - Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) - .ConfigureAwait(false); - - MessageRouter router = sink.MessageRouter; - if (router.CanHandle(message)) - { - return await router.RouteMessageAsync(message, this.BoundContext) - .ConfigureAwait(false); - } - return null; - } -} - -internal class InputEdgeRuner(IRunnerContext runContext, string sinkId) - : EdgeRunner(runContext, sinkId) -{ - public IWorkflowContext WorkflowContext { get; } = runContext.Bind(sinkId); - - private async ValueTask FindRouterAsync() - { - Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData) - .ConfigureAwait(false); - - return sink.MessageRouter; - } - - public async ValueTask ChaseAsync(object message) - { - MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); - if (router.CanHandle(message)) - { - return await router.RouteMessageAsync(message, this.WorkflowContext) - .ConfigureAwait(false); - } - - // TODO: Throw instead? - - return null; - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs new file mode 100644 index 0000000000..585f7e1833 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData) : + EdgeRunner(runContext, edgeData) +{ + private IWorkflowContext BoundContext { get; } = runContext.Bind(edgeData.SinkId); + + public FanInEdgeState CreateState() => new(this.EdgeData); + + public async ValueTask ChaseAsync(string sourceId, object message, FanInEdgeState state) + { + IEnumerable? releasedMessages = state.ProcessMessage(sourceId, message); + if (releasedMessages is null) + { + // Not ready to process yet. + return null; + } + + Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); + + MessageRouter router = sink.MessageRouter; + if (router.CanHandle(message)) + { + return await router.RouteMessageAsync(message, this.BoundContext) + .ConfigureAwait(false); + } + return null; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeState.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeState.cs new file mode 100644 index 0000000000..2747969d91 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeState.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal record FanInEdgeState(FanInEdgeData EdgeData) +{ + private List? _pendingMessages + = EdgeData.Trigger == FanInTrigger.WhenAll ? [] : null; + + private HashSet? _unseen + = EdgeData.Trigger == FanInTrigger.WhenAll ? new(EdgeData.SourceIds) : null; + + public IEnumerable? ProcessMessage(string sourceId, object message) + { + if (this.EdgeData.Trigger == FanInTrigger.WhenAll) + { + this._pendingMessages!.Add(message); + this._unseen!.Remove(sourceId); + + if (this._unseen.Count == 0) + { + List result = this._pendingMessages; + + this._pendingMessages = []; + this._unseen = new(this.EdgeData.SourceIds); + + return result; + } + + return null; + } + + return [message]; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs new file mode 100644 index 0000000000..7ff3e9c171 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeData) : + EdgeRunner(runContext, edgeData) +{ + private Dictionary BoundContexts { get; } + = edgeData.SinkIds.ToDictionary( + sinkId => sinkId, + sinkId => runContext.Bind(sinkId)); + + public async ValueTask> ChaseAsync(object message) + { + List targets = + this.EdgeData.Partitioner == null + ? this.EdgeData.SinkIds + : this.EdgeData.Partitioner(message, this.BoundContexts.Count).Select(i => this.EdgeData.SinkIds[i]).ToList(); + + CallResult?[] result = await Task.WhenAll(targets.Select(ProcessTargetAsync)).ConfigureAwait(false); + return result.Where(r => r is not null); + + async Task ProcessTargetAsync(string targetId) + { + Executor executor = await this.RunContext.EnsureExecutorAsync(targetId) + .ConfigureAwait(false); + + MessageRouter router = executor.MessageRouter; + if (router.CanHandle(message)) + { + return await router.RouteMessageAsync(message, this.BoundContexts[targetId]) + .ConfigureAwait(false); + } + + return null; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs new file mode 100644 index 0000000000..2ba64f1ee8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class InputEdgeRuner(IRunnerContext runContext, string sinkId) + : EdgeRunner(runContext, sinkId) +{ + public IWorkflowContext WorkflowContext { get; } = runContext.Bind(sinkId); + + private async ValueTask FindRouterAsync() + { + Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData) + .ConfigureAwait(false); + + return sink.MessageRouter; + } + + public async ValueTask ChaseAsync(object message) + { + MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); + if (router.CanHandle(message)) + { + return await router.RouteMessageAsync(message, this.WorkflowContext) + .ConfigureAwait(false); + } + + // TODO: Throw instead? + + return null; + } +} From 476a0fccde02b116f45e8f80a2b4ce4a4a6e9f87 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 14:06:47 -0400 Subject: [PATCH 065/232] refactor: Use Throw.IfNull() --- dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs | 6 ++---- dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs | 6 ++---- dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs | 6 +++--- dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs | 5 +---- .../Core/StreamsMessageAttribute.cs | 3 ++- dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs | 4 ++-- .../src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs | 2 +- dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs | 7 ++++--- .../Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs | 1 - 9 files changed, 17 insertions(+), 23 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs index 9b484610b0..934c03d43c 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; @@ -68,10 +69,7 @@ public static CallResult ReturnVoid() /// Thrown when is null. public static CallResult RaisedException(bool wasVoid, Exception exception) { - if (exception == null) - { - throw new ArgumentNullException(nameof(exception), "Exception cannot be null."); - } + Throw.IfNull(exception); return new(wasVoid) { Exception = exception }; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 1a95663f57..c617bb52e6 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -7,6 +7,7 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; @@ -142,10 +143,7 @@ public ExecutorCapabilities Capabilities /// public void RestoreState(IDictionary state) { - if (state == null) - { - throw new ArgumentNullException(nameof(state), "State cannot be null."); - } + Throw.IfNull(state); this.State.Clear(); diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs index c3c6bd2bbe..e94fad7848 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; - +using Microsoft.Shared.Diagnostics; using ExecutorId = string; // TODO: Unclear whether this should be forcibly a serializable type. using MetadataValueT = object; @@ -93,8 +93,8 @@ public record Message /// public Message(TContent content, MessageMetadata metadata) { - this.Content = content ?? throw new ArgumentNullException(nameof(content)); - this.Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); + this.Content = Throw.IfNull(content); + this.Metadata = Throw.IfNull(metadata); } /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs index 43e4ef4d74..0e7fce5082 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs @@ -125,10 +125,7 @@ internal MessageRouter(Dictionary public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) { - if (message == null) - { - throw new ArgumentNullException(nameof(message), "Message cannot be null."); - } + Throw.IfNull(message); // TODO: Implement base type delegation? CallResult? result = null; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs index 52e1afb457..c79d8fb8ab 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; @@ -21,6 +22,6 @@ public sealed class StreamsMessageAttribute : Attribute public StreamsMessageAttribute(Type type) { // This attribute is used to mark executors that yield messages. - this.Type = type ?? throw new ArgumentNullException(nameof(type), "Type cannot be null."); + this.Type = Throw.IfNull(type); } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs index bb92518d7b..e246635e89 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs @@ -25,8 +25,8 @@ public Type InputType public Workflow(string startExecutorId, Type type) { - this.StartExecutorId = startExecutorId ?? throw new ArgumentNullException(nameof(startExecutorId)); - this.InputType = type ?? throw new ArgumentNullException(nameof(type)); + this.StartExecutorId = Throw.IfNull(startExecutorId); + this.InputType = Throw.IfNull(type); // TODO: How do we (1) ensure the types are happy, and (2) work under AOT? } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index 89e18037cf..0646a25b49 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -14,7 +14,7 @@ internal class LocalRunner : ISuperStepRunner where TInput : notnull { public LocalRunner(Workflow workflow) { - this.Workflow = workflow ?? throw new ArgumentNullException(nameof(workflow)); + this.Workflow = Throw.IfNull(workflow); this.RunContext = new LocalRunnerContext(workflow); // Initialize the runners for each of the edges, along with the state for edges that diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs index f40fbf606f..a1fa16dd58 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -2,6 +2,7 @@ using System; using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; @@ -29,13 +30,13 @@ public enum Type public ExecutorIsh(Executor executor) { this.ExecutorType = Type.Executor; - this._executorValue = executor ?? throw new ArgumentNullException(nameof(executor)); + this._executorValue = Throw.IfNull(executor); } public ExecutorIsh(string id) { this.ExecutorType = Type.Unbound; - this._idValue = id ?? throw new ArgumentNullException(nameof(id)); + this._idValue = Throw.IfNull(id); } public bool IsUnbound => this.ExecutorType == Type.Unbound; @@ -63,7 +64,7 @@ public ExecutorIsh(string id) //public ExecutorIsh(Func function) //{ // this.ExecutorType = Type.Function; - // this._functionValue = function ?? throw new ArgumentNullException(nameof(function)); + // this._functionValue = Throw.IfNull(function); //} // Implicit conversions into ExecutorIsh diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index e3dae65cf3..3a0f3a2fbd 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.Linq; using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; From 748786534c1e8130232fd7aa7d7c11abe7abd279 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 14:17:14 -0400 Subject: [PATCH 066/232] refactor: Remove AddLoop() Per https://github.com/microsoft/agent-framework/pull/272#discussion_r2241739079 we decided this was not very useful. --- .../WorkflowBuilderExtensions.cs | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index 3a0f3a2fbd..7b2836952b 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -9,18 +9,6 @@ namespace Microsoft.Agents.Workflows; internal static class WorkflowBuilderExtensions { - public static WorkflowBuilder AddLoop(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh loopBody, Func? condition = null) - { - Throw.IfNull(builder); - Throw.IfNull(source); - Throw.IfNull(loopBody); - - builder.AddEdge(source, loopBody, condition); - builder.AddEdge(loopBody, source); - - return builder; - } - public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors) { Throw.IfNull(builder); @@ -62,22 +50,4 @@ public static Workflow BuildWithOutput workflow = builder.Build(); return workflow.Promote(outputSink); } - - //public static WorkflowBuilder AddMapReduce } - -//class T -//{ -// async Task A() -// { -// WorkflowBuilder b; - -// Workflow> wf = -// WorkflowBuilderExtensions.BuildWithOutput>(b, "my_last_node", StreamingAggregators.Union()); - -// LocalRunner> runner = new(wf); - -// await runner.RunAsync(42).ConfigureAwait(false); -// var result = runner.RunningOutput; -// } -//} From 7cdadf13ff59d75c9f3cc8d80581cb6c7ddac605 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 14:20:25 -0400 Subject: [PATCH 067/232] refactor: Normalize use of ValueTask --- .../Core/CompletedValueTaskSource.cs | 23 ------------------- .../Core/Executor.cs | 6 ++--- .../Execution/LocalRunner.cs | 2 +- .../Execution/LocalRunnerContext.cs | 6 ++--- .../OutputCollectorExecutor.cs | 2 +- .../Sample/02_Simple_Workflow_Sequential.cs | 4 ++-- .../Sample/02a_Simple_Workflow_Condition.cs | 2 +- .../Sample/02b_Simple_Workflow_Loop.cs | 6 ++--- 8 files changed, 14 insertions(+), 37 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs deleted file mode 100644 index 2c8cec1e81..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// Helper class to work around lack of proper ValueTask support in .NET Framework. -/// -internal static class CompletedValueTaskSource -{ - internal static ValueTask Completed => -#if NET5_0_OR_GREATER - ValueTask.CompletedTask; -#else - new(Task.CompletedTask); -#endif - - internal static ValueTask FromResult(T result) - { - return new ValueTask(result); - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index c617bb52e6..6ba34b4457 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -159,7 +159,7 @@ public void RestoreState(IDictionary state) /// protected virtual ValueTask PrepareForCheckpointAsync() { - return CompletedValueTaskSource.Completed; + return default; } /// @@ -168,7 +168,7 @@ protected virtual ValueTask PrepareForCheckpointAsync() /// protected virtual ValueTask AfterCheckpointRestoreAsync() { - return CompletedValueTaskSource.Completed; + return default; } /// @@ -179,7 +179,7 @@ protected virtual ValueTask AfterCheckpointRestoreAsync() protected virtual ValueTask InitializeOverride(IWorkflowContext context) { // Default implementation does nothing. - return CompletedValueTaskSource.Completed; + return default; } private async ValueTask FlushReduceRemainingAsync() diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index 0646a25b49..a155dfe2c2 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -143,7 +143,7 @@ public async ValueTask StreamAsync(TInput input, Cance public ValueTask GetResultAsync(CancellationToken cancellation = default) { // TODO: Block on finishing consuming StreamAsync()? - return CompletedValueTaskSource.FromResult(this.RunningOutput!); + return new ValueTask(this.RunningOutput!); } public TResult? RunningOutput => this._workflow.RunningOutput; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index e915d16157..2a4edcbcf4 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -44,7 +44,7 @@ public ValueTask AddExternalMessageAsync([NotNull] object message) Throw.IfNull(message); this._nextStep.MessagesFor(ExecutorIdentity.None).Add(message); - return CompletedValueTaskSource.Completed; + return default; } public bool NextStepHasActions => this._nextStep.HasMessages; @@ -57,13 +57,13 @@ public StepContext Advance() public ValueTask AddEventAsync(string executorId, WorkflowEvent workflowEvent) { this.QueuedEvents.Add(workflowEvent); - return CompletedValueTaskSource.Completed; + return default; } public ValueTask SendMessageAsync(string executorId, object message) { this._nextStep.MessagesFor(message.GetType().Name).Add(message); - return CompletedValueTaskSource.Completed; + return default; } public IWorkflowContext Bind(string executorId) diff --git a/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs index 3417b36161..3380293cfa 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs @@ -25,6 +25,6 @@ public OutputCollectorExecutor(StreamingAggregator aggregator, public ValueTask HandleAsync(TInput message, IWorkflowContext context) { this.Result = this._aggregator(message); - return CompletedValueTaskSource.Completed; + return default; } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs index af50478e26..c731a2c3f1 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs @@ -28,7 +28,7 @@ internal sealed class UppercaseExecutor : Executor, IMessageHandler HandleAsync(string message, IWorkflowContext context) { - return CompletedValueTaskSource.FromResult(message.ToUpperInvariant()); + return new ValueTask(message.ToUpperInvariant()); } } @@ -38,6 +38,6 @@ public ValueTask HandleAsync(string message, IWorkflowContext context) { char[] charArray = message.ToCharArray(); System.Array.Reverse(charArray); - return CompletedValueTaskSource.FromResult(new string(charArray)); + return new ValueTask(new string(charArray)); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs index d782783404..4040452540 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs @@ -47,7 +47,7 @@ public ValueTask HandleAsync(string message, IWorkflowContext context) bool isSpam = this.SpamKeywords.Any(keyword => message.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0); #endif - return CompletedValueTaskSource.FromResult(isSpam); + return new ValueTask(isSpam); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs index 3ce581bf8b..9b09b23306 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs @@ -79,15 +79,15 @@ public ValueTask HandleAsync(int message, IWorkflowContext context { if (message == this._targetNumber) { - return CompletedValueTaskSource.FromResult(NumberSignal.Matched); + return new ValueTask(NumberSignal.Matched); } else if (message < this._targetNumber) { - return CompletedValueTaskSource.FromResult(NumberSignal.Below); + return new ValueTask(NumberSignal.Below); } else { - return CompletedValueTaskSource.FromResult(NumberSignal.Above); + return new ValueTask(NumberSignal.Above); } } } From b46e385bcc0898e36b6410ee0bb9e414b64f1300 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 10:06:46 -0400 Subject: [PATCH 068/232] fix: Build Break from removing .AddLoop --- .../Sample/02b_Simple_Workflow_Loop.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs index 9b09b23306..df69c47167 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs @@ -14,7 +14,8 @@ public static async ValueTask RunAsync() JudgeExecutor judge = new(42); // Let's say the target number is 42 Workflow workflow = new WorkflowBuilder(guessNumber) - .AddLoop(guessNumber, judge) + .AddEdge(guessNumber, judge) + .AddEdge(judge, guessNumber) .Build(); LocalRunner runner = new(workflow); From 4e2772c91700fe57721185631f06a74be1db1414 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 10:08:37 -0400 Subject: [PATCH 069/232] refactor: Explicit routing and RouteBuilder Split out reflection from MessageRouter implemention into build phase, enabling AOT compilation to drive RouteBuilding without reflection. --- .../Core/Executor.cs | 71 +++++--- .../Core/IMessageRouter.cs | 16 ++ .../Core/MessageRouter.cs | 156 +++--------------- .../Core/RouteBuilder.cs | 92 +++++++++++ .../Core/RouteBuilderExtensions.cs | 78 +++++++++ .../Execution/DirectEdgeRunner.cs | 2 +- .../Execution/FanInEdgeRunner.cs | 2 +- .../Execution/FanOutEdgeRunner.cs | 2 +- .../Execution/InputEdgeRuner.cs | 2 +- 9 files changed, 260 insertions(+), 161 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/IMessageRouter.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 6ba34b4457..ebaea05391 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -27,7 +27,6 @@ public abstract class Executor : IIdentified, IAsyncDisposable /// public string Name { get; } - internal MessageRouter MessageRouter { get; init; } private Dictionary State { get; } = new(); /// @@ -39,8 +38,32 @@ protected Executor(string? id = null, string? name = null) { this.Name = name ?? this.GetType().Name; this.Id = id ?? $"{this.Name}{Guid.NewGuid():N}"; + } + + /// + /// Override this method to register handlers for the executor. The deafult implementation uses reflection to + /// look for implementations of and . + /// + /// + /// + protected virtual RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + return routeBuilder.ReflectHandlers(this); + } - this.MessageRouter = MessageRouter.BindMessageHandlers(this, checkType: true); + private MessageRouter? _router = null; + internal MessageRouter Router + { + get + { + if (this._router == null) + { + RouteBuilder routeBuilder = this.ConfigureRoutes(new RouteBuilder()); + this._router = routeBuilder.Build(); + } + + return this._router; + } } /// @@ -55,7 +78,7 @@ protected Executor(string? id = null, string? name = null) { await context.AddEventAsync(new ExecutorInvokeEvent(this.Id)).ConfigureAwait(false); - CallResult? result = await this.MessageRouter.RouteMessageAsync(message, context, requireRoute: true) + CallResult? result = await this.Router.RouteMessageAsync(message, context, requireRoute: true) .ConfigureAwait(false); await context.AddEventAsync(new ExecutorCompleteEvent(this.Id)).ConfigureAwait(false); @@ -81,10 +104,25 @@ protected Executor(string? id = null, string? name = null) private bool _initialized = false; + /// + /// Ensures that the executor has been initialized before performing operations. + /// + /// This method checks the internal state of the executor and throws an exception if it has not + /// been initialized. Call InitializeAsync before invoking any operations that require + /// initialization. + /// Thrown if the executor has not been initialized by calling InitializeAsync. + protected void CheckInitialized() + { + if (!this._initialized) + { + throw new InvalidOperationException($"Executor {this.GetType().Name} is not initialized. Call InitializeAsync first."); + } + } + /// /// . /// - public ISet InputTypes => this.MessageRouter.IncomingTypes; + public ISet InputTypes => this.Router.IncomingTypes; /// /// . @@ -97,7 +135,7 @@ protected Executor(string? id = null, string? name = null) /// /// /// - public bool CanHandle(Type messageType) => this.MessageRouter.CanHandle(messageType); + public bool CanHandle(Type messageType) => this.Router.CanHandle(messageType); /// /// . @@ -157,35 +195,20 @@ public void RestoreState(IDictionary state) /// . /// /// - protected virtual ValueTask PrepareForCheckpointAsync() - { - return default; - } + protected virtual ValueTask PrepareForCheckpointAsync() => default; /// /// . /// /// - protected virtual ValueTask AfterCheckpointRestoreAsync() - { - return default; - } + protected virtual ValueTask AfterCheckpointRestoreAsync() => default; /// /// . /// /// /// - protected virtual ValueTask InitializeOverride(IWorkflowContext context) - { - // Default implementation does nothing. - return default; - } - - private async ValueTask FlushReduceRemainingAsync() - { - return; - } + protected virtual ValueTask InitializeOverride(IWorkflowContext context) => default; /// /// . @@ -194,8 +217,6 @@ private async ValueTask FlushReduceRemainingAsync() protected virtual async ValueTask DisposeAsync() { this._initialized = false; - - await this.FlushReduceRemainingAsync().ConfigureAwait(false); } ValueTask IAsyncDisposable.DisposeAsync() diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageRouter.cs new file mode 100644 index 0000000000..8876220a8e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageRouter.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows.Core; + +internal interface IMessageRouter +{ + HashSet IncomingTypes { get; } + + bool CanHandle(object message); + bool CanHandle(Type candidateType); + ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs index 0e7fce5082..29281c63bc 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs @@ -2,165 +2,57 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -using HandlerInfosT = - System.Collections.Generic.Dictionary< - System.Type, - Microsoft.Agents.Workflows.Core.MessageHandlerInfo +using MessageHandlerF = + System.Func< + object, // message + Microsoft.Agents.Workflows.Core.IWorkflowContext, // context + System.Threading.Tasks.ValueTask >; namespace Microsoft.Agents.Workflows.Core; -internal class MessageRouter +internal class MessageRouter : IMessageRouter { - // TODO: The goal of the cache is to allow SourceGenerators to do the reflection to bind the handlers in the router. - internal static readonly Dictionary> s_routerFactoryCache = new(); + private readonly Dictionary _typedHandlers; + private readonly bool _hasCatchall; - private Dictionary>> BoundHandlers { get; init; } = new(); - private IDefaultMessageHandler? DefaultHandler { get; init; } = null; - - [SuppressMessage("Trimming", - "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + - "of the source method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - [SuppressMessage("Trimming", - "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of " + - "method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - private static HandlerInfosT ReflectHandlers(Type executorType) + internal MessageRouter(Dictionary handlers) { - // This method reflects over the methods of the executor type to find message handlers. - HandlerInfosT handlers = new(); - - // Get all implementations of IMessageHandler or IMessageHandler - // and create a MessageHandlerInfo for each. - if (!typeof(Executor).IsAssignableFrom(executorType)) - { - throw new ArgumentException($"Type {executorType.FullName} is not a valid Executor type.", nameof(executorType)); - } - - if (executorType.IsAbstract || executorType.IsInterface) - { - throw new ArgumentException($"Type {executorType.FullName} cannot be abstract or an interface.", nameof(executorType)); - } - - // Iterate all interfaces implemented by the executor type. - foreach (Type interfaceType in executorType.GetInterfaces()) - { - // Check if the interface is a message handler. - if (!interfaceType.IsGenericType) - { - continue; - } - - if (interfaceType.IsGenericType && (interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || - interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>))) - { - // Get the generic arguments of the interface. - Type[] genericArguments = interfaceType.GetGenericArguments(); - if (genericArguments.Length < 1 || genericArguments.Length > 2) - { - continue; // Invalid handler signature. - } - Type inType = genericArguments[0]; - Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; - MethodInfo? method = interfaceType.GetMethod("HandleAsync"); - if (method != null) - { - MessageHandlerInfo info = new(method) { InType = inType, OutType = outType }; - handlers[inType] = info; - } - } - } - - return handlers; + this._typedHandlers = Throw.IfNull(handlers); + this._hasCatchall = this._typedHandlers.ContainsKey(typeof(object)); } - [SuppressMessage("Trimming", - "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + - "of the source method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - internal static HandlerInfosT ReflectHandlers() where TExecutor : Executor - => ReflectHandlers(typeof(TExecutor)); - - internal static MessageRouter BindMessageHandlers(Executor executor, bool checkType) - { - if (s_routerFactoryCache.TryGetValue(executor.GetType(), out var factory)) - { - return factory(); - } - - // If no factory is found, reflect over the handlers - HandlerInfosT handlers = ReflectHandlers(executor.GetType()); + public HashSet IncomingTypes => [.. this._typedHandlers.Keys]; - Dictionary>> boundHandlers = new(); - foreach (Type inType in handlers.Keys) - { - MessageHandlerInfo handlerInfo = handlers[inType]; - Func> boundHandler = handlerInfo.Bind(executor, checkType); - boundHandlers.Add(inType, boundHandler); // TODO: Turn the error here into something more actionable. - } - - return new MessageRouter(boundHandlers, executor as IDefaultMessageHandler); - } + public bool CanHandle(object message) => this.CanHandle(Throw.IfNull(message).GetType()); - internal MessageRouter(Dictionary>> handlers, IDefaultMessageHandler? defaultHandler = null) + public bool CanHandle(Type candidateType) { - this.BoundHandlers = handlers; - this.DefaultHandler = defaultHandler; + // For now we only support routing to handlers registered on the exact type (no base type delegation). + return this._hasCatchall || this._typedHandlers.ContainsKey(candidateType); } - /// - /// . - /// - /// - /// - /// - /// - /// - /// public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) { Throw.IfNull(message); - // TODO: Implement base type delegation? CallResult? result = null; - if (this.BoundHandlers.TryGetValue(message.GetType(), out Func>? handler)) - { - result = await handler(message, context).ConfigureAwait(false); - } - else if (this.DefaultHandler != null) + + try { - try - { - await this.DefaultHandler.HandleAsync(message, context).ConfigureAwait(false); - result = CallResult.ReturnVoid(); - } - catch (Exception e) + if (this._typedHandlers.TryGetValue(message.GetType(), out MessageHandlerF? handler)) { - result = CallResult.RaisedException(wasVoid: true, e); + result = await handler(message, context).ConfigureAwait(false); } } + catch (Exception e) + { + result = CallResult.RaisedException(wasVoid: true, e); + } return result; } - - public bool CanHandle(object message) => this.CanHandle(Throw.IfNull(message).GetType()); - - public bool CanHandle(Type candidateType) - { - Throw.IfNull(candidateType); - - // Check if the router can handle the candidate type. - return this.DefaultHandler != null || this.BoundHandlers.ContainsKey(candidateType); - } - - public HashSet IncomingTypes - => this.DefaultHandler != null - ? [.. this.BoundHandlers.Keys, typeof(object)] - : [.. this.BoundHandlers.Keys]; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs new file mode 100644 index 0000000000..e480aeb97d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +using MessageHandlerF = + System.Func< + object, // message + Microsoft.Agents.Workflows.Core.IWorkflowContext, // context + System.Threading.Tasks.ValueTask + >; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +public class RouteBuilder +{ + private readonly Dictionary _typedHandlers = new(); + + internal RouteBuilder AddHandler(Type messageType, MessageHandlerF handler, bool overwrite = false) + { + Throw.IfNull(messageType); + Throw.IfNull(handler); + + // Overwrite must be false if the type is not registered. Overwrite must be true if the type is registered. + if (this._typedHandlers.ContainsKey(messageType) == overwrite) + { + this._typedHandlers[messageType] = handler; + } + else if (overwrite) + { + // overwrite is true, but the type is not registered. + throw new ArgumentException($"A handler for message type {messageType.FullName} has not yet been registered (overwrite = true)."); + } + else if (!overwrite) + { + throw new ArgumentException($"A handler for message type {messageType.FullName} is already registered (overwrite = false)."); + } + + return this; + } + + /// + /// . + /// + /// + /// + /// + /// + public RouteBuilder AddHandler(Func handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddHandler(typeof(TInput), WrappedHandlerAsync, overwrite); + + async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) + { + await handler.Invoke((TInput)msg, ctx).ConfigureAwait(false); + return CallResult.ReturnVoid(); + } + } + + /// + /// . + /// + /// + /// + /// + /// + /// + public RouteBuilder AddHandler(Func> handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddHandler(typeof(TInput), WrappedHandlerAsync, overwrite); + + async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) + { + TResult result = await handler.Invoke((TInput)msg, ctx).ConfigureAwait(false); + return CallResult.ReturnResult(result); + } + } + + internal MessageRouter Build() + { + return new MessageRouter(this._typedHandlers); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs new file mode 100644 index 0000000000..7d7aa7a7a3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Core; + +internal static class RouteBuilderExtensions +{ + [SuppressMessage("Trimming", + "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + + "of the source method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + [SuppressMessage("Trimming", + "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of " + + "method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + private static IEnumerable GetHandlerInfos(this Type executorType) + { + // Handlers are defined by implementations of IMessageHandler or IMessageHandler + Debug.Assert(typeof(Executor).IsAssignableFrom(executorType), "executorType must be an Executor type."); + + foreach (Type interfaceType in executorType.GetInterfaces()) + { + // Check if the interface is a message handler. + if (!interfaceType.IsGenericType) + { + continue; + } + + if (interfaceType.IsGenericType && (interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || + interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>))) + { + // Get the generic arguments of the interface. + Type[] genericArguments = interfaceType.GetGenericArguments(); + if (genericArguments.Length < 1 || genericArguments.Length > 2) + { + continue; // Invalid handler signature. + } + Type inType = genericArguments[0]; + Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; + MethodInfo? method = interfaceType.GetMethod("HandleAsync"); + + if (method != null) + { + yield return new MessageHandlerInfo(method) { InType = inType, OutType = outType }; + } + } + } + } + + public static RouteBuilder ReflectHandlers(this RouteBuilder builder, Type executorType, Executor executor) + { + Throw.IfNull(builder); + Throw.IfNull(executorType); + + Debug.Assert(typeof(Executor).IsAssignableFrom(executorType), "executorType must be an Executor type."); + + foreach (MessageHandlerInfo handlerInfo in executorType.GetHandlerInfos()) + { + builder = builder.AddHandler(handlerInfo.InType, handlerInfo.Bind(executor, checkType: true)); + } + + if (executor is IDefaultMessageHandler defaultHandler) + { + builder = builder.AddHandler(defaultHandler.HandleAsync); + } + + return builder; + } + + public static RouteBuilder ReflectHandlers(this RouteBuilder builder, TExecutor executor) where TExecutor : Executor + => builder.ReflectHandlers(typeof(TExecutor), executor); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs index 29b1b2dc83..8908a6e3e6 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs @@ -16,7 +16,7 @@ private async ValueTask FindRouterAsync() Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) .ConfigureAwait(false); - return sink.MessageRouter; + return sink.Router; } public async ValueTask> ChaseAsync(object message) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs index 585f7e1833..3d8db74eca 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs @@ -25,7 +25,7 @@ internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) .ConfigureAwait(false); - MessageRouter router = sink.MessageRouter; + MessageRouter router = sink.Router; if (router.CanHandle(message)) { return await router.RouteMessageAsync(message, this.BoundContext) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs index 7ff3e9c171..59afcd1ced 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs @@ -30,7 +30,7 @@ internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeDa Executor executor = await this.RunContext.EnsureExecutorAsync(targetId) .ConfigureAwait(false); - MessageRouter router = executor.MessageRouter; + MessageRouter router = executor.Router; if (router.CanHandle(message)) { return await router.RouteMessageAsync(message, this.BoundContexts[targetId]) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs index 2ba64f1ee8..24c5622b80 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs @@ -15,7 +15,7 @@ private async ValueTask FindRouterAsync() Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData) .ConfigureAwait(false); - return sink.MessageRouter; + return sink.Router; } public async ValueTask ChaseAsync(object message) From 799decec5adc1bb2b5db7c779180662ec4a8f133 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 10:43:31 -0400 Subject: [PATCH 070/232] test: Add Reflection/Invocation tests --- .../Core/RouteBuilderExtensions.cs | 2 +- .../ReflectionSmokeTest.cs | 136 ++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs index 7d7aa7a7a3..601e5de199 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs @@ -74,5 +74,5 @@ public static RouteBuilder ReflectHandlers(this RouteBuilder builder, Type execu } public static RouteBuilder ReflectHandlers(this RouteBuilder builder, TExecutor executor) where TExecutor : Executor - => builder.ReflectHandlers(typeof(TExecutor), executor); + => builder.ReflectHandlers(executor.GetType(), executor); } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs new file mode 100644 index 0000000000..2a3734f7b4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Moq; + +namespace Microsoft.Agents.Orchestration.UnitTest; + +public class BaseTestExecutor : Executor +{ + protected void OnInvokedHandler() + { + this.InvokedHandler = true; + } + + public bool InvokedHandler + { + get; + private set; + } = false; +} + +public class DefaultHandler : BaseTestExecutor, IDefaultMessageHandler +{ + public ValueTask HandleAsync(object message, IWorkflowContext context) + { + this.OnInvokedHandler(); + return this.Handler(message, context); + } + + public Func Handler + { + get; + set; + } = (message, context) => default; +} + +public class TypedHandler : BaseTestExecutor, IMessageHandler +{ + public ValueTask HandleAsync(TInput message, IWorkflowContext context) + { + this.OnInvokedHandler(); + return this.Handler(message, context); + } + + public Func Handler + { + get; + set; + } = (message, context) => default; +} + +public class TypedHandlerWithOutput : BaseTestExecutor, IMessageHandler +{ + public ValueTask HandleAsync(TInput message, IWorkflowContext context) + { + this.OnInvokedHandler(); + return this.Handler(message, context); + } + public Func> Handler + { + get; + set; + } = (message, context) => default; +} + +public class RoutingReflectionTests +{ + private async ValueTask RunTestReflectAndRouteMessageAsync(BaseTestExecutor executor, TInput? input = default) where TInput : new() + { + MessageRouter router = executor.Router; + + Assert.NotNull(router); + input ??= new(); + Assert.True(router.CanHandle(input.GetType())); + Assert.True(router.CanHandle(input)); + + CallResult? result = await router.RouteMessageAsync(input, Mock.Of()); + + Assert.True(executor.InvokedHandler); + + return result; + } + + [Fact] + public async Task Test_ReflectAndExecute_DefaultHandlerAsync() + { + DefaultHandler executor = new(); + + CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor); + + Assert.NotNull(result); + Assert.True(result.IsSuccess); + Assert.True(result.IsVoid); + + await ((IAsyncDisposable)executor).DisposeAsync(); + } + + [Fact] + public async Task Test_ReflectAndExecute_HandlerReturnsVoidAsync() + { + TypedHandler executor = new(); + + CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor, 3); + + Assert.NotNull(result); + Assert.True(result.IsSuccess); + Assert.True(result.IsVoid); + + await ((IAsyncDisposable)executor).DisposeAsync(); + } + + [Fact] + public async Task Test_ReflectAndExecute_HandlerReturnsValueAsync() + { + TypedHandlerWithOutput executor = new() + { + Handler = (message, context) => + { + return new ValueTask($"{message}"); + } + }; + + const string Expected = "3"; + CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor, int.Parse(Expected)); + + Assert.NotNull(result); + Assert.True(result.IsSuccess); + Assert.False(result.IsVoid); + + Assert.Equal(Expected, result.Result); + + await ((IAsyncDisposable)executor).DisposeAsync(); + } +} From 3cfd5fd7f8487f03e3eb9269d1766085db9e41a4 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 10:06:32 -0400 Subject: [PATCH 071/232] fix: Terminate on Completion event --- .../Execution/StreamingExecutionHandle.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs index ca915e24e7..048b6fa5ad 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs @@ -49,10 +49,23 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell { while (await this._stepRunner.RunSuperStepAsync(cancellation).ConfigureAwait(false)) { + bool hadCompletionEvent = false; List outputEvents = Interlocked.Exchange(ref eventSink, new()); foreach (WorkflowEvent raisedEvent in outputEvents) { yield return raisedEvent; + + // TODO: Do we actually want to interpret this as a termination request? + if (raisedEvent is WorkflowCompletedEvent) + { + hadCompletionEvent = true; + } + } + + if (hadCompletionEvent) + { + // If we had a completion event, we are done. + yield break; } } } From 95eba65724a27ee5900e86824d90e21af07d5a2d Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 11:15:25 -0400 Subject: [PATCH 072/232] refactor: Update public API surface --- .../Core/CallResult.cs | 2 +- .../Microsoft.Agents.Workflow/Core/Edge.cs | 150 ++++++++++++++++++ .../Microsoft.Agents.Workflow/Core/Edges.cs | 89 ----------- .../Core/Executor.cs | 4 +- .../Core/IDefaultMessageHandler.cs | 22 --- .../Core/IWorkflowContext.cs | 13 +- .../Core/RouteBuilderExtensions.cs | 5 - .../Core/Workflow.cs | 68 ++++---- .../Execution/EdgeMap.cs | 22 +-- .../Execution/LocalRunner.cs | 69 ++++++-- .../Microsoft.Agents.Workflow/ExecutorIsh.cs | 52 +++++- .../StreamingAggregators.cs | 56 ++++++- .../WorkflowBuilder.cs | 72 +++++++-- .../WorkflowBuilderExtensions.cs | 32 +++- .../ReflectionSmokeTest.cs | 2 +- 15 files changed, 453 insertions(+), 205 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs index 934c03d43c..482a228e1a 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.Workflows.Core; /// This class represents the result of a call to a /// or . /// -public sealed class CallResult +internal sealed class CallResult { /// /// Indicates whether the call was void (i.e., no result expected). This only applies to diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs new file mode 100644 index 0000000000..de2dc0ed44 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +using PredicateT = System.Func; +using PartitionerT = System.Func>; +using System; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +/// +/// +/// +public record DirectEdgeData( + string SourceId, + string SinkId, + PredicateT? Condition = null) +{ + /// + /// . + /// + /// + public static implicit operator Edge(DirectEdgeData data) + { + return new Edge(data); + } +} + +/// +/// . +/// +/// +/// +/// +public record FanOutEdgeData( + string SourceId, + List SinkIds, + PartitionerT? Partitioner = null) +{ + /// + /// . + /// + /// + public static implicit operator Edge(FanOutEdgeData data) + { + return new Edge(data); + } +} + +/// +/// . +/// +public enum FanInTrigger +{ + /// + /// . + /// + WhenAll, + /// + /// . + /// + WhenAny +} + +/// +/// . +/// +/// +/// +/// +public record FanInEdgeData( + IEnumerable SourceIds, + string SinkId, + FanInTrigger Trigger = FanInTrigger.WhenAll) +{ + internal Guid UniqueKey { get; } = Guid.NewGuid(); + + /// + /// . + /// + /// + public static implicit operator Edge(FanInEdgeData data) + { + return new Edge(data); + } +} + +/// +/// . +/// +public class Edge +{ + /// + /// . + /// + public enum Type + { + /// + /// . + /// + Direct, + /// + /// . + /// + FanOut, + /// + /// . + /// + FanIn + } + + /// + /// . + /// + public Type EdgeType { get; init; } + + /// + /// . + /// + public object Data { get; init; } + + internal Edge(DirectEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.Direct; + } + + internal Edge(FanOutEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.FanOut; + } + + internal Edge(FanInEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.FanIn; + } + + internal DirectEdgeData? DirectEdgeData => this.Data as DirectEdgeData; + internal FanOutEdgeData? FanOutEdgeData => this.Data as FanOutEdgeData; + internal FanInEdgeData? FanInEdgeData => this.Data as FanInEdgeData; +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs deleted file mode 100644 index b17d8ebb54..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using Microsoft.Shared.Diagnostics; - -using PredicateT = System.Func; -using PartitionerT = System.Func>; -using System; - -namespace Microsoft.Agents.Workflows.Core; - -internal record DirectEdgeData( - string SourceId, - string SinkId, - PredicateT? Condition = null) -{ - public static implicit operator FlowEdge(DirectEdgeData data) - { - return new FlowEdge(data); - } -} - -internal record FanOutEdgeData( - string SourceId, - List SinkIds, - PartitionerT? Partitioner = null) -{ - public static implicit operator FlowEdge(FanOutEdgeData data) - { - return new FlowEdge(data); - } -} - -internal enum FanInTrigger -{ - WhenAll, - WhenAny -} - -internal record FanInEdgeData( - IEnumerable SourceIds, - string SinkId, - FanInTrigger Trigger = FanInTrigger.WhenAll) -{ - internal Guid UniqueKey { get; } = Guid.NewGuid(); - - public static implicit operator FlowEdge(FanInEdgeData data) - { - return new FlowEdge(data); - } -} - -internal class FlowEdge -{ - public enum Type - { - Direct, - FanOut, - FanIn - } - - public Type EdgeType { get; init; } - public object Data { get; init; } - - public FlowEdge(DirectEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.Direct; - } - - public FlowEdge(FanOutEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.FanOut; - } - - public FlowEdge(FanInEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.FanIn; - } - - public DirectEdgeData? DirectEdgeData => this.Data as DirectEdgeData; - public FanOutEdgeData? FanOutEdgeData => this.Data as FanOutEdgeData; - public FanInEdgeData? FanInEdgeData => this.Data as FanInEdgeData; -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index ebaea05391..5e43a5c98b 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -127,8 +126,7 @@ protected void CheckInitialized() /// /// . /// - [SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "")] - public ISet OutputTypes => throw new NotImplementedException(); + public virtual ISet OutputTypes => new HashSet(); /// /// . diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs deleted file mode 100644 index bd8de4e48b..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// A default message handler interface for handling messages that do not have a specific handler registered. -/// -public interface IDefaultMessageHandler -{ - /// - /// Handles the incoming message asynchronously. - /// - /// - /// This is used as a fallback handler for messages that do not have a specific handler registered. - /// - /// The message to handle. - /// The execution context. - /// A task that represents the asynchronous operation. - ValueTask HandleAsync(object message, IWorkflowContext context); -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs index decf8ce8d4..bf5528db9c 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs @@ -10,17 +10,18 @@ namespace Microsoft.Agents.Workflows.Core; public interface IWorkflowContext { /// - /// . + /// Adds an event to the workflow's output queue. These events will be raised to the caller of the workflow at the + /// end of the current SuperStep. /// - /// - /// + /// The event to be raised. + /// A representing the asynchronous operation. ValueTask AddEventAsync(WorkflowEvent workflowEvent); /// - /// . + /// Queues a message to be sent to connected executors. The message will be sent during the next SuperStep. /// - /// - /// + /// The message to be sent. + /// A representing the asynchronous operation. ValueTask SendMessageAsync(object message); // TODO: State management diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs index 601e5de199..2beadc3ec5 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs @@ -65,11 +65,6 @@ public static RouteBuilder ReflectHandlers(this RouteBuilder builder, Type execu builder = builder.AddHandler(handlerInfo.InType, handlerInfo.Bind(executor, checkType: true)); } - if (executor is IDefaultMessageHandler defaultHandler) - { - builder = builder.AddHandler(defaultHandler.HandleAsync); - } - return builder; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs index e246635e89..c49afff737 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs @@ -6,65 +6,72 @@ namespace Microsoft.Agents.Workflows.Core; -internal class Workflow +/// +/// . +/// +public class Workflow { + /// + /// . + /// public Dictionary> ExecutorProviders { get; internal init; } = new(); - public Dictionary> Edges { get; internal init; } = new(); -#if NET9_0_OR_GREATER - required -#endif - public string StartExecutorId - { get; init; } + /// + /// . + /// + public Dictionary> Edges { get; internal init; } = new(); -#if NET9_0_OR_GREATER - required -#endif - public Type InputType - { get; init; } = typeof(object); + /// + /// . + /// + public string StartExecutorId { get; } - public Workflow(string startExecutorId, Type type) + /// + /// . + /// + public Type InputType { get; } + + internal Workflow(string startExecutorId, Type type) { this.StartExecutorId = Throw.IfNull(startExecutorId); this.InputType = Throw.IfNull(type); // TODO: How do we (1) ensure the types are happy, and (2) work under AOT? } - -#if NET9_0_OR_GREATER - public Workflow() - { } -#endif } -internal class Workflow : Workflow +/// +/// . +/// +/// +public class Workflow : Workflow { + /// + /// . + /// + /// public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) { } -#if NET9_0_OR_GREATER - public Workflow() - { - this.InputType = typeof(T); - } -#endif - internal Workflow Promote(OutputSink outputSource) { Throw.IfNull(outputSource); return new Workflow(this.StartExecutorId, outputSource) { - StartExecutorId = this.StartExecutorId, ExecutorProviders = this.ExecutorProviders, Edges = this.Edges, - InputType = this.InputType, }; } } -internal class Workflow : Workflow +/// +/// . +/// +/// +/// +public class Workflow : Workflow { private readonly OutputSink _output; @@ -74,5 +81,8 @@ internal Workflow(string startExecutorId, OutputSink outputSource) this._output = Throw.IfNull(outputSource); } + /// + /// . + /// public TResult? RunningOutput => this._output.Result; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs index 0b84cba03a..373b443ec2 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs @@ -10,19 +10,19 @@ namespace Microsoft.Agents.Workflows.Execution; internal class EdgeMap { - private readonly Dictionary _edgeRunners = new(); - private readonly Dictionary _fanInState = new(); + private readonly Dictionary _edgeRunners = new(); + private readonly Dictionary _fanInState = new(); private readonly InputEdgeRuner _inputRunner; - public EdgeMap(IRunnerContext runContext, Dictionary> workflowEdges, string startExecutorId) + public EdgeMap(IRunnerContext runContext, Dictionary> workflowEdges, string startExecutorId) { - foreach (FlowEdge edge in workflowEdges.Values.SelectMany(e => e)) + foreach (Edge edge in workflowEdges.Values.SelectMany(e => e)) { object edgeRunner = edge.EdgeType switch { - FlowEdge.Type.Direct => new DirectEdgeRunner(runContext, edge.DirectEdgeData!), - FlowEdge.Type.FanOut => new FanOutEdgeRunner(runContext, edge.FanOutEdgeData!), - FlowEdge.Type.FanIn => new FanInEdgeRunner(runContext, edge.FanInEdgeData!), + Edge.Type.Direct => new DirectEdgeRunner(runContext, edge.DirectEdgeData!), + Edge.Type.FanOut => new FanOutEdgeRunner(runContext, edge.FanOutEdgeData!), + Edge.Type.FanIn => new FanInEdgeRunner(runContext, edge.FanInEdgeData!), _ => throw new NotSupportedException($"Unsupported edge type: {edge.EdgeType}") }; @@ -32,7 +32,7 @@ public EdgeMap(IRunnerContext runContext, Dictionary> this._inputRunner = new InputEdgeRuner(runContext, startExecutorId); } - public async ValueTask> InvokeEdgeAsync(FlowEdge edge, string sourceId, object message) + public async ValueTask> InvokeEdgeAsync(Edge edge, string sourceId, object message) { if (!this._edgeRunners.TryGetValue(edge, out object? edgeRunner)) { @@ -48,21 +48,21 @@ public EdgeMap(IRunnerContext runContext, Dictionary> // in FanIn/Out cases) // TODO: Once we have a fixed interface, if it is reasonably generalizable // between the Runners, we can normalize it behind an IFace. - case FlowEdge.Type.Direct: + case Edge.Type.Direct: { DirectEdgeRunner runner = (DirectEdgeRunner)this._edgeRunners[edge]; edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); break; } - case FlowEdge.Type.FanOut: + case Edge.Type.FanOut: { FanOutEdgeRunner runner = (FanOutEdgeRunner)this._edgeRunners[edge]; edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); break; } - case FlowEdge.Type.FanIn: + case Edge.Type.FanIn: { FanInEdgeState state = this._fanInState[edge]; FanInEdgeRunner runner = (FanInEdgeRunner)this._edgeRunners[edge]; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index a155dfe2c2..e5bc824348 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -10,8 +10,16 @@ namespace Microsoft.Agents.Workflows.Execution; -internal class LocalRunner : ISuperStepRunner where TInput : notnull +/// +/// . +/// +/// +public class LocalRunner : ISuperStepRunner where TInput : notnull { + /// + /// . + /// + /// public LocalRunner(Workflow workflow) { this.Workflow = Throw.IfNull(workflow); @@ -27,13 +35,19 @@ ValueTask ISuperStepRunner.EnqueueMessageAsync(object message) return this.RunContext.AddExternalMessageAsync(message); } - protected Dictionary PendingCalls { get; } = new(); - protected Workflow Workflow { get; init; } - protected LocalRunnerContext RunContext { get; init; } - protected EdgeMap EdgeMap { get; init; } + private Dictionary PendingCalls { get; } = new(); + private Workflow Workflow { get; init; } + private LocalRunnerContext RunContext { get; init; } + private EdgeMap EdgeMap { get; init; } // TODO: Better signature? - public event EventHandler? WorkflowEvent; + event EventHandler? ISuperStepRunner.WorkflowEvent + { + add => this.WorkflowEvent += value; + remove => this.WorkflowEvent -= value; + } + + private event EventHandler? WorkflowEvent; private void RaiseWorkflowEvent(WorkflowEvent workflowEvent) { @@ -56,6 +70,12 @@ private bool IsResponse(object message) : this.EdgeMap.InvokeInputAsync(message); } + /// + /// . + /// + /// + /// + /// public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) { await this.RunContext.AddExternalMessageAsync(input).ConfigureAwait(false); @@ -64,7 +84,7 @@ public async ValueTask StreamAsync(TInput input, Cance } private StepContext? _currentStep = null; - public async ValueTask RunSuperStepAsync(CancellationToken cancellation) + async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cancellation) { cancellation.ThrowIfCancellationRequested(); @@ -99,8 +119,8 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) } else { - HashSet outgoingEdges = this.Workflow.Edges[sender.Id!]; // Id is not null when Identity is not .None - foreach (FlowEdge outgoingEdge in outgoingEdges) + HashSet outgoingEdges = this.Workflow.Edges[sender.Id!]; // Id is not null when Identity is not .None + foreach (Edge outgoingEdge in outgoingEdges) { edgeTasks.AddRange(senderMessages.Select(message => this.EdgeMap.InvokeEdgeAsync(outgoingEdge, sender.Id, message).AsTask())); } @@ -122,31 +142,54 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) } } -internal class LocalRunner : IRunnerWithResult where TInput : notnull +/// +/// . +/// +/// +/// +public class LocalRunner : IRunnerWithResult where TInput : notnull { private readonly Workflow _workflow; - private readonly LocalRunner _innerRunner; + private readonly ISuperStepRunner _innerRunner; + /// + /// . + /// + /// public LocalRunner(Workflow workflow) { this._workflow = Throw.IfNull(workflow); this._innerRunner = new LocalRunner(workflow); } + /// + /// . + /// + /// + /// + /// public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) { - await this.StepRunner.EnqueueMessageAsync(input).ConfigureAwait(false); + await this._innerRunner.EnqueueMessageAsync(input).ConfigureAwait(false); return new StreamingExecutionHandle(this._innerRunner); } + /// + /// . + /// + /// + /// public ValueTask GetResultAsync(CancellationToken cancellation = default) { // TODO: Block on finishing consuming StreamAsync()? return new ValueTask(this.RunningOutput!); } + /// + /// . + /// public TResult? RunningOutput => this._workflow.RunningOutput; - public ISuperStepRunner StepRunner => this._innerRunner; + ISuperStepRunner IRunnerWithResult.StepRunner => this._innerRunner; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs index a1fa16dd58..6200dc5d2e 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -6,41 +6,65 @@ namespace Microsoft.Agents.Workflows; -internal sealed class ExecutorIsh : +/// +/// . +/// +public sealed class ExecutorIsh : IIdentified, IEquatable, IEquatable, IEquatable { + /// + /// . + /// public enum Type { + /// + /// . + /// Unbound, + /// + /// . + /// Executor, //Function, //Agent, //ProcessStep } + /// + /// . + /// public Type ExecutorType { get; init; } private readonly string? _idValue; private readonly Executor? _executorValue; //private readonly Func? _functionValue; + /// + /// . + /// + /// public ExecutorIsh(Executor executor) { this.ExecutorType = Type.Executor; this._executorValue = Throw.IfNull(executor); } + /// + /// . + /// + /// public ExecutorIsh(string id) { this.ExecutorType = Type.Unbound; this._idValue = Throw.IfNull(id); } - public bool IsUnbound => this.ExecutorType == Type.Unbound; + internal bool IsUnbound => this.ExecutorType == Type.Unbound; + /// public string Id => this.ExecutorType switch { Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), @@ -48,9 +72,12 @@ public ExecutorIsh(string id) //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), - _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") }; + /// + /// . + /// public ExecutorProvider ExecutorProvider => this.ExecutorType switch { Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), @@ -58,7 +85,7 @@ public ExecutorIsh(string id) //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), - _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") }; //public ExecutorIsh(Func function) @@ -67,7 +94,10 @@ public ExecutorIsh(string id) // this._functionValue = Throw.IfNull(function); //} - // Implicit conversions into ExecutorIsh + /// + /// . + /// + /// public static implicit operator ExecutorIsh(Executor executor) { return new ExecutorIsh(executor); @@ -79,29 +109,37 @@ public static implicit operator ExecutorIsh(Executor executor) // return new ExecutorIsh(function); //} + /// + /// . + /// + /// public static implicit operator ExecutorIsh(string id) { return new ExecutorIsh(id); } + /// public bool Equals(ExecutorIsh? other) { return other is not null && other.Id == this.Id; } + /// public bool Equals(IIdentified? other) { return other is not null && other.Id == this.Id; } + /// public bool Equals(string? other) { return other is not null && other == this.Id; } + /// public override bool Equals(object? obj) { if (obj is null) @@ -125,11 +163,13 @@ public override bool Equals(object? obj) return false; } + /// public override int GetHashCode() { return this.Id.GetHashCode(); } + /// public override string ToString() { return this.ExecutorType switch @@ -139,7 +179,7 @@ public override string ToString() //Type.Function => $"ExecutorIsh for Function with ID '{this.Id}'", //Type.Agent => $"ExecutorIsh for Agent with ID '{this.Id}'", //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", - _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + _ => $"'{this.Id}':(TInput input); - -internal static class StreamingAggregators +/// +/// . +/// +/// +/// +/// +/// +public delegate TResult? StreamingAggregator(TInput input); + +/// +/// . +/// +public static class StreamingAggregators { + /// + /// . + /// + /// + /// + /// + /// + /// public static StreamingAggregator First(Func conversion, TResult? defaultValue = default) { bool hasRun = false; @@ -27,9 +45,23 @@ public static StreamingAggregator First(Func + /// . + /// + /// + /// + /// public static StreamingAggregator First(TInput? defaultValue = default) => First(input => input, defaultValue); + /// + /// . + /// + /// + /// + /// + /// + /// public static StreamingAggregator Last(Func conversion, TResult? defaultValue = default) { TResult? local = defaultValue; @@ -43,9 +75,22 @@ public static StreamingAggregator Last(Func + /// . + /// + /// + /// + /// public static StreamingAggregator Last(TInput? defaultValue = default) => Last(input => input, defaultValue); + /// + /// . + /// + /// + /// + /// + /// public static StreamingAggregator> Union(Func conversion) { List results = new(); @@ -59,6 +104,11 @@ IEnumerable Aggregate(TInput input) } } + /// + /// . + /// + /// + /// public static StreamingAggregator> Union() => Union(input => input); } diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index 28416db074..d92bef1c8a 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -15,23 +15,35 @@ namespace Microsoft.Agents.Workflows; -internal delegate TExecutor ExecutorProvider() +/// +/// A factory method that produces an executor instance. +/// +/// The executor type. +/// A new instance. +public delegate TExecutor ExecutorProvider() where TExecutor : Executor; -internal record struct EdgeId(string SourceId, string TargetId) +/// +/// . +/// +public class WorkflowBuilder { - public override string ToString() => $"{this.SourceId} -> {this.TargetId}"; -} + private record struct EdgeId(string SourceId, string TargetId) + { + public override string ToString() => $"{this.SourceId} -> {this.TargetId}"; + } -internal class WorkflowBuilder -{ private readonly Dictionary> _executors = new(); - private readonly Dictionary> _edges = new(); + private readonly Dictionary> _edges = new(); private readonly HashSet _unboundExecutors = new(); private readonly HashSet _conditionlessEdges = new(); private readonly string _startExecutorId; + /// + /// . + /// + /// public WorkflowBuilder(ExecutorIsh start) { this._startExecutorId = this.Track(start).Id; @@ -63,6 +75,12 @@ private void UpdateExecutor(string id, ExecutorProvider provider) this._executors[id] = provider; } + /// + /// . + /// + /// + /// + /// public WorkflowBuilder BindExecutor(Executor executor) { if (!this._unboundExecutors.Contains(executor.Id)) @@ -76,18 +94,26 @@ public WorkflowBuilder BindExecutor(Executor executor) return this; } - private HashSet EnsureEdgesFor(string sourceId) + private HashSet EnsureEdgesFor(string sourceId) { // Ensure that there is a set of edges for the given source ID. // If it does not exist, create a new one. - if (!this._edges.TryGetValue(sourceId, out HashSet? edges)) + if (!this._edges.TryGetValue(sourceId, out HashSet? edges)) { - this._edges[sourceId] = edges = new HashSet(); + this._edges[sourceId] = edges = new HashSet(); } return edges; } + /// + /// . + /// + /// + /// + /// + /// + /// public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func? condition = null) { // Add an edge from source to target with an optional condition. @@ -110,8 +136,13 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func + /// . + /// + /// + /// + /// + /// public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func>? partitioner = null, params ExecutorIsh[] targets) { Throw.IfNull(source); @@ -126,6 +157,13 @@ public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func + /// . + /// + /// + /// + /// + /// public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = default, params ExecutorIsh[] sources) { Throw.IfNull(target); @@ -144,6 +182,12 @@ public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = d return this; } + /// + /// . + /// + /// + /// + /// public Workflow Build() { if (this._unboundExecutors.Count > 0) @@ -173,9 +217,7 @@ public Workflow Build() return new Workflow(this._startExecutorId) // Why does it not see the default ctor? { ExecutorProviders = this._executors, - Edges = this._edges, - StartExecutorId = this._startExecutorId, - InputType = typeof(T) + Edges = this._edges }; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index 7b2836952b..aa8fe8c8ce 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -7,8 +7,19 @@ namespace Microsoft.Agents.Workflows; -internal static class WorkflowBuilderExtensions +/// +/// . +/// +public static class WorkflowBuilderExtensions { + /// + /// . + /// + /// + /// + /// + /// + /// public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors) { Throw.IfNull(builder); @@ -34,9 +45,28 @@ public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh return builder; } + /// + /// . + /// + /// + /// + /// + /// + /// + /// public static Workflow BuildWithOutput(this WorkflowBuilder builder, ExecutorIsh outputSource, StreamingAggregator aggregator) => builder.BuildWithOutput(outputSource, aggregator); + /// + /// . + /// + /// + /// + /// + /// + /// + /// + /// public static Workflow BuildWithOutput(this WorkflowBuilder builder, ExecutorIsh outputSource, StreamingAggregator aggregator) { Throw.IfNull(outputSource); diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs index 2a3734f7b4..ae6cb303a1 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs @@ -21,7 +21,7 @@ public bool InvokedHandler } = false; } -public class DefaultHandler : BaseTestExecutor, IDefaultMessageHandler +public class DefaultHandler : BaseTestExecutor, IMessageHandler { public ValueTask HandleAsync(object message, IWorkflowContext context) { From d369d1b4c107fe3cfd50560174cda6dca9d08568 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 18:23:48 -0400 Subject: [PATCH 073/232] feat: Add support for external requests --- .../Microsoft.Agents.Workflow/Core/Events.cs | 5 ++ .../Core/ExternalRequest.cs | 73 +++++++++++++++++++ .../Core/ExternalResponse.cs | 16 ++++ .../Core/InputPort.cs | 14 ++++ .../Core/Workflow.cs | 6 ++ .../Execution/EdgeMap.cs | 24 ++++-- .../Execution/IExternalRequestSink.cs | 11 +++ .../Execution/IRunnerContext.cs | 4 +- .../{InputEdgeRuner.cs => InputEdgeRunner.cs} | 13 +++- .../Execution/LocalRunner.cs | 18 ++++- .../Execution/LocalRunnerContext.cs | 20 ++++- .../Execution/StreamingExecutionHandle.cs | 10 +-- .../Microsoft.Agents.Workflow/ExecutorIsh.cs | 42 ++++++++--- .../Microsoft.Agents.Workflows.csproj | 8 +- .../OutputCollectorExecutor.cs | 2 +- .../Specialized/RequestInputExecutor.cs | 46 ++++++++++++ .../WorkflowBuilderExtensions.cs | 21 ++++++ 17 files changed, 295 insertions(+), 38 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/IExternalRequestSink.cs rename dotnet/src/Microsoft.Agents.Workflow/Execution/{InputEdgeRuner.cs => InputEdgeRunner.cs} (67%) rename dotnet/src/Microsoft.Agents.Workflow/{ => Specialized}/OutputCollectorExecutor.cs (94%) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs index 6ce2a05702..0a06cf1bb0 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs @@ -19,6 +19,11 @@ public record WorkflowStartedEvent : WorkflowEvent; /// public record WorkflowCompletedEvent : WorkflowEvent; +/// +/// . +/// +public record RequestInputEvent(ExternalRequest request) : WorkflowEvent; + /// /// . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs new file mode 100644 index 0000000000..c7ab3b2d5e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +/// +/// +/// +public record ExternalRequest(InputPort Port, string RequestId, object Data) +{ + /// + /// . + /// + /// + /// + /// + /// + /// + public static ExternalRequest Create(InputPort port, [NotNull] object data, string? requestId = null) + { + if (!port.Request.IsAssignableFrom(Throw.IfNull(data).GetType())) + { + throw new InvalidOperationException( + $"Message type {data.GetType().Name} is not assignable to the request type {port.Request.Name} of input port {port.Id}."); + } + + requestId ??= Guid.NewGuid().ToString("N"); + + return new ExternalRequest(port, requestId, data); + } + + /// + /// . + /// + /// + /// + /// + /// + /// + public static ExternalRequest Create(InputPort port, T data, string? requestId = null) => Create(port, (object)Throw.IfNull(data), requestId); + + /// + /// . + /// + /// + /// + /// + public ExternalResponse CreateResponse(object data) + { + if (!Throw.IfNull(this.Port).Response.IsAssignableFrom(Throw.IfNull(data).GetType())) + { + throw new InvalidOperationException( + $"Message type {data.GetType().Name} is not assignable to the response type {this.Port.Response.Name} of input port {this.Port.Id}."); + } + + return new ExternalResponse(this.Port, this.RequestId, data); + } + + /// + /// . + /// + /// + /// + /// + /// + public ExternalResponse CreateResponse(InputPort port, T data) => this.CreateResponse(port, (object)Throw.IfNull(data)); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs new file mode 100644 index 0000000000..2c6bd22782 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + + +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +/// +/// +/// +public record ExternalResponse(InputPort Port, string RequestId, object Data) +{ +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs new file mode 100644 index 0000000000..cfa4e27f8d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +/// +/// +/// +public record InputPort(string Id, Type Request, Type Response) +{ }; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs index c49afff737..b12ff25113 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using Microsoft.Agents.Workflows.Specialized; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; @@ -21,6 +22,11 @@ public class Workflow /// public Dictionary> Edges { get; internal init; } = new(); + /// + /// . + /// + public Dictionary Ports { get; } = new(); + /// /// . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs index 373b443ec2..f9ba08af00 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs @@ -12,9 +12,13 @@ internal class EdgeMap { private readonly Dictionary _edgeRunners = new(); private readonly Dictionary _fanInState = new(); - private readonly InputEdgeRuner _inputRunner; + private readonly Dictionary _portEdgeRunners; + private readonly InputEdgeRunner _inputRunner; - public EdgeMap(IRunnerContext runContext, Dictionary> workflowEdges, string startExecutorId) + public EdgeMap(IRunnerContext runContext, + Dictionary> workflowEdges, + IEnumerable workflowPorts, + string startExecutorId) { foreach (Edge edge in workflowEdges.Values.SelectMany(e => e)) { @@ -29,7 +33,12 @@ public EdgeMap(IRunnerContext runContext, Dictionary> work this._edgeRunners[edge] = edgeRunner; } - this._inputRunner = new InputEdgeRuner(runContext, startExecutorId); + this._portEdgeRunners = workflowPorts.ToDictionary( + port => port.Id, + port => InputEdgeRunner.ForPort(runContext, port) + ); + + this._inputRunner = new InputEdgeRunner(runContext, startExecutorId); } public async ValueTask> InvokeEdgeAsync(Edge edge, string sourceId, object message) @@ -84,8 +93,13 @@ public EdgeMap(IRunnerContext runContext, Dictionary> work return [await this._inputRunner.ChaseAsync(inputMessage).ConfigureAwait(false)]; } - public ValueTask> InvokeResponseAsync(object externalResponse) + public async ValueTask> InvokeResponseAsync(ExternalResponse response) { - throw new NotImplementedException(); + if (!this._portEdgeRunners.TryGetValue(response.Port.Id, out InputEdgeRunner? portRunner)) + { + throw new InvalidOperationException($"Port {response.Port.Id} not found in the edge map."); + } + + return [await portRunner.ChaseAsync(response.Data).ConfigureAwait(false)]; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IExternalRequestSink.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/IExternalRequestSink.cs new file mode 100644 index 0000000000..76301b2785 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/IExternalRequestSink.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal interface IExternalRequestSink +{ + ValueTask PostAsync(ExternalRequest request); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs index 78770036a9..692abdf3af 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs @@ -5,9 +5,9 @@ namespace Microsoft.Agents.Workflows.Execution; -internal interface IRunnerContext +internal interface IRunnerContext : IExternalRequestSink { - ValueTask AddEventAsync(string executorId, WorkflowEvent workflowEvent); + ValueTask AddEventAsync(WorkflowEvent workflowEvent); ValueTask SendMessageAsync(string executorId, object message); // TODO: State Management diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs similarity index 67% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs rename to dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs index 24c5622b80..b0c45ba0f4 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs @@ -2,14 +2,23 @@ using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Execution; -internal class InputEdgeRuner(IRunnerContext runContext, string sinkId) +internal class InputEdgeRunner(IRunnerContext runContext, string sinkId) : EdgeRunner(runContext, sinkId) { public IWorkflowContext WorkflowContext { get; } = runContext.Bind(sinkId); + public static InputEdgeRunner ForPort(IRunnerContext runContext, InputPort port) + { + Throw.IfNull(port); + + // The port is an input port, so we can use the port's ID as the sink ID. + return new InputEdgeRunner(runContext, port.Id); + } + private async ValueTask FindRouterAsync() { Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData) @@ -24,7 +33,7 @@ private async ValueTask FindRouterAsync() if (router.CanHandle(message)) { return await router.RouteMessageAsync(message, this.WorkflowContext) - .ConfigureAwait(false); + .ConfigureAwait(false); } // TODO: Throw instead? diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index e5bc824348..ed37852ccb 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -27,7 +27,7 @@ public LocalRunner(Workflow workflow) // 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.StartExecutorId); + this.EdgeMap = new EdgeMap(this.RunContext, this.Workflow.Edges, this.Workflow.Ports.Values, this.Workflow.StartExecutorId); } ValueTask ISuperStepRunner.EnqueueMessageAsync(object message) @@ -56,7 +56,7 @@ private void RaiseWorkflowEvent(WorkflowEvent workflowEvent) private bool IsResponse(object message) { - return false; + return message is ExternalResponse; } private ValueTask> RouteExternalMessageAsync(object message) @@ -65,11 +65,21 @@ private bool IsResponse(object message) bool isHil = false; #pragma warning restore CS0219 // Variable is assigned but its value is never used - return this.IsResponse(message) - ? this.EdgeMap.InvokeResponseAsync(message) + return message is ExternalResponse response + ? this.CompleteExternalResponseAsync(response) : this.EdgeMap.InvokeInputAsync(message); } + private ValueTask> CompleteExternalResponseAsync(ExternalResponse response) + { + if (!this.RunContext.CompleteRequest(response.RequestId)) + { + throw new InvalidOperationException($"No pending request with ID {response.RequestId} found in the workflow context."); + } + + return this.EdgeMap.InvokeResponseAsync(response); + } + /// /// . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index 2a4edcbcf4..fb14752d2d 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Specialized; using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; @@ -16,6 +17,7 @@ internal class LocalRunnerContext : IRunnerContext private StepContext _nextStep = new(); private readonly Dictionary> _executorProviders; private readonly Dictionary _executors = new(); + private readonly Dictionary _externalRequests = new(); public LocalRunnerContext(Workflow workflow, ILogger? logger = null) { @@ -34,6 +36,11 @@ public async ValueTask EnsureExecutorAsync(string executorId) this._executors[executorId] = executor = provider(); await executor.InitializeAsync(this.Bind(executor.Id)).ConfigureAwait(false); + + if (executor is RequestInputExecutor requestInputExecutor) + { + requestInputExecutor.AttachRequestSink(this); + } } return executor; @@ -48,13 +55,14 @@ public ValueTask AddExternalMessageAsync([NotNull] object message) } public bool NextStepHasActions => this._nextStep.HasMessages; + public bool HasUnservicedRequests => this._externalRequests.Count > 0; public StepContext Advance() { return Interlocked.Exchange(ref this._nextStep, new StepContext()); } - public ValueTask AddEventAsync(string executorId, WorkflowEvent workflowEvent) + public ValueTask AddEventAsync(WorkflowEvent workflowEvent) { this.QueuedEvents.Add(workflowEvent); return default; @@ -71,11 +79,19 @@ public IWorkflowContext Bind(string executorId) return new BoundContext(this, executorId); } + public ValueTask PostAsync(ExternalRequest request) + { + this._externalRequests.Add(request.RequestId, request); + return this.AddEventAsync(new RequestInputEvent(request)); + } + + public bool CompleteRequest(string requestId) => this._externalRequests.Remove(requestId); + public readonly List QueuedEvents = new(); private class BoundContext(LocalRunnerContext RunnerContext, string ExecutorId) : IWorkflowContext { - public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => RunnerContext.AddEventAsync(ExecutorId, workflowEvent); + public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => RunnerContext.AddEventAsync(workflowEvent); public ValueTask SendMessageAsync(object message) => RunnerContext.SendMessageAsync(ExecutorId, message); } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs index 048b6fa5ad..eba2f960a1 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs @@ -28,7 +28,7 @@ internal StreamingExecutionHandle(ISuperStepRunner stepRunner) /// /// /// - public ValueTask SendResponseAsync(object response) + public ValueTask SendResponseAsync(ExternalResponse response) { return this._stepRunner.EnqueueMessageAsync(response); } @@ -119,20 +119,20 @@ public static class ExecutionHandleExtensions /// name="handle"/> and invokes the for each event. If the callback returns a /// non- response, the response is sent back to the workflow using the handle. /// The representing the workflow execution stream to monitor. - /// An optional callback function invoked for each received from the stream. The - /// callback can return a response object to be sent back to the workflow, or if no response + /// An optional callback function invoked for each received from the stream. + /// The /// callback can return a response object to be sent back to the workflow, or if no response /// is required. /// A to observe while waiting for events. Defaults to . /// A that represents the asynchronous operation. The task completes when the workflow /// execution stream is fully processed. - public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) + public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) { Throw.IfNull(handle); await foreach (WorkflowEvent @event in handle.WatchStreamAsync(cancellation).ConfigureAwait(false)) { - object? maybeResponse = eventCallback?.Invoke(@event); + ExternalResponse? maybeResponse = eventCallback?.Invoke(@event); if (maybeResponse != null) { await handle.SendResponseAsync(maybeResponse).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs index 6200dc5d2e..30367ac709 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -2,6 +2,7 @@ using System; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Specialized; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; @@ -28,6 +29,10 @@ public enum Type /// . /// Executor, + /// + /// . + /// + InputPort, //Function, //Agent, //ProcessStep @@ -40,8 +45,19 @@ public enum Type private readonly string? _idValue; private readonly Executor? _executorValue; + private readonly InputPort? _inputPortValue; //private readonly Func? _functionValue; + /// + /// . + /// + /// + public ExecutorIsh(string id) + { + this.ExecutorType = Type.Unbound; + this._idValue = Throw.IfNull(id); + } + /// /// . /// @@ -55,11 +71,11 @@ public ExecutorIsh(Executor executor) /// /// . /// - /// - public ExecutorIsh(string id) + /// + public ExecutorIsh(InputPort port) { - this.ExecutorType = Type.Unbound; - this._idValue = Throw.IfNull(id); + this.ExecutorType = Type.InputPort; + this._inputPortValue = Throw.IfNull(port); } internal bool IsUnbound => this.ExecutorType == Type.Unbound; @@ -69,6 +85,7 @@ public ExecutorIsh(string id) { Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), Type.Executor => this._executorValue!.Id, + Type.InputPort => this._inputPortValue!.Id, //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), @@ -82,6 +99,7 @@ public ExecutorIsh(string id) { Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), Type.Executor => () => this._executorValue!, + Type.InputPort => () => new RequestInputExecutor(this._inputPortValue!), //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), @@ -98,10 +116,13 @@ public ExecutorIsh(string id) /// . /// /// - public static implicit operator ExecutorIsh(Executor executor) - { - return new ExecutorIsh(executor); - } + public static implicit operator ExecutorIsh(Executor executor) => new(executor); + + /// + /// . + /// + /// + public static implicit operator ExecutorIsh(InputPort inputPort) => new(inputPort); // How do we AoT compile this? //public static implicit operator ExecutorIsh(Func function) @@ -175,11 +196,12 @@ public override string ToString() return this.ExecutorType switch { Type.Unbound => $"'{this.Id}':", - Type.Executor => $"'{this.Id}':{this._executorValue!.GetType()}", + Type.Executor => $"'{this.Id}':{this._executorValue!.GetType().Name}", + Type.InputPort => $"'{this.Id}':Input({this._inputPortValue!.Request.Name}->{this._inputPortValue!.Response.Name})", //Type.Function => $"ExecutorIsh for Function with ID '{this.Id}'", //Type.Agent => $"ExecutorIsh for Agent with ID '{this.Id}'", //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", - _ => $"'{this.Id}': $"'{this.Id}':" }; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj index 478396f484..65f8a0dfab 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj @@ -20,14 +20,8 @@ - - - - - - + - diff --git a/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs similarity index 94% rename from dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs index 3380293cfa..f3658fb479 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs @@ -4,7 +4,7 @@ using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.Workflows; +namespace Microsoft.Agents.Workflows.Specialized; internal class OutputSink : Executor { diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs new file mode 100644 index 0000000000..be460d2955 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Specialized; + +internal class RequestInputExecutor : Executor, IMessageHandler, IMessageHandler +{ + private InputPort Port { get; } + private IExternalRequestSink? RequestSink { get; set; } + + public RequestInputExecutor(InputPort port) : base(port.Id) + { + this.Port = port; + } + + internal void AttachRequestSink(IExternalRequestSink requestSink) + { + this.RequestSink = Throw.IfNull(requestSink); + } + + public ValueTask HandleAsync(object message, IWorkflowContext context) + { + Throw.IfNull(message); + + return this.RequestSink!.PostAsync(ExternalRequest.Create(this.Port, message)); + } + + public ValueTask HandleAsync(ExternalResponse message, IWorkflowContext context) + { + Throw.IfNull(message); + Throw.IfNull(message.Data); + + if (!this.Port.Response.IsAssignableFrom(message.Data.GetType())) + { + throw new InvalidOperationException( + $"Message type {message.Data.GetType().Name} is not assignable to the response type {this.Port.Response.Name} of input port {this.Port.Id}."); + } + + return context.SendMessageAsync(message.Data); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index aa8fe8c8ce..7643d05078 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Specialized; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; @@ -45,6 +46,26 @@ public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh return builder; } + /// + /// . + /// + /// + /// + /// + /// + /// + /// + public static WorkflowBuilder AddExternalCall(this WorkflowBuilder builder, ExecutorIsh source, string portId) + { + Throw.IfNull(builder); + Throw.IfNull(source); + Throw.IfNull(portId); + + InputPort port = new(portId, typeof(TRequest), typeof(TResponse)); + return builder.AddEdge(source, port) + .AddEdge(port, source); + } + /// /// . /// From 8bc5a9f6028d824f45953e833654b24d5bb88309 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 18:59:24 -0400 Subject: [PATCH 074/232] feat: Support hosting AIAgent instances in Workflows --- .../Microsoft.Agents.Workflow/ExecutorIsh.cs | 44 ++++++++++--------- .../Specialized/AIAgentHostExecutor.cs | 31 +++++++++++++ 2 files changed, 55 insertions(+), 20 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Specialized/AIAgentHostExecutor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs index 30367ac709..28384a2cf8 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -3,6 +3,7 @@ using System; using Microsoft.Agents.Workflows.Core; using Microsoft.Agents.Workflows.Specialized; +using Microsoft.Extensions.AI.Agents; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; @@ -33,8 +34,10 @@ public enum Type /// . /// InputPort, - //Function, - //Agent, + /// + /// . + /// + Agent, //ProcessStep } @@ -46,7 +49,7 @@ public enum Type private readonly string? _idValue; private readonly Executor? _executorValue; private readonly InputPort? _inputPortValue; - //private readonly Func? _functionValue; + private readonly AIAgent? _aiAgentValue; /// /// . @@ -78,6 +81,16 @@ public ExecutorIsh(InputPort port) this._inputPortValue = Throw.IfNull(port); } + /// + /// . + /// + /// + public ExecutorIsh(AIAgent aiAgent) + { + this.ExecutorType = Type.Agent; + this._aiAgentValue = Throw.IfNull(aiAgent); + } + internal bool IsUnbound => this.ExecutorType == Type.Unbound; /// @@ -86,8 +99,7 @@ public ExecutorIsh(InputPort port) Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), Type.Executor => this._executorValue!.Id, Type.InputPort => this._inputPortValue!.Id, - //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), - //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), + Type.Agent => this._aiAgentValue!.Id, //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") }; @@ -100,18 +112,11 @@ public ExecutorIsh(InputPort port) Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), Type.Executor => () => this._executorValue!, Type.InputPort => () => new RequestInputExecutor(this._inputPortValue!), - //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), - //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), + Type.Agent => () => new AIAgentHostExecutor(this._aiAgentValue!), //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") }; - //public ExecutorIsh(Func function) - //{ - // this.ExecutorType = Type.Function; - // this._functionValue = Throw.IfNull(function); - //} - /// /// . /// @@ -124,11 +129,11 @@ public ExecutorIsh(InputPort port) /// public static implicit operator ExecutorIsh(InputPort inputPort) => new(inputPort); - // How do we AoT compile this? - //public static implicit operator ExecutorIsh(Func function) - //{ - // return new ExecutorIsh(function); - //} + /// + /// . + /// + /// + public static implicit operator ExecutorIsh(AIAgent aiAgent) => new(aiAgent); /// /// . @@ -198,8 +203,7 @@ public override string ToString() Type.Unbound => $"'{this.Id}':", Type.Executor => $"'{this.Id}':{this._executorValue!.GetType().Name}", Type.InputPort => $"'{this.Id}':Input({this._inputPortValue!.Request.Name}->{this._inputPortValue!.Response.Name})", - //Type.Function => $"ExecutorIsh for Function with ID '{this.Id}'", - //Type.Agent => $"ExecutorIsh for Agent with ID '{this.Id}'", + Type.Agent => $"{this.Id}':AIAgent(@{this._aiAgentValue!.GetType().Name})", //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", _ => $"'{this.Id}':" }; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/Specialized/AIAgentHostExecutor.cs new file mode 100644 index 0000000000..2f156d9191 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Specialized/AIAgentHostExecutor.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; + +namespace Microsoft.Agents.Workflows.Specialized; + +internal class AIAgentHostExecutor : Executor, IMessageHandler> +{ + private AIAgent Agent { get; set; } + + public AIAgentHostExecutor(AIAgent agent) + { + this.Agent = agent; + } + + public async ValueTask HandleAsync(IList message, IWorkflowContext context) + { + IReadOnlyCollection messageList = (message as List ?? message.ToList()).AsReadOnly(); + + // TODO: Ideally we want to be able to split the Run across multiple super-steps so that we can stream out + // incremental updates from the chat model. + AgentRunResponse runResponse = await this.Agent.RunAsync(messageList).ConfigureAwait(false); + + await context.SendMessageAsync(runResponse).ConfigureAwait(false); + } +} From 38e9d0ce43a3490a12963e9e8e627293b8228c64 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 20:28:33 -0400 Subject: [PATCH 075/232] fix: Fix routing to go through Executor.ExecuteAsync --- .../Core/Executor.cs | 7 ++- .../Core/ExternalResponse.cs | 3 - .../Execution/DirectEdgeRunner.cs | 17 +++--- .../Execution/EdgeMap.cs | 8 +-- .../Execution/FanInEdgeRunner.cs | 13 ++--- .../Execution/FanOutEdgeRunner.cs | 13 ++--- .../Execution/InputEdgeRunner.cs | 15 ++--- .../Execution/LocalRunner.cs | 17 +++--- .../Execution/LocalRunnerContext.cs | 2 +- .../Execution/StreamingExecutionHandle.cs | 2 +- .../Sample/01_Simple_Workflow_Sequential.cs | 57 +++++++++++++++++++ .../Sample/02_Simple_Workflow_Sequential.cs | 43 -------------- .../SampleSmokeTest.cs | 30 ++++++++++ 13 files changed, 131 insertions(+), 96 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/01_Simple_Workflow_Sequential.cs delete mode 100644 dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 5e43a5c98b..e3f5af19d9 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -80,7 +80,12 @@ internal MessageRouter Router CallResult? result = await this.Router.RouteMessageAsync(message, context, requireRoute: true) .ConfigureAwait(false); - await context.AddEventAsync(new ExecutorCompleteEvent(this.Id)).ConfigureAwait(false); + ExecutorCompleteEvent completeEvent = new(this.Id) + { + Data = result == null ? null : result.IsSuccess ? result.Result : result.Exception + }; + + await context.AddEventAsync(completeEvent).ConfigureAwait(false); if (result == null) { diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs index 2c6bd22782..00dde3bcbd 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs @@ -1,8 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. - -// Copyright (c) Microsoft. All rights reserved. - namespace Microsoft.Agents.Workflows.Core; /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs index 8908a6e3e6..df70b2b620 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs @@ -11,26 +11,23 @@ internal class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData edgeDa { public IWorkflowContext WorkflowContext { get; } = runContext.Bind(edgeData.SinkId); - private async ValueTask FindRouterAsync() + private async ValueTask FindRouterAsync() { - Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) - .ConfigureAwait(false); - - return sink.Router; + return await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); } - public async ValueTask> ChaseAsync(object message) + public async ValueTask> ChaseAsync(object message) { if (this.EdgeData.Condition != null && !this.EdgeData.Condition(message)) { return []; } - MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); - if (router.CanHandle(message)) + Executor target = await this.FindRouterAsync().ConfigureAwait(false); + if (target.CanHandle(message.GetType())) { - return [await router.RouteMessageAsync(message, this.WorkflowContext) - .ConfigureAwait(false)]; + return [await target.ExecuteAsync(message, this.WorkflowContext).ConfigureAwait(false)]; } return []; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs index f9ba08af00..34e13c898d 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs @@ -41,14 +41,14 @@ public EdgeMap(IRunnerContext runContext, this._inputRunner = new InputEdgeRunner(runContext, startExecutorId); } - public async ValueTask> InvokeEdgeAsync(Edge edge, string sourceId, object message) + public async ValueTask> InvokeEdgeAsync(Edge edge, string sourceId, object message) { if (!this._edgeRunners.TryGetValue(edge, out object? edgeRunner)) { throw new InvalidOperationException($"Edge {edge} not found in the edge map."); } - IEnumerable edgeResults; + IEnumerable edgeResults; switch (edge.EdgeType) { // We know the corresponding EdgeRunner type given the FlowEdge EdgeType, as @@ -88,12 +88,12 @@ public EdgeMap(IRunnerContext runContext, } // TODO: Should we promote Input to a true "FlowEdge" type? - public async ValueTask> InvokeInputAsync(object inputMessage) + public async ValueTask> InvokeInputAsync(object inputMessage) { return [await this._inputRunner.ChaseAsync(inputMessage).ConfigureAwait(false)]; } - public async ValueTask> InvokeResponseAsync(ExternalResponse response) + public async ValueTask> InvokeResponseAsync(ExternalResponse response) { if (!this._portEdgeRunners.TryGetValue(response.Port.Id, out InputEdgeRunner? portRunner)) { diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs index 3d8db74eca..9b790bd0e6 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs @@ -13,7 +13,7 @@ internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData public FanInEdgeState CreateState() => new(this.EdgeData); - public async ValueTask ChaseAsync(string sourceId, object message, FanInEdgeState state) + public async ValueTask ChaseAsync(string sourceId, object message, FanInEdgeState state) { IEnumerable? releasedMessages = state.ProcessMessage(sourceId, message); if (releasedMessages is null) @@ -22,14 +22,13 @@ internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData return null; } - Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) - .ConfigureAwait(false); + Executor target = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); - MessageRouter router = sink.Router; - if (router.CanHandle(message)) + if (target.CanHandle(message.GetType())) { - return await router.RouteMessageAsync(message, this.BoundContext) - .ConfigureAwait(false); + return await target.ExecuteAsync(message, this.BoundContext) + .ConfigureAwait(false); } return null; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs index 59afcd1ced..7a21accf8b 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs @@ -15,26 +15,25 @@ internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeDa sinkId => sinkId, sinkId => runContext.Bind(sinkId)); - public async ValueTask> ChaseAsync(object message) + public async ValueTask> ChaseAsync(object message) { List targets = this.EdgeData.Partitioner == null ? this.EdgeData.SinkIds : this.EdgeData.Partitioner(message, this.BoundContexts.Count).Select(i => this.EdgeData.SinkIds[i]).ToList(); - CallResult?[] result = await Task.WhenAll(targets.Select(ProcessTargetAsync)).ConfigureAwait(false); + object?[] result = await Task.WhenAll(targets.Select(ProcessTargetAsync)).ConfigureAwait(false); return result.Where(r => r is not null); - async Task ProcessTargetAsync(string targetId) + async Task ProcessTargetAsync(string targetId) { Executor executor = await this.RunContext.EnsureExecutorAsync(targetId) .ConfigureAwait(false); - MessageRouter router = executor.Router; - if (router.CanHandle(message)) + if (executor.CanHandle(message.GetType())) { - return await router.RouteMessageAsync(message, this.BoundContexts[targetId]) - .ConfigureAwait(false); + return await executor.ExecuteAsync(message, this.BoundContexts[targetId]) + .ConfigureAwait(false); } return null; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs index b0c45ba0f4..bfe002b9bd 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs @@ -19,20 +19,17 @@ public static InputEdgeRunner ForPort(IRunnerContext runContext, InputPort port) return new InputEdgeRunner(runContext, port.Id); } - private async ValueTask FindRouterAsync() + private async ValueTask FindExecutorAsync() { - Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData) - .ConfigureAwait(false); - - return sink.Router; + return await this.RunContext.EnsureExecutorAsync(this.EdgeData).ConfigureAwait(false); } - public async ValueTask ChaseAsync(object message) + public async ValueTask ChaseAsync(object message) { - MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); - if (router.CanHandle(message)) + Executor target = await this.FindExecutorAsync().ConfigureAwait(false); + if (target.CanHandle(message.GetType())) { - return await router.RouteMessageAsync(message, this.WorkflowContext) + return await target.ExecuteAsync(message, this.WorkflowContext) .ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index ed37852ccb..0a089619e6 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -59,18 +59,14 @@ private bool IsResponse(object message) return message is ExternalResponse; } - private ValueTask> RouteExternalMessageAsync(object message) + private ValueTask> RouteExternalMessageAsync(object message) { -#pragma warning disable CS0219 // Variable is assigned but its value is never used - bool isHil = false; -#pragma warning restore CS0219 // Variable is assigned but its value is never used - return message is ExternalResponse response ? this.CompleteExternalResponseAsync(response) : this.EdgeMap.InvokeInputAsync(message); } - private ValueTask> CompleteExternalResponseAsync(ExternalResponse response) + private ValueTask> CompleteExternalResponseAsync(ExternalResponse response) { if (!this.RunContext.CompleteRequest(response.RequestId)) { @@ -119,7 +115,7 @@ async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cance private async ValueTask RunSuperstepAsync(StepContext currentStep) { // Deliver the messages and queue the next step - List>> edgeTasks = new(); + List>> edgeTasks = new(); foreach (ExecutorIdentity sender in currentStep.QueuedMessages.Keys) { IEnumerable senderMessages = currentStep.QueuedMessages[sender]; @@ -127,9 +123,8 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) { edgeTasks.AddRange(senderMessages.Select(message => this.RouteExternalMessageAsync(message).AsTask())); } - else + else if (this.Workflow.Edges.TryGetValue(sender.Id!, out HashSet? outgoingEdges)) { - HashSet outgoingEdges = this.Workflow.Edges[sender.Id!]; // Id is not null when Identity is not .None foreach (Edge outgoingEdge in outgoingEdges) { edgeTasks.AddRange(senderMessages.Select(message => this.EdgeMap.InvokeEdgeAsync(outgoingEdge, sender.Id, message).AsTask())); @@ -140,7 +135,7 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) // TODO: Should we let the user specify that they want strictly turn-based execution of the edges, vs. concurrent? // (Simply substitute a strategy that replaces Task.WhenAll with a loop with an await in the middle. Difficulty is // that we would need to avoid firing the tasks when we call InvokeEdgeAsync, or RouteExternalMessageAsync. - IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); + IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); // TODO: Commit the state updates (so they are visible to the next step) @@ -149,6 +144,8 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) { this.RaiseWorkflowEvent(@event); } + + this.RunContext.QueuedEvents.Clear(); } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index fb14752d2d..ad6fafe741 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -70,7 +70,7 @@ public ValueTask AddEventAsync(WorkflowEvent workflowEvent) public ValueTask SendMessageAsync(string executorId, object message) { - this._nextStep.MessagesFor(message.GetType().Name).Add(message); + this._nextStep.MessagesFor(executorId).Add(message); return default; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs index eba2f960a1..1fee2c7695 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs @@ -39,7 +39,7 @@ public ValueTask SendResponseAsync(ExternalResponse response) /// /// /// - public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancellation] CancellationToken cancellation) + public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancellation] CancellationToken cancellation = default) { List eventSink = new(); diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/01_Simple_Workflow_Sequential.cs new file mode 100644 index 0000000000..5af83b752a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/01_Simple_Workflow_Sequential.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.IO; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; + +namespace Microsoft.Agents.Workflows.Sample; + +internal static class Step1EntryPoint +{ + public static async ValueTask RunAsync(TextWriter writer) + { + UppercaseExecutor uppercase = new(); + ReverseTextExecutor reverse = new(); + + WorkflowBuilder builder = new(uppercase); + builder.AddEdge(uppercase, reverse); + + Workflow workflow = builder.Build(); + LocalRunner runner = new(workflow); + + var handle = await runner.StreamAsync("Hello, World!").ConfigureAwait(false); + + await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + { + if (evt is ExecutorCompleteEvent executorComplete) + { + writer.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}"); + } + } + } +} + +internal sealed class UppercaseExecutor() : Executor("UppercaseExecutor"), IMessageHandler +{ + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + string result = message.ToUpperInvariant(); + + await context.SendMessageAsync(result).ConfigureAwait(false); + return result; + } +} + +internal sealed class ReverseTextExecutor() : Executor("ReverseTextExecutor"), IMessageHandler +{ + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + char[] charArray = message.ToCharArray(); + System.Array.Reverse(charArray); + string result = new(charArray); + + await context.SendMessageAsync(result).ConfigureAwait(false); + return result; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs deleted file mode 100644 index c731a2c3f1..0000000000 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Core; -using Microsoft.Agents.Workflows.Execution; - -namespace Microsoft.Agents.Workflows.Sample; - -internal static class Step2EntryPoint -{ - public static async ValueTask RunAsync() - { - UppercaseExecutor uppercase = new(); - ReverseTextExecutor reverse = new(); - - WorkflowBuilder builder = new(uppercase); - builder.AddEdge(uppercase, reverse); - - Workflow workflow = builder.Build(); - LocalRunner runner = new(workflow); - - var handle = await runner.StreamAsync("Hello, World!").ConfigureAwait(false); - await handle.RunToCompletionAsync().ConfigureAwait(false); - } -} - -internal sealed class UppercaseExecutor : Executor, IMessageHandler -{ - public ValueTask HandleAsync(string message, IWorkflowContext context) - { - return new ValueTask(message.ToUpperInvariant()); - } -} - -internal sealed class ReverseTextExecutor : Executor, IMessageHandler -{ - public ValueTask HandleAsync(string message, IWorkflowContext context) - { - char[] charArray = message.ToCharArray(); - System.Array.Reverse(charArray); - return new ValueTask(new string(charArray)); - } -} diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs new file mode 100644 index 0000000000..7fdee9601c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Sample; + +namespace Microsoft.Agents.Workflow.UnitTests; + +public class SampleSmokeTest +{ + [Fact] + public async Task Test_RunSample_Step1Async() + { + using StringWriter writer = new(); + + await Step1EntryPoint.RunAsync(writer); + + string result = writer.ToString(); + string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); + + const string INPUT = "Hello, World!"; + + Assert.Collection(lines, + line => Assert.Contains($"UppercaseExecutor: {INPUT.ToUpperInvariant()}", line), + line => Assert.Contains($"ReverseTextExecutor: {new string(INPUT.ToUpperInvariant().Reverse().ToArray())}", line) + ); + } +} From 991ba4558281fd2f4a52b86b56dd551c3483ff5f Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 21:31:25 -0400 Subject: [PATCH 076/232] test: Update samples for "must SendMessage" semantics * Add invoking samples to unit tests to avoid future breaks --- ...ion.cs => 02_Simple_Workflow_Condition.cs} | 41 ++++++++++++++----- ...low_Loop.cs => 03_Simple_Workflow_Loop.cs} | 40 ++++++++++++++---- .../SampleSmokeTest.cs | 24 +++++++++++ 3 files changed, 87 insertions(+), 18 deletions(-) rename dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/{02a_Simple_Workflow_Condition.cs => 02_Simple_Workflow_Condition.cs} (63%) rename dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/{02b_Simple_Workflow_Loop.cs => 03_Simple_Workflow_Loop.cs} (61%) diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Condition.cs similarity index 63% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs rename to dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Condition.cs index 4040452540..c6f52d7166 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Condition.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.IO; using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; @@ -8,9 +9,9 @@ namespace Microsoft.Agents.Workflows.Sample; -internal static class Step2aEntryPoint +internal static class Step2EntryPoint { - public static async ValueTask RunAsync() + public static async ValueTask RunAsync(TextWriter writer, string input = "This is a spam message.") { string[] spamKeywords = { "spam", "advertisement", "offer" }; @@ -19,14 +20,29 @@ public static async ValueTask RunAsync() RemoveSpamExecutor removeSpam = new(); Workflow workflow = new WorkflowBuilder(detectSpam) - .AddEdge(detectSpam, respondToMessage, isSpam => isSpam is true) // If not spam, respond - .AddEdge(detectSpam, removeSpam, isSpam => isSpam is false) // If spam, remove + .AddEdge(detectSpam, respondToMessage, isSpam => isSpam is false) // If not spam, respond + .AddEdge(detectSpam, removeSpam, isSpam => isSpam is true) // If spam, remove .Build(); LocalRunner runner = new(workflow); - StreamingExecutionHandle handle = await runner.StreamAsync("This is a spam message.").ConfigureAwait(false); - await handle.RunToCompletionAsync().ConfigureAwait(false); + StreamingExecutionHandle handle = await runner.StreamAsync(input).ConfigureAwait(false); + await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + { + switch (evt) + { + case WorkflowCompletedEvent workflowCompleteEvt: + // The workflow has completed successfully, return the result + string workflowResult = workflowCompleteEvt.Data!.ToString()!; + writer.WriteLine($"Result: {workflowResult}"); + return workflowResult; + case ExecutorCompleteEvent executorCompleteEvt: + writer.WriteLine($"'{executorCompleteEvt.ExecutorId}: {executorCompleteEvt.Data}"); + break; + } + } + + throw new InvalidOperationException("Workflow failed to yield the completion event."); } } @@ -39,7 +55,7 @@ public DetectSpamExecutor(params string[] spamKeywords) this.SpamKeywords = spamKeywords; } - public ValueTask HandleAsync(string message, IWorkflowContext context) + public async ValueTask HandleAsync(string message, IWorkflowContext context) { #if NET5_0_OR_GREATER bool isSpam = this.SpamKeywords.Any(keyword => message.Contains(keyword, StringComparison.OrdinalIgnoreCase)); @@ -47,12 +63,15 @@ public ValueTask HandleAsync(string message, IWorkflowContext context) bool isSpam = this.SpamKeywords.Any(keyword => message.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0); #endif - return new ValueTask(isSpam); + await context.SendMessageAsync(isSpam).ConfigureAwait(false); + return isSpam; } } internal sealed class RespondToMessageExecutor : Executor, IMessageHandler { + public const string ActionResult = "Message processed successfully."; + public async ValueTask HandleAsync(bool message, IWorkflowContext context) { if (message) @@ -63,13 +82,15 @@ public async ValueTask HandleAsync(bool message, IWorkflowContext context) await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay - await context.AddEventAsync(new WorkflowCompletedEvent { Data = "Message processed successfully." }) + await context.AddEventAsync(new WorkflowCompletedEvent { Data = RespondToMessageExecutor.ActionResult }) .ConfigureAwait(false); } } internal sealed class RemoveSpamExecutor : Executor, IMessageHandler { + public const string ActionResult = "Spam message removed."; + public async ValueTask HandleAsync(bool message, IWorkflowContext context) { if (!message) @@ -80,7 +101,7 @@ public async ValueTask HandleAsync(bool message, IWorkflowContext context) await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay - await context.AddEventAsync(new WorkflowCompletedEvent { Data = "Spam message removed." }) + await context.AddEventAsync(new WorkflowCompletedEvent { Data = RemoveSpamExecutor.ActionResult }) .ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/03_Simple_Workflow_Loop.cs similarity index 61% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs rename to dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/03_Simple_Workflow_Loop.cs index df69c47167..ead24833e4 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/03_Simple_Workflow_Loop.cs @@ -1,14 +1,16 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.IO; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; using Microsoft.Agents.Workflows.Execution; namespace Microsoft.Agents.Workflows.Sample; -internal static class Step2bEntryPoint +internal static class Step3EntryPoint { - public static async ValueTask RunAsync() + public static async ValueTask RunAsync(TextWriter writer) { GuessNumberExecutor guessNumber = new(1, 100); JudgeExecutor judge = new(42); // Let's say the target number is 42 @@ -20,7 +22,23 @@ public static async ValueTask RunAsync() LocalRunner runner = new(workflow); StreamingExecutionHandle handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); - await handle.RunToCompletionAsync(); + + await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + { + switch (evt) + { + case WorkflowCompletedEvent workflowCompleteEvt: + // The workflow has completed successfully, return the result + string workflowResult = workflowCompleteEvt.Data!.ToString()!; + writer.WriteLine($"Result: {workflowResult}"); + return workflowResult; + case ExecutorCompleteEvent executorCompleteEvt: + writer.WriteLine($"'{executorCompleteEvt.ExecutorId}: {executorCompleteEvt.Data}"); + break; + } + } + + throw new InvalidOperationException("Workflow failed to yield the completion event."); } } @@ -63,7 +81,9 @@ await context.AddEventAsync(new WorkflowCompletedEvent { Data = $"Guessed the nu break; } - return this._currGuess = this.NextGuess; + this._currGuess = this.NextGuess; + await context.SendMessageAsync(this._currGuess).ConfigureAwait(false); + return this._currGuess; } } @@ -76,19 +96,23 @@ public JudgeExecutor(int targetNumber) this._targetNumber = targetNumber; } - public ValueTask HandleAsync(int message, IWorkflowContext context) + public async ValueTask HandleAsync(int message, IWorkflowContext context) { + NumberSignal result; if (message == this._targetNumber) { - return new ValueTask(NumberSignal.Matched); + result = NumberSignal.Matched; } else if (message < this._targetNumber) { - return new ValueTask(NumberSignal.Below); + result = NumberSignal.Below; } else { - return new ValueTask(NumberSignal.Above); + result = NumberSignal.Above; } + + await context.SendMessageAsync(result).ConfigureAwait(false); + return result; } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs index 7fdee9601c..1acf4aa1d8 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs @@ -27,4 +27,28 @@ public async Task Test_RunSample_Step1Async() line => Assert.Contains($"ReverseTextExecutor: {new string(INPUT.ToUpperInvariant().Reverse().ToArray())}", line) ); } + + [Fact] + public async Task Test_RunSample_Step2Async() + { + using StringWriter writer = new(); + + string spamResult = await Step2EntryPoint.RunAsync(writer); + + Assert.Equal(RemoveSpamExecutor.ActionResult, spamResult); + + string nonSpamResult = await Step2EntryPoint.RunAsync(writer, "This is a valid message."); + + Assert.Equal(RespondToMessageExecutor.ActionResult, nonSpamResult); + } + + [Fact] + public async Task Test_RunSample_Step3Async() + { + using StringWriter writer = new(); + + string guessResult = await Step3EntryPoint.RunAsync(writer); + + Assert.Equal("Guessed the number: 42", guessResult); + } } From 7ece023a9cbd99e3c4cb0ca3ee105724df55727d Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 12:02:13 -0400 Subject: [PATCH 077/232] fix: ExternalRequest should block Workflow completion --- .../Microsoft.Agents.Workflow/Core/Events.cs | 2 +- .../Core/Executor.cs | 2 +- .../Core/ExternalRequest.cs | 3 +- .../Core/RouteBuilder.cs | 40 ++++++++++ .../Core/Workflow.cs | 3 +- .../Execution/EdgeMap.cs | 2 +- .../Execution/ISuperStepRunner.cs | 3 + .../Execution/LocalRunner.cs | 22 +++--- .../Execution/StreamingExecutionHandle.cs | 25 +++++- .../Microsoft.Agents.Workflow/ExecutorIsh.cs | 2 +- .../Specialized/OutputCollectorExecutor.cs | 15 +++- .../Specialized/RequestInputExecutor.cs | 25 ++++-- .../StreamingAggregators.cs | 9 ++- .../WorkflowBuilder.cs | 10 ++- .../WorkflowBuilderExtensions.cs | 22 ++---- .../05_Simple_Workflow_ExternalRequest.cs | 76 +++++++++++++++++++ .../SampleSmokeTest.cs | 38 ++++++++++ 17 files changed, 250 insertions(+), 49 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs index 0a06cf1bb0..63013e03a3 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs @@ -22,7 +22,7 @@ public record WorkflowCompletedEvent : WorkflowEvent; /// /// . /// -public record RequestInputEvent(ExternalRequest request) : WorkflowEvent; +public record RequestInputEvent(ExternalRequest Request) : WorkflowEvent; /// /// . diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index e3f5af19d9..4312f2a7cf 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -78,7 +78,7 @@ internal MessageRouter Router await context.AddEventAsync(new ExecutorInvokeEvent(this.Id)).ConfigureAwait(false); CallResult? result = await this.Router.RouteMessageAsync(message, context, requireRoute: true) - .ConfigureAwait(false); + .ConfigureAwait(false); ExecutorCompleteEvent completeEvent = new(this.Id) { diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs index c7ab3b2d5e..e06cdfaf02 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs @@ -66,8 +66,7 @@ public ExternalResponse CreateResponse(object data) /// . /// /// - /// /// /// - public ExternalResponse CreateResponse(InputPort port, T data) => this.CreateResponse(port, (object)Throw.IfNull(data)); + public ExternalResponse CreateResponse(T data) => this.CreateResponse((object)Throw.IfNull(data)); } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs index e480aeb97d..83eb64020a 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs @@ -44,6 +44,46 @@ internal RouteBuilder AddHandler(Type messageType, MessageHandlerF handler, bool return this; } + /// + /// . + /// + /// + /// + /// + /// + internal RouteBuilder AddHandler(Type type, Func handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddHandler(type, WrappedHandlerAsync, overwrite); + + async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) + { + await handler.Invoke(msg, ctx).ConfigureAwait(false); + return CallResult.ReturnVoid(); + } + } + + /// + /// . + /// + /// + /// + /// + /// + internal RouteBuilder AddHandler(Type type, Func> handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddHandler(type, WrappedHandlerAsync, overwrite); + + async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) + { + TResult result = await handler.Invoke(msg, ctx).ConfigureAwait(false); + return CallResult.ReturnResult(result); + } + } + /// /// . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs index b12ff25113..d26ee4f12f 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs @@ -25,7 +25,7 @@ public class Workflow /// /// . /// - public Dictionary Ports { get; } = new(); + public Dictionary Ports { get; internal init; } = new(); /// /// . @@ -68,6 +68,7 @@ internal Workflow Promote(OutputSink outputSource) { ExecutorProviders = this.ExecutorProviders, Edges = this.Edges, + Ports = this.Ports }; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs index 34e13c898d..80c3daab19 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs @@ -100,6 +100,6 @@ public EdgeMap(IRunnerContext runContext, throw new InvalidOperationException($"Port {response.Port.Id} not found in the edge map."); } - return [await portRunner.ChaseAsync(response.Data).ConfigureAwait(false)]; + return [await portRunner.ChaseAsync(response).ConfigureAwait(false)]; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs index f2c6b5f929..073d8d398c 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs @@ -9,6 +9,9 @@ namespace Microsoft.Agents.Workflows.Execution; internal interface ISuperStepRunner { + bool HasUnservicedRequests { get; } + bool HasUnprocessedMessages { get; } + ValueTask EnqueueMessageAsync(object message); event EventHandler? WorkflowEvent; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index 0a089619e6..54158616dc 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -89,23 +89,19 @@ public async ValueTask StreamAsync(TInput input, Cance return new StreamingExecutionHandle(this); } - private StepContext? _currentStep = null; + bool ISuperStepRunner.HasUnservicedRequests => this.RunContext.HasUnservicedRequests; + bool ISuperStepRunner.HasUnprocessedMessages => this.RunContext.NextStepHasActions; + + //private StepContext? _currentStep = null; async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cancellation) { cancellation.ThrowIfCancellationRequested(); - if (this._currentStep == null) - { - // TODO: Python-side does not raise this event. - // await this.RunContext.AddEventAsync(this.Workflow.StartExecutorId, new WorkflowStartedEvent()).ConfigureAwait(false); - this._currentStep = this.RunContext.Advance(); - } + StepContext currentStep = this.RunContext.Advance(); - if (this._currentStep.HasMessages) + if (currentStep.HasMessages) { - await this.RunSuperstepAsync(this._currentStep).ConfigureAwait(false); - this._currentStep = this.RunContext.Advance(); - + await this.RunSuperstepAsync(currentStep).ConfigureAwait(false); return true; } @@ -175,11 +171,11 @@ public LocalRunner(Workflow workflow) /// /// /// - public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) + public async ValueTask> StreamAsync(TInput input, CancellationToken cancellation = default) { await this._innerRunner.EnqueueMessageAsync(input).ConfigureAwait(false); - return new StreamingExecutionHandle(this._innerRunner); + return new StreamingExecutionHandle(this); } /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs index 1fee2c7695..47d5b24ecd 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs @@ -15,6 +15,7 @@ namespace Microsoft.Agents.Workflows.Execution; /// public class StreamingExecutionHandle { + private TaskCompletionSource? _waitForResponseSource = null; private readonly ISuperStepRunner _stepRunner; internal StreamingExecutionHandle(ISuperStepRunner stepRunner) @@ -30,6 +31,8 @@ internal StreamingExecutionHandle(ISuperStepRunner stepRunner) /// public ValueTask SendResponseAsync(ExternalResponse response) { + this._waitForResponseSource?.TrySetResult(new()); + return this._stepRunner.EnqueueMessageAsync(response); } @@ -47,8 +50,11 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell try { - while (await this._stepRunner.RunSuperStepAsync(cancellation).ConfigureAwait(false)) + do { + // Drain SuperSteps while there are steps to run + await this._stepRunner.RunSuperStepAsync(cancellation).ConfigureAwait(false); + bool hadCompletionEvent = false; List outputEvents = Interlocked.Exchange(ref eventSink, new()); foreach (WorkflowEvent raisedEvent in outputEvents) @@ -67,7 +73,22 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell // If we had a completion event, we are done. yield break; } - } + + // If we do not have any actions to take on the Workflow, but have unprocessed + // requests, wait for the responses to come in before exiting out of the workflow + // execution. + if (!this._stepRunner.HasUnprocessedMessages && + this._stepRunner.HasUnservicedRequests) + { + if (this._waitForResponseSource == null) + { + this._waitForResponseSource = new(); + } + + await this._waitForResponseSource.Task.ConfigureAwait(false); + this._waitForResponseSource = null; + } + } while (this._stepRunner.HasUnprocessedMessages); } finally { diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs index 28384a2cf8..1a1bfb193e 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -48,7 +48,7 @@ public enum Type private readonly string? _idValue; private readonly Executor? _executorValue; - private readonly InputPort? _inputPortValue; + internal readonly InputPort? _inputPortValue; private readonly AIAgent? _aiAgentValue; /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs index f3658fb479..a1a6099572 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; @@ -17,14 +18,24 @@ internal OutputSink(string? id = null) : base(id) internal class OutputCollectorExecutor : OutputSink, IMessageHandler { private readonly StreamingAggregator _aggregator; - public OutputCollectorExecutor(StreamingAggregator aggregator, string? id = null) : base(id) + private readonly Func? _completionCondition; + + public OutputCollectorExecutor(StreamingAggregator aggregator, Func? completionCondition = null, string? id = null) : base(id) { this._aggregator = Throw.IfNull(aggregator); + this._completionCondition = completionCondition; } public ValueTask HandleAsync(TInput message, IWorkflowContext context) { - this.Result = this._aggregator(message); + this.Result = this._aggregator(message, this.Result); + + if (this._completionCondition is not null && + this._completionCondition!(message, this.Result)) + { + return context.AddEventAsync(new WorkflowCompletedEvent() { Data = this.Result }); + } + return default; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs index be460d2955..9cd3a8e2c9 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.Workflows.Specialized; -internal class RequestInputExecutor : Executor, IMessageHandler, IMessageHandler +internal class RequestInputExecutor : Executor, IMessageHandler, IMessageHandler { private InputPort Port { get; } private IExternalRequestSink? RequestSink { get; set; } @@ -18,19 +18,32 @@ public RequestInputExecutor(InputPort port) : base(port.Id) this.Port = port; } + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + return routeBuilder + // Handle incoming requests (as raw request payloads) + .AddHandler(this.Port.Request, this.HandleAsync) + .AddHandler(typeof(object), this.HandleAsync) + // Handle incoming responses (as wrapped Response object) + .AddHandler(this.HandleAsync); + } + internal void AttachRequestSink(IExternalRequestSink requestSink) { this.RequestSink = Throw.IfNull(requestSink); } - public ValueTask HandleAsync(object message, IWorkflowContext context) + public async ValueTask HandleAsync(object message, IWorkflowContext context) { Throw.IfNull(message); - return this.RequestSink!.PostAsync(ExternalRequest.Create(this.Port, message)); + ExternalRequest request = ExternalRequest.Create(this.Port, message); + await this.RequestSink!.PostAsync(request).ConfigureAwait(false); + + return request; } - public ValueTask HandleAsync(ExternalResponse message, IWorkflowContext context) + public async ValueTask HandleAsync(ExternalResponse message, IWorkflowContext context) { Throw.IfNull(message); Throw.IfNull(message.Data); @@ -41,6 +54,8 @@ public ValueTask HandleAsync(ExternalResponse message, IWorkflowContext context) $"Message type {message.Data.GetType().Name} is not assignable to the response type {this.Port.Response.Name} of input port {this.Port.Id}."); } - return context.SendMessageAsync(message.Data); + await context.SendMessageAsync(message.Data).ConfigureAwait(false); + + return message; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs b/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs index 942eb81ab6..b90048a43a 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs @@ -11,8 +11,9 @@ namespace Microsoft.Agents.Workflows; /// /// /// +/// /// -public delegate TResult? StreamingAggregator(TInput input); +public delegate TResult? StreamingAggregator(TInput input, TResult? runningResult); /// /// . @@ -34,7 +35,7 @@ public static StreamingAggregator First(Func Last(Func> Union Aggregate(TInput input) + IEnumerable Aggregate(TInput input, IEnumerable? runningResult) { results.Add(conversion(input)); return results; diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index d92bef1c8a..cb96b72c78 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -37,6 +37,7 @@ private record struct EdgeId(string SourceId, string TargetId) private readonly Dictionary> _edges = new(); private readonly HashSet _unboundExecutors = new(); private readonly HashSet _conditionlessEdges = new(); + private readonly Dictionary _inputPorts = new(); private readonly string _startExecutorId; @@ -67,6 +68,12 @@ private ExecutorIsh Track(ExecutorIsh executorish) this._executors[executorish.Id] = provider; } + if (executorish.ExecutorType == ExecutorIsh.Type.InputPort) + { + InputPort port = executorish._inputPortValue!; + this._inputPorts[port.Id] = port; + } + return executorish; } @@ -217,7 +224,8 @@ public Workflow Build() return new Workflow(this._startExecutorId) // Why does it not see the default ctor? { ExecutorProviders = this._executors, - Edges = this._edges + Edges = this._edges, + Ports = this._inputPorts }; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index 7643d05078..69894710fa 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -74,26 +74,18 @@ public static WorkflowBuilder AddExternalCall(this Workflow /// /// /// + /// /// - public static Workflow BuildWithOutput(this WorkflowBuilder builder, ExecutorIsh outputSource, StreamingAggregator aggregator) - => builder.BuildWithOutput(outputSource, aggregator); - - /// - /// . - /// - /// - /// - /// - /// - /// - /// - /// - public static Workflow BuildWithOutput(this WorkflowBuilder builder, ExecutorIsh outputSource, StreamingAggregator aggregator) + public static Workflow BuildWithOutput( + this WorkflowBuilder builder, + ExecutorIsh outputSource, + StreamingAggregator aggregator, + Func? completionCondition = null) { Throw.IfNull(outputSource); Throw.IfNull(aggregator); - OutputCollectorExecutor outputSink = new(aggregator); + OutputCollectorExecutor outputSink = new(aggregator, completionCondition); // TODO: Check taht the outputSource has a TResult output? builder.AddEdge(outputSource, outputSink); diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs new file mode 100644 index 0000000000..ca2de73632 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; +using Microsoft.Agents.Workflows.Sample; + +namespace Microsoft.Agents.Workflow.UnitTests.Sample; + +internal static class Step5EntryPoint +{ + public static async ValueTask RunAsync(TextWriter writer, Func userGuessCallback) + { + InputPort guessNumber = new("GuessNumber", typeof(NumberSignal), typeof(int)); + JudgeExecutor judge = new(42); // Let's say the target number is 42 + + Workflow workflow = new WorkflowBuilder(guessNumber) + .AddEdge(guessNumber, judge) + .AddEdge(judge, guessNumber, (message) => message is NumberSignal signal && signal != NumberSignal.Matched) + .BuildWithOutput(judge, ComputeStreamingOutput, (NumberSignal s, string? _) => s == NumberSignal.Matched); + + LocalRunner runner = new(workflow); + StreamingExecutionHandle handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); + + await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + { + switch (evt) + { + case RequestInputEvent requestInputEvt: + ExternalResponse response = ExecuteExternalRequest(requestInputEvt.Request, userGuessCallback, workflow.RunningOutput); + await handle.SendResponseAsync(response).ConfigureAwait(false); + break; + + case WorkflowCompletedEvent workflowCompleteEvt: + // The workflow has completed successfully, return the result + string workflowResult = workflowCompleteEvt.Data!.ToString()!; + writer.WriteLine($"Result: {workflowResult}"); + return workflowResult; + case ExecutorCompleteEvent executorCompleteEvt: + writer.WriteLine($"'{executorCompleteEvt.ExecutorId}: {executorCompleteEvt.Data}"); + break; + } + } + + throw new InvalidOperationException("Workflow failed to yield the completion event."); + } + + private static ExternalResponse ExecuteExternalRequest( + ExternalRequest request, + Func userGuessCallback, + string? runningState) + { + object result = request.Port.Id switch + { + "GuessNumber" => userGuessCallback(runningState ?? "Guess the number."), + _ => throw new NotSupportedException($"Request {request.Port.Id} is not supported") + }; + + return request.CreateResponse(result); + } + + private static string ComputeStreamingOutput(NumberSignal signal, string? runningResult) + { + return signal switch + { + NumberSignal.Matched => "You guessed correctly! You Win!", + NumberSignal.Above => "Your guess was too high. Try again.", + NumberSignal.Below => "Your guess was too low. Try again.", + + _ => runningResult ?? string.Empty + }; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs index 1acf4aa1d8..7ec53c5756 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using Microsoft.Agents.Workflow.UnitTests.Sample; using Microsoft.Agents.Workflows.Sample; namespace Microsoft.Agents.Workflow.UnitTests; @@ -51,4 +52,41 @@ public async Task Test_RunSample_Step3Async() Assert.Equal("Guessed the number: 42", guessResult); } + + [Fact] + public async Task Test_RunSample_Step5Async() + { + using StringWriter writer = new(); + + VerifyingPlaybackResponder responder = new( + ("Guess the number.", 50), + ("Your guess was too high. Try again.", 23), + ("Your guess was too low. Try again.", 42)); + + string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext); + Assert.Equal("You guessed correctly! You Win!", guessResult); + } +} + +internal sealed class VerifyingPlaybackResponder +{ + public (TInput input, TResponse response)[] Responses { get; } + private int _position = 0; + + public VerifyingPlaybackResponder(params (TInput input, TResponse response)[] responses) + { + this.Responses = responses; + } + + public int Remaining => Math.Max(0, this.Responses.Length - this._position); + + public TResponse InvokeNext(TInput input) + { + Assert.True(this.Remaining > 0); + + (TInput expectedInput, TResponse expectedResponse) = this.Responses[this._position++]; + Assert.Equal(expectedInput, input); + + return expectedResponse; + } } From 0f3b2ace59dea1f4db32b37aa151ac725e9f6238 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 13:54:53 -0400 Subject: [PATCH 078/232] feat: Normalize API surface against Python * Also adds xmldoc to all public APIs --- .../Microsoft.Agents.Workflow/Core/Edge.cs | 64 +++++---- .../Microsoft.Agents.Workflow/Core/Events.cs | 32 +++-- .../Core/Executor.cs | 125 +++--------------- .../Core/ExecutorCapabilities.cs | 69 ---------- .../Core/ExternalRequest.cs | 48 +++---- .../Core/ExternalResponse.cs | 8 +- .../Core/InputPort.cs | 14 +- .../Core/RouteBuilder.cs | 52 ++++---- .../Core/Workflow.cs | 40 +++--- .../Execution/IRunnerWithOutput.cs | 10 ++ .../Execution/IRunnerWithResult.cs | 13 -- .../Execution/LocalRunner.cs | 74 ++++++----- .../Execution/LocalRunnerContext.cs | 2 - .../Execution/StreamingExecutionHandle.cs | 66 +++++---- .../Microsoft.Agents.Workflow/ExecutorIsh.cs | 52 ++++---- .../StreamingAggregators.cs | 91 ++++++++----- .../WorkflowBuilder.cs | 75 +++++++---- .../WorkflowBuilderExtensions.cs | 60 ++++++--- 18 files changed, 418 insertions(+), 477 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithOutput.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs index de2dc0ed44..0769d0012e 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs @@ -10,32 +10,34 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// Represents a directed edge between two nodes, optionally associated with a condition that determines whether the +/// edge is active. /// -/// -/// -/// +/// The id of the source executor node. +/// The id of the target executor node. +/// A predicate determining whether the edge is active for a given message. public record DirectEdgeData( string SourceId, string SinkId, PredicateT? Condition = null) { /// - /// . + /// Converts a instance to an using an implicit conversion. /// - /// + /// The to convert to an . Cannot be null. public static implicit operator Edge(DirectEdgeData data) { - return new Edge(data); + return new Edge(Throw.IfNull(data)); } } /// -/// . +/// Represents a connection from a single node to a set of nodes, optionally associated with a paritition selector +/// function which maps incoming messages to a subset of the target set. /// -/// -/// -/// +/// The id of the source executor node. +/// A list of ids of the target executor nodes. +/// A function that maps an incoming message to a subset of the target executor nodes. public record FanOutEdgeData( string SourceId, List SinkIds, @@ -52,26 +54,29 @@ public static implicit operator Edge(FanOutEdgeData data) } /// -/// . +/// Specifies the condition under which a fan-in operation is triggered in a workflow. +/// Use to trigger the operation when all incoming edges have data, or +/// to trigger when any incoming edge has data. /// public enum FanInTrigger { /// - /// . + /// Trigger when all incoming edges have data. /// WhenAll, /// - /// . + /// Trigger when any incoming edge has data. /// WhenAny } /// -/// . +/// Represents a connection from a set of nodes to a single node. It can trigger either when all edges have data +/// or when any of them have data. /// -/// -/// -/// +/// An enumeration of ids of the source executor nodes. +/// The id of the target executor node. +/// The that determines when the fan-in edge is activated. public record FanInEdgeData( IEnumerable SourceIds, string SinkId, @@ -90,37 +95,46 @@ public static implicit operator Edge(FanInEdgeData data) } /// -/// . +/// Represents a connection or relationship between nodes, characterized by its type and associated data. /// +/// +/// An can be of type , , or , as specified by the property. The property holds +/// additional information relevant to the edge, and its concrete type depends on the value of , functioning as a tagged union. +/// public class Edge { /// - /// . + /// Specified the edge type. /// public enum Type { /// - /// . + /// A direct connection from one node to another. /// Direct, /// - /// . + /// A connection from one node to a set of nodes. /// FanOut, /// - /// . + /// A connection from a set of nodes to a single node. /// FanIn } /// - /// . + /// Specifies the type of the edge, which determines how the edge is processed in the workflow. /// public Type EdgeType { get; init; } /// - /// . + /// The -dependent edge data. /// + /// + /// + /// public object Data { get; init; } internal Edge(DirectEdgeData data) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs index 63013e03a3..1bbf5150a5 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs @@ -5,27 +5,31 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// Base class for -scoped events. /// public record WorkflowEvent(object? Data = null); /// -/// . +/// Event triggered when a workflow starts execution. /// public record WorkflowStartedEvent : WorkflowEvent; /// -/// . +/// Event triggered when a workflow completes execution. /// +/// +/// The user is expected to raise this event from a terminating , or to build +/// the workflow with output capture using . +/// public record WorkflowCompletedEvent : WorkflowEvent; /// -/// . +/// Event triggered when a workflow executor request external information. /// public record RequestInputEvent(ExternalRequest Request) : WorkflowEvent; /// -/// . +/// Base class for -scoped events. /// public record ExecutorEvent : WorkflowEvent { @@ -35,8 +39,11 @@ public record ExecutorEvent : WorkflowEvent public string ExecutorId { get; } /// - /// . + /// Initializes a new instance of the class with the specified executor identifier and + /// optional event data. /// + /// The unique identifier of the executor associated with this event. Cannot be null. + /// Optional event data to associate with the event. May be null if no additional data is required. public ExecutorEvent(string executorId, object? data = null) : base(data) { this.ExecutorId = Throw.IfNull(executorId); @@ -44,12 +51,12 @@ public ExecutorEvent(string executorId, object? data = null) : base(data) } /// -/// . +/// Event triggered when an executor handler is invoked. /// public record ExecutorInvokeEvent : ExecutorEvent { /// - /// . + /// Initializes a new instance of the class. /// public ExecutorInvokeEvent(string executorId, object? data = null) : base(executorId, data) { @@ -57,14 +64,17 @@ public ExecutorInvokeEvent(string executorId, object? data = null) : base(execut } /// -/// . +/// Event triggered when an executor handler has completed. /// public record ExecutorCompleteEvent : ExecutorEvent { /// - /// . + /// Initializes a new instance of the class to signal that an executor has + /// completed its operation. /// - public ExecutorCompleteEvent(string executorId, object? data = null) : base(executorId, data) { } + /// The unique identifier of the executor that has completed. Cannot be null or empty. + /// The result produced by the executor upon completion, or null if no result is available. + public ExecutorCompleteEvent(string executorId, object? result = null) : base(executorId, result) { } } // TODO: This is a placeholder for streaming chat message content. diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 4312f2a7cf..8081bb9af3 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -2,49 +2,39 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Diagnostics; using System.Reflection; using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// A component that processes messages in a . /// -[DebuggerDisplay("{GetType().Name}{Id}({Name})")] +[DebuggerDisplay("{GetType().Name}{Id}")] public abstract class Executor : IIdentified, IAsyncDisposable { /// - /// . + /// A unique identifier for the executor. /// public string Id { get; } - /// - /// . - /// - public string Name { get; } - private Dictionary State { get; } = new(); /// - /// . + /// Initialize the executor with a unique identifier /// - /// - /// - protected Executor(string? id = null, string? name = null) + /// A optional unique identifier for the executor. If null, a type-tagged + /// UUID will be generated. + protected Executor(string? id = null) { - this.Name = name ?? this.GetType().Name; - this.Id = id ?? $"{this.Name}{Guid.NewGuid():N}"; + this.Id = id ?? $"{this.GetType().Name}/{Guid.NewGuid():N}"; } /// /// Override this method to register handlers for the executor. The deafult implementation uses reflection to /// look for implementations of and . /// - /// - /// protected virtual RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) { return routeBuilder.ReflectHandlers(this); @@ -66,13 +56,13 @@ internal MessageRouter Router } /// - /// . + /// Process an incoming message using the registered handlers. /// - /// - /// - /// - /// - /// + /// The message to be processed by the executor. + /// The workflow context in which the executor executes. + /// A ValueTask representing the asynchronous operation, wrapping the output from the executor. + /// No handler found for the message type. + /// An exception is generated while handling the message. public async ValueTask ExecuteAsync(object message, IWorkflowContext context) { await context.AddEventAsync(new ExecutorInvokeEvent(this.Id)).ConfigureAwait(false); @@ -124,104 +114,29 @@ protected void CheckInitialized() } /// - /// . + /// A set of s, representing the messages this executor can handle. /// public ISet InputTypes => this.Router.IncomingTypes; /// - /// . + /// A set of s, representing the messages this executor can produce as output. /// - public virtual ISet OutputTypes => new HashSet(); + public virtual ISet OutputTypes => new HashSet([typeof(object)]); /// - /// . + /// Checks if the executor can handle a specific message type. /// /// /// public bool CanHandle(Type messageType) => this.Router.CanHandle(messageType); - /// - /// . - /// - /// - /// - public async ValueTask InitializeAsync(IWorkflowContext context) - { - if (this._initialized) - { - return; - } - - await this.InitializeOverride(context).ConfigureAwait(false); - - this._initialized = true; - } - - /// - /// . - /// - public ExecutorCapabilities Capabilities - => new() - { - Id = this.Id, - Name = this.Name, - ExecutorType = this.GetType(), - HandledMessageTypes = new HashSet(this.InputTypes), - IsInitialized = this._initialized, - StateKeys = new HashSet(this.State.Keys) - }; - - /// - /// . - /// - /// - public ReadOnlyDictionary CurrentState => new(this.State); - - /// - /// . - /// - /// - /// - public void RestoreState(IDictionary state) - { - Throw.IfNull(state); - - this.State.Clear(); - - foreach (KeyValuePair kvp in state) - { - this.State[kvp.Key] = kvp.Value; - } - } - - /// - /// . - /// - /// - protected virtual ValueTask PrepareForCheckpointAsync() => default; - - /// - /// . - /// - /// - protected virtual ValueTask AfterCheckpointRestoreAsync() => default; - - /// - /// . - /// - /// - /// - protected virtual ValueTask InitializeOverride(IWorkflowContext context) => default; - - /// - /// . - /// - /// + /// protected virtual async ValueTask DisposeAsync() { this._initialized = false; } + /// ValueTask IAsyncDisposable.DisposeAsync() { GC.SuppressFinalize(this); // Should we be suppressing the finalizer here? CodeAnalysis seems to want it (CA1816) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs deleted file mode 100644 index 7f6ab5aebd..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// . -/// -public record ExecutorCapabilities -{ - /// - /// . - /// - public string Id { get; init; } - /// - /// . - /// - public string Name { get; init; } - /// - /// . - /// - public Type ExecutorType { get; init; } - /// - /// . - /// - public ISet HandledMessageTypes { get; init; } - /// - /// . - /// - public bool IsInitialized { get; init; } - /// - /// . - /// - public ISet StateKeys { get; init; } - - /// - /// . - /// - public ExecutorCapabilities() - { - this.Id = string.Empty; - this.Name = string.Empty; - this.ExecutorType = typeof(Executor); - this.HandledMessageTypes = new HashSet(); - this.IsInitialized = false; - this.StateKeys = new HashSet(); - } - - /// - /// . - /// - /// - /// - /// - /// - /// - /// - public ExecutorCapabilities(string id, string name, Type executorType, ISet handledMessageTypes, bool isInitialized, ISet stateKeys) - { - this.Id = id; - this.Name = name; - this.ExecutorType = executorType; - this.HandledMessageTypes = handledMessageTypes; - this.IsInitialized = isInitialized; - this.StateKeys = stateKeys; - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs index e06cdfaf02..0dcc3008a8 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs @@ -7,21 +7,21 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// Represents a request to an external input port. /// -/// -/// -/// +/// The port to invoke. +/// A unique identifier for this request instance. +/// The data contained in the request. public record ExternalRequest(InputPort Port, string RequestId, object Data) { /// - /// . + /// Creates a new for the specified input port and data payload. /// - /// - /// - /// - /// - /// + /// The port to invoke. + /// The data contained in the request. + /// An optional unique identifier for this request instance. If null, a UUID will be generated. + /// An instance containing the specified port, data, and request identifier. + /// Thrown when the input data object does not match the expected request type. public static ExternalRequest Create(InputPort port, [NotNull] object data, string? requestId = null) { if (!port.Request.IsAssignableFrom(Throw.IfNull(data).GetType())) @@ -36,21 +36,21 @@ public static ExternalRequest Create(InputPort port, [NotNull] object data, stri } /// - /// . + /// Creates a new for the specified input port and data payload. /// - /// - /// - /// - /// - /// + /// The type of request data. + /// The input port that identifies the target endpoint for the request. Must not be null. + /// The data payload to include in the request. Must not be null. + /// An optional identifier for the request. If null, a default identifier may be assigned. + /// An instance containing the specified port, data, and request identifier. public static ExternalRequest Create(InputPort port, T data, string? requestId = null) => Create(port, (object)Throw.IfNull(data), requestId); /// - /// . + /// Creates a new corresponding to the request, with the speicified data payload. /// - /// - /// - /// + /// The data contained in the response. + /// An instance corresponding to this request with the specified data. + /// Thrown when the input data object does not match the expected response type. public ExternalResponse CreateResponse(object data) { if (!Throw.IfNull(this.Port).Response.IsAssignableFrom(Throw.IfNull(data).GetType())) @@ -63,10 +63,10 @@ public ExternalResponse CreateResponse(object data) } /// - /// . + /// Creates a new corresponding to the request, with the speicified data payload. /// - /// - /// - /// + /// The type of the response data. + /// The data contained in the response. + /// An instance corresponding to this request with the specified data. public ExternalResponse CreateResponse(T data) => this.CreateResponse((object)Throw.IfNull(data)); } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs index 00dde3bcbd..58ed3a1be9 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs @@ -3,11 +3,11 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// Represents a request from an external input port. /// -/// -/// -/// +/// The port invoked. +/// The unique identifier of the corresponding request. +/// The data contained in the response. public record ExternalResponse(InputPort Port, string RequestId, object Data) { } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs index cfa4e27f8d..bf49afb78f 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs @@ -5,10 +5,20 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// An external request port for a with the specified request and response types. /// /// /// /// public record InputPort(string Id, Type Request, Type Response) -{ }; +{ + /// + /// Creates a new instance configured for the specified request and response types. + /// + /// The type of the request messages that the input port will accept. + /// The type of the response messages that the input port will produce. + /// The unique identifier for the input port. + /// An instance associated with the specified , configured to handle + /// requests of type and responses of type . + public static InputPort Create(string id) => new(id, typeof(TRequest), typeof(TResponse)); +}; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs index 83eb64020a..467a374bfa 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs @@ -15,8 +15,13 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// Provides a builder for configuring message type handlers for an . /// +/// +/// Override the method to customize the routing of messages to handlers. By +/// default, uses reflection to find implementations of and +/// . +/// public class RouteBuilder { private readonly Dictionary _typedHandlers = new(); @@ -44,13 +49,6 @@ internal RouteBuilder AddHandler(Type messageType, MessageHandlerF handler, bool return this; } - /// - /// . - /// - /// - /// - /// - /// internal RouteBuilder AddHandler(Type type, Func handler, bool overwrite = false) { Throw.IfNull(handler); @@ -64,13 +62,6 @@ async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx } } - /// - /// . - /// - /// - /// - /// - /// internal RouteBuilder AddHandler(Type type, Func> handler, bool overwrite = false) { Throw.IfNull(handler); @@ -85,12 +76,18 @@ async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx } /// - /// . + /// Registers a handler for messages of the specified input type in the workflow route. /// + /// If a handler for the specified input type already exists and is + /// , the existing handler will not be replaced. Handlers are invoked asynchronously and are + /// expected to complete their processing before the workflow continues. /// - /// - /// - /// + /// A delegate that processes messages of type within the workflow context. The + /// delegate is invoked for each incoming message of the specified type. + /// to replace any existing handler for the specified input type; otherwise, to preserve the existing handler. + /// The current instance, enabling fluent configuration of additional handlers or route + /// options. public RouteBuilder AddHandler(Func handler, bool overwrite = false) { Throw.IfNull(handler); @@ -105,13 +102,18 @@ async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx } /// - /// . + /// Registers a handler function for messages of the specified input type in the workflow route. /// - /// - /// - /// - /// - /// + /// If a handler for the given input type already exists, setting to + /// will replace the existing handler; otherwise, an exception may be thrown. The handler + /// receives the input message and workflow context, and returns a result asynchronously. + /// The type of input message the handler will process. + /// The type of result produced by the handler. + /// A function that processes messages of type within the workflow context and returns + /// a representing the asynchronous result. + /// to replace any existing handler for the input type; otherwise, to + /// preserve existing handlers. + /// The current instance, enabling fluent configuration of workflow routes. public RouteBuilder AddHandler(Func> handler, bool overwrite = false) { Throw.IfNull(handler); diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs index d26ee4f12f..0376119559 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs @@ -8,54 +8,61 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// A class that represents a workflow that can be executed. /// public class Workflow { /// - /// . + /// A dictionary of executor providers, keyed by executor ID. /// public Dictionary> ExecutorProviders { get; internal init; } = new(); /// - /// . + /// Gets the collection of edges grouped by their source node identifier. /// public Dictionary> Edges { get; internal init; } = new(); /// - /// . + /// Gets the collection of external request ports, keyed by their ID. /// + /// + /// Each port has a corresponding entry in the dictionary. + /// public Dictionary Ports { get; internal init; } = new(); /// - /// . + /// Gets the identifier of the starting executor of the workflow. /// public string StartExecutorId { get; } /// - /// . + /// Gets the type of input expected by the starting executor of the workflow. /// public Type InputType { get; } + /// + /// Initializes a new instance of the class with the specified starting executor identifier + /// and input type. + /// + /// The unique identifier of the starting executor for the workflow. Cannot be null. + /// The representing the input data for the workflow. Cannot be null. internal Workflow(string startExecutorId, Type type) { this.StartExecutorId = Throw.IfNull(startExecutorId); this.InputType = Throw.IfNull(type); - - // TODO: How do we (1) ensure the types are happy, and (2) work under AOT? } } /// -/// . +/// Represents a workflow that operates on data of type . /// -/// +/// The type of input to the workflow. public class Workflow : Workflow { /// - /// . + /// Initializes a new instance of the class with the specified starting executor identifier /// - /// + /// The unique identifier of the starting executor for the workflow. Cannot be null. public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) { } @@ -74,10 +81,11 @@ internal Workflow Promote(OutputSink outputSource) } /// -/// . +/// Represents a workflow that operates on data of type , resulting in +/// . /// -/// -/// +/// The type of input to the workflow. +/// The type of the output from the workflow. public class Workflow : Workflow { private readonly OutputSink _output; @@ -89,7 +97,7 @@ internal Workflow(string startExecutorId, OutputSink outputSource) } /// - /// . + /// The running (partial) output of the workflow, if any. /// public TResult? RunningOutput => this._output.Result; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithOutput.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithOutput.cs new file mode 100644 index 0000000000..032fe9e944 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithOutput.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows.Execution; + +internal interface IRunnerWithOutput +{ + ISuperStepRunner StepRunner { get; } + + TResult? RunningOutput { get; } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs deleted file mode 100644 index 0d8a8ff422..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Execution; - -internal interface IRunnerWithResult -{ - ISuperStepRunner StepRunner { get; } - - ValueTask GetResultAsync(CancellationToken cancellation = default); -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index 54158616dc..729b95bedb 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -11,15 +11,22 @@ namespace Microsoft.Agents.Workflows.Execution; /// -/// . +/// Provides a local, in-process runner for executing a workflow using the specified input type. /// -/// +/// enables step-by-step execution of a workflow graph entirely +/// within the current process, without distributed coordination. It is primarily intended for testing, debugging, or +/// scenarios where workflow execution does not require executor distribution. +/// The type of input accepted by the workflow. Must be non-nullable. public class LocalRunner : ISuperStepRunner where TInput : notnull { /// - /// . + /// Initializes a new instance of the class to execute the specified workflow + /// locally. /// - /// + /// The manages the execution context and edge mapping for the + /// provided workflow, enabling local, in-process execution. The workflow's structure, including its edges and + /// ports, is used to set up the runner's internal state. + /// The workflow to be executed. Must not be null. public LocalRunner(Workflow workflow) { this.Workflow = Throw.IfNull(workflow); @@ -77,11 +84,15 @@ private bool IsResponse(object message) } /// - /// . + /// Initiates an asynchronous streaming execution using the specified input. /// - /// - /// - /// + /// The returned provides methods to observe and control + /// the ongoing streaming execution. The operation will continue until the streaming execution is finished or + /// cancelled. + /// The input message to be processed as part of the streaming execution. + /// A that can be used to cancel the streaming operation. + /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming execution. public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) { await this.RunContext.AddExternalMessageAsync(input).ConfigureAwait(false); @@ -146,19 +157,25 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) } /// -/// . +/// Provides a local, in-process runner for executing a workflow with input and producing a result. /// -/// -/// -public class LocalRunner : IRunnerWithResult where TInput : notnull +/// manages the execution of a instance locally, allowing for streaming input and asynchronous result retrieval. +/// This class is intended for scenarios where workflow execution does not require distributed procesing. +/// It supports streaming execution and exposes methods to retrieve the final result asynchronously. +/// +/// The type of input accepted by the workflow. Must be non-nullable. +/// The type of output produced by the workflow. +public class LocalRunner : IRunnerWithOutput where TInput : notnull { private readonly Workflow _workflow; private readonly ISuperStepRunner _innerRunner; /// - /// . + /// Initializes a new instance of the class to execute the specified + /// workflow locally. /// - /// + /// The workflow to be executed. Must not be null. public LocalRunner(Workflow workflow) { this._workflow = Throw.IfNull(workflow); @@ -166,11 +183,15 @@ public LocalRunner(Workflow workflow) } /// - /// . + /// Initiates an asynchronous streaming execution for the specified input. /// - /// - /// - /// + /// The returned can be used to retrieve results + /// as they become available. If the operation is cancelled via the token, the + /// streaming execution will be terminated. + /// The input value to be processed by the streaming execution. + /// A that can be used to cancel the streaming operation. + /// A that provides access to the results of the streaming + /// execution. public async ValueTask> StreamAsync(TInput input, CancellationToken cancellation = default) { await this._innerRunner.EnqueueMessageAsync(input).ConfigureAwait(false); @@ -178,21 +199,8 @@ public async ValueTask> StreamAsync(TInput inp return new StreamingExecutionHandle(this); } - /// - /// . - /// - /// - /// - public ValueTask GetResultAsync(CancellationToken cancellation = default) - { - // TODO: Block on finishing consuming StreamAsync()? - return new ValueTask(this.RunningOutput!); - } - - /// - /// . - /// + /// public TResult? RunningOutput => this._workflow.RunningOutput; - ISuperStepRunner IRunnerWithResult.StepRunner => this._innerRunner; + ISuperStepRunner IRunnerWithOutput.StepRunner => this._innerRunner; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index ad6fafe741..71ccc6d1d1 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -35,8 +35,6 @@ public async ValueTask EnsureExecutorAsync(string executorId) this._executors[executorId] = executor = provider(); - await executor.InitializeAsync(this.Bind(executor.Id)).ConfigureAwait(false); - if (executor is RequestInputExecutor requestInputExecutor) { requestInputExecutor.AttachRequestSink(this); diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs index 47d5b24ecd..6a93989a85 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs @@ -11,7 +11,8 @@ namespace Microsoft.Agents.Workflows.Execution; /// -/// . +/// Provides a handle for managing and interacting with a streaming workflow execution, enabling asynchronous response +/// delivery and event monitoring. /// public class StreamingExecutionHandle { @@ -24,11 +25,13 @@ internal StreamingExecutionHandle(ISuperStepRunner stepRunner) } /// - /// . + /// Asynchronously sends the specified response to the external system and signals completion of the current + /// response wait operation. /// - /// - /// - /// + /// The response will be queued for processing for the next superstep. + /// The to send. Must not be null. + /// A that represents the asynchronous send operation. The task completes when the response + /// has been enqueued for processing, but will not wait for processing to complete. public ValueTask SendResponseAsync(ExternalResponse response) { this._waitForResponseSource?.TrySetResult(new()); @@ -37,11 +40,15 @@ public ValueTask SendResponseAsync(ExternalResponse response) } /// - /// . + /// Asynchronously streams workflow events as they occur during workflow execution. /// - /// - /// - /// + /// This method yields instances in real time as the workflow + /// progresses. The stream completes when a is encountered. Events are + /// delivered in the order they are raised. + /// A that can be used to cancel the streaming operation. If cancellation is + /// requested, the stream will end and no further events will be yielded. + /// An asynchronous stream of objects representing significant workflow state changes. + /// The stream ends when the workflow completes or when cancellation is requested. public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancellation] CancellationToken cancellation = default) { List eventSink = new(); @@ -103,33 +110,25 @@ void OnWorkflowEvent(object? sender, WorkflowEvent e) } /// -/// . +/// Represents a handle for managing and retrieving the result of a streaming execution operation. /// /// public class StreamingExecutionHandle : StreamingExecutionHandle { - private readonly IRunnerWithResult _resultSource; + private readonly IRunnerWithOutput _resultSource; - internal StreamingExecutionHandle(IRunnerWithResult runner) + internal StreamingExecutionHandle(IRunnerWithOutput runner) : base(Throw.IfNull(runner.StepRunner)) { this._resultSource = runner; } - /// - /// . - /// - /// - /// - /// - public ValueTask GetResultAsync(CancellationToken cancellation = default) - { - return this._resultSource.GetResultAsync(cancellation); - } + /// + public TResult? RunningOutput => this._resultSource.RunningOutput; } /// -/// . +/// Provides extension methods for processing and executing workflows using streaming execution handles. /// public static class ExecutionHandleExtensions { @@ -141,10 +140,9 @@ public static class ExecutionHandleExtensions /// non- response, the response is sent back to the workflow using the handle. /// The representing the workflow execution stream to monitor. /// An optional callback function invoked for each received from the stream. - /// The /// callback can return a response object to be sent back to the workflow, or if no response + /// The callback can return a response object to be sent back to the workflow, or if no response /// is required. - /// A to observe while waiting for events. Defaults to . + /// A to observe while waiting for events. /// A that represents the asynchronous operation. The task completes when the workflow /// execution stream is fully processed. public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) @@ -169,20 +167,18 @@ public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle /// is provided, it will be invoked for each event emitted during the workflow's /// execution, allowing for custom event handling. /// The type of the result produced by the workflow. - /// The representing the workflow to execute. This parameter cannot - /// be . - /// An optional callback function that is invoked for each emitted during the workflow - /// execution. The callback can process the event and return an object, or if no processing - /// is required. - /// A that can be used to cancel the workflow execution. The default value is . - /// A that represents the asynchronous operation. The task's result is the final + /// The representing the workflow to execute. + /// An optional callback function that is invoked for each + /// emitted during execution. The callback can process the event and return an object, or + /// if no response is required. + /// A that can be used to cancel the workflow execution. + /// A that represents the asynchronous operation. The task's result is the final /// result of the workflow execution. public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) { Throw.IfNull(handle); await handle.RunToCompletionAsync(eventCallback, cancellation).ConfigureAwait(false); - return await handle.GetResultAsync(cancellation).ConfigureAwait(false); + return handle.RunningOutput!; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs index 1a1bfb193e..428fec3491 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -9,7 +9,8 @@ namespace Microsoft.Agents.Workflows; /// -/// . +/// A tagged union representing an object that can function like an in a , +/// or a reference to one by ID. /// public sealed class ExecutorIsh : IIdentified, @@ -18,31 +19,30 @@ public sealed class ExecutorIsh : IEquatable { /// - /// . + /// The type of the . /// public enum Type { /// - /// . + /// An unbound executor reference, identified only by ID. /// Unbound, /// - /// . + /// An actual instance. /// Executor, /// - /// . + /// An for servicing external requests. /// InputPort, /// - /// . + /// An instance. /// Agent, - //ProcessStep } /// - /// . + /// Gets the type of data contained in this instance. /// public Type ExecutorType { get; init; } @@ -52,9 +52,9 @@ public enum Type private readonly AIAgent? _aiAgentValue; /// - /// . + /// Initializes a new instance of the class as an unbound reference by ID. /// - /// + /// A unique identifier for an in the public ExecutorIsh(string id) { this.ExecutorType = Type.Unbound; @@ -62,9 +62,9 @@ public ExecutorIsh(string id) } /// - /// . + /// Initializes a new instance of the ExecutorIsh class using the specified executor. /// - /// + /// The executor instance to be wrapped. public ExecutorIsh(Executor executor) { this.ExecutorType = Type.Executor; @@ -72,9 +72,9 @@ public ExecutorIsh(Executor executor) } /// - /// . + /// Initializes a new instance of the ExecutorIsh class using the specified input port. /// - /// + /// The input port to associate to be wrapped. public ExecutorIsh(InputPort port) { this.ExecutorType = Type.InputPort; @@ -82,7 +82,7 @@ public ExecutorIsh(InputPort port) } /// - /// . + /// Initializes a new instance of the ExecutorIsh class using the specified AI agent. /// /// public ExecutorIsh(AIAgent aiAgent) @@ -100,12 +100,12 @@ public ExecutorIsh(AIAgent aiAgent) Type.Executor => this._executorValue!.Id, Type.InputPort => this._inputPortValue!.Id, Type.Agent => this._aiAgentValue!.Id, - //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") }; /// - /// . + /// Gets an that can be used to obtain an instance + /// corresponding to this . /// public ExecutorProvider ExecutorProvider => this.ExecutorType switch { @@ -113,32 +113,31 @@ public ExecutorIsh(AIAgent aiAgent) Type.Executor => () => this._executorValue!, Type.InputPort => () => new RequestInputExecutor(this._inputPortValue!), Type.Agent => () => new AIAgentHostExecutor(this._aiAgentValue!), - //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") }; /// - /// . + /// Defines an implicit conversion from an instance to an object. /// - /// + /// The instance to convert to . public static implicit operator ExecutorIsh(Executor executor) => new(executor); /// - /// . + /// Defines an implicit conversion from an to an instance. /// - /// + /// The to convert to an . public static implicit operator ExecutorIsh(InputPort inputPort) => new(inputPort); /// - /// . + /// Defines an implicit conversion from an to an instance. /// - /// + /// The to convert to an . public static implicit operator ExecutorIsh(AIAgent aiAgent) => new(aiAgent); /// - /// . + /// Defines an implicit conversion from a string to an instance. /// - /// + /// The string ID to convert to an . public static implicit operator ExecutorIsh(string id) { return new ExecutorIsh(id); @@ -204,7 +203,6 @@ public override string ToString() Type.Executor => $"'{this.Id}':{this._executorValue!.GetType().Name}", Type.InputPort => $"'{this.Id}':Input({this._inputPortValue!.Request.Name}->{this._inputPortValue!.Response.Name})", Type.Agent => $"{this.Id}':AIAgent(@{this._aiAgentValue!.GetType().Name})", - //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", _ => $"'{this.Id}':" }; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs b/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs index b90048a43a..4a767bb282 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs @@ -6,28 +6,36 @@ namespace Microsoft.Agents.Workflows; /// -/// . +/// Represents a function that incrementally aggregates a sequence of input values, producing an updated result for each +/// input. /// -/// -/// -/// -/// -/// -public delegate TResult? StreamingAggregator(TInput input, TResult? runningResult); +/// The type of the input value to be aggregated. +/// The type of the aggregation result produced by the function. +/// The current input value to be incorporated into the aggregation. +/// The current aggregated result, or null if this is the first input. +/// The updated aggregation result after processing the input value, or null if no result can be produced. +public delegate TResult? StreamingAggregator(TInput input, TResult? runningResult); /// -/// . +/// Provides a set of streaming aggregation functions for processing sequences of input values in a stateful, +/// incremental manner. /// public static class StreamingAggregators { /// - /// . + /// Creates a streaming aggregator that returns the result of applying the specified conversion function to the + /// first input value, or a default value if no input is provided. /// - /// - /// - /// - /// - /// + /// Subsequent inputs after the first are ignored by the aggregator. This method is useful for + /// scenarios where only the first occurrence in a stream is relevant. The conversion function is invoked at most + /// once. + /// The type of the input elements to be aggregated. + /// The type of the result produced by the conversion function. + /// A function that converts an input value of type to a result of type . This function is applied to the first input received. + /// The value to return if no input is provided. + /// A that yields the converted result of the first input, or the + /// specified default value if no input is received. public static StreamingAggregator First(Func conversion, TResult? defaultValue = default) { bool hasRun = false; @@ -47,22 +55,26 @@ public static StreamingAggregator First(Func - /// . + /// Creates a streaming aggregator that returns the first input element, or a specified default value if no elements + /// are provided. /// - /// - /// - /// + /// The type of the input elements to aggregate. + /// The value to return if the input sequence contains no elements. + /// A that yields the first input element, or if the sequence is empty. public static StreamingAggregator First(TInput? defaultValue = default) => First(input => input, defaultValue); /// - /// . + /// Creates a streaming aggregator that returns the result of applying the specified conversion to the most recent + /// input value. /// - /// - /// - /// - /// - /// + /// The type of the input elements to be aggregated. + /// The type of the result produced by the conversion function. + /// A function that converts each input value to a result. Cannot be null. + /// The initial result value to use before any input is processed. + /// A streaming aggregator that yields the converted value of the last input received, or the specified default + /// value if no input has been processed. public static StreamingAggregator Last(Func conversion, TResult? defaultValue = default) { TResult? local = defaultValue; @@ -77,21 +89,25 @@ public static StreamingAggregator Last(Func - /// . + /// Creates a streaming aggregator that returns the last element in a sequence, or a specified default value if the + /// sequence is empty. /// - /// - /// - /// + /// The type of elements in the input sequence. + /// The value to return if the input sequence contains no elements. + /// A that yields the last element of the sequence, or if the sequence is empty. public static StreamingAggregator Last(TInput? defaultValue = default) => Last(input => input, defaultValue); /// - /// . + /// Creates a streaming aggregator that produces the union of results by applying a conversion function to each + /// input and accumulating the results. /// - /// - /// - /// - /// + /// The type of the input elements to be aggregated. + /// The type of the result elements produced by the conversion function. + /// A function that converts each input element to a result element to be included in the union. + /// A streaming aggregator that, for each input, returns an enumerable containing all result elements produced so + /// far. public static StreamingAggregator> Union(Func conversion) { List results = new(); @@ -106,10 +122,13 @@ IEnumerable Aggregate(TInput input, IEnumerable? runningResult } /// - /// . + /// Creates a streaming aggregator that produces the union of all input sequences of type TInput. /// - /// - /// + /// The resulting aggregator combines all input sequences into a single sequence containing + /// distinct elements. The order of elements in the output sequence is not guaranteed. + /// The type of the elements in the input sequences to be aggregated. + /// A StreamingAggregator that, when applied to multiple input sequences, returns an IEnumerable containing the + /// union of all elements from those sequences. public static StreamingAggregator> Union() => Union(input => input); } diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index cb96b72c78..e7084b2895 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -24,8 +24,13 @@ public delegate TExecutor ExecutorProvider() where TExecutor : Executor; /// -/// . +/// Provides a builder for constructing and configuring a workflow by defining executors and the connections between +/// them. /// +/// Use the WorkflowBuilder to incrementally add executors and edges, including fan-in and fan-out +/// patterns, before building a strongly-typed workflow instance. Executors must be bound before building the workflow. +/// All executors must be bound by calling into if they were intially specified as +/// . public class WorkflowBuilder { private record struct EdgeId(string SourceId, string TargetId) @@ -42,9 +47,9 @@ private record struct EdgeId(string SourceId, string TargetId) private readonly string _startExecutorId; /// - /// . + /// Initializes a new instance of the WorkflowBuilder class with the specified starting executor. /// - /// + /// The executor that defines the starting point of the workflow. Cannot be null. public WorkflowBuilder(ExecutorIsh start) { this._startExecutorId = this.Track(start).Id; @@ -83,11 +88,11 @@ private void UpdateExecutor(string id, ExecutorProvider provider) } /// - /// . + /// Binds the specified executor to the workflow, allowing it to participate in workflow execution. /// - /// - /// - /// + /// The executor instance to bind. The executor must exist in the workflow and not be already bound. + /// The current instance, enabling fluent configuration. + /// Thrown if the specified executor is already bound or does not exist in the workflow. public WorkflowBuilder BindExecutor(Executor executor) { if (!this._unboundExecutors.Contains(executor.Id)) @@ -114,13 +119,16 @@ private HashSet EnsureEdgesFor(string sourceId) } /// - /// . + /// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a + /// condition. /// - /// - /// - /// - /// - /// + /// The executor that acts as the source node of the edge. Cannot be null. + /// The executor that acts as the target node of the edge. Cannot be null. + /// An optional predicate that determines whether the edge should be followed based on the input. + /// If null, the edge is always activated when the source sends a message. + /// The current instance of . + /// Thrown if an unconditional edge between the specified source and target + /// executors already exists. public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func? condition = null) { // Add an edge from source to target with an optional condition. @@ -144,12 +152,16 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func - /// . + /// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a + /// custom partitioning function. /// - /// - /// - /// - /// + /// If a partitioner function is provided, it will be used to distribute input across the target + /// executors. The order of targets determines their mapping in the partitioning process. + /// The source executor from which the fan-out edge originates. Cannot be null. + /// An optional function that determines how input is partitioned among the target executors. + /// If null, messages will route to all targets. + /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. + /// The current instance of . public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func>? partitioner = null, params ExecutorIsh[] targets) { Throw.IfNull(source); @@ -165,13 +177,18 @@ public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func - /// . + /// Adds a fan-in edge to the workflow, connecting multiple source executors to a single target executor with an + /// optional trigger condition. /// - /// - /// - /// - /// - public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = default, params ExecutorIsh[] sources) + /// This method establishes a fan-in relationship, allowing the target executor to be activated + /// based on the completion or state of multiple sources. The trigger parameter can be used to customize activation + /// behavior. + /// The target executor that receives input from the specified source executors. Cannot be null. + /// An optional trigger condition that determines when the fan-in edge activates. Defaults to + /// . + /// One or more source executors that provide input to the target. Cannot be null or empty. + /// The current instance of . + public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = FanInTrigger.WhenAll, params ExecutorIsh[] sources) { Throw.IfNull(target); Throw.IfNullOrEmpty(sources); @@ -190,11 +207,13 @@ public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = d } /// - /// . + /// Builds and returns a workflow instance configured to process messages of the specified input type. /// - /// - /// - /// + /// The type of input messages that the workflow will accept and process. + /// A new instance of . + /// Thrown if there are unbound executors in the workflow definition, + /// if the start executor is not bound, or if the start executor does not contain a handler for the specified input + /// type . public Workflow Build() { if (this._unboundExecutors.Count > 0) diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index 69894710fa..c765bc53ca 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -9,18 +9,24 @@ namespace Microsoft.Agents.Workflows; /// -/// . +/// Provides extension methods for configuring and building workflows using the WorkflowBuilder type. /// +/// These extension methods simplify the process of connecting executors, adding external calls, and +/// constructing workflows with output aggregation. They are intended to streamline workflow graph construction and +/// promote common patterns for chaining and aggregating workflow steps. public static class WorkflowBuilderExtensions { /// - /// . + /// Adds a sequential chain of executors to the workflow, connecting each executor in order so that each is + /// executed after the previous one. /// - /// - /// - /// - /// - /// + /// Each executor in the chain is connected so that execution flows from the source to each subsequent + /// executor in the order provided. + /// The workflow builder to which the executor chain will be added. + /// The initial executor in the chain. Cannot be null. + /// An ordered array of executors to be added to the chain after the source. + /// The original workflow builder instance with the specified executor chain added. + /// Thrown if there is a cycle in the chain. public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors) { Throw.IfNull(builder); @@ -47,14 +53,18 @@ public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh } /// - /// . + /// Adds an external call to the workflow by connecting the specified source to a new input port with the given + /// request and response types. /// - /// - /// - /// - /// - /// - /// + /// This method creates a bidirectional connection between the source and the new input port, + /// allowing the workflow to send requests and receive responses through the specified external call. The port is + /// configured to handle messages of the specified request and response types. + /// The type of the request message that the external call will accept. + /// The type of the response message that the external call will produce. + /// The workflow builder to which the external call will be added. + /// The source executor representing the external system or process to connect. Cannot be null. + /// The unique identifier for the input port that will handle the external call. Cannot be null. + /// The original workflow builder instance with the external call added. public static WorkflowBuilder AddExternalCall(this WorkflowBuilder builder, ExecutorIsh source, string portId) { Throw.IfNull(builder); @@ -67,15 +77,21 @@ public static WorkflowBuilder AddExternalCall(this Workflow } /// - /// . + /// Builds a workflow that collects output from the specified executor, aggregates results using the provided + /// streaming aggregator, and optionally completes based on a custom condition. /// - /// - /// - /// - /// - /// - /// - /// + /// The returned workflow promotes the output collector as its result source, allowing consumers + /// to access the aggregated output directly. The completion condition can be used to implement custom termination + /// logic, such as early stopping when a desired result is reached. + /// The type of input items processed by the workflow. + /// The type of aggregated result produced by the workflow. + /// The workflow builder used to construct the workflow and define its execution graph. + /// The executor that produces output items to be collected and aggregated. Cannot be null. + /// The streaming aggregator that processes input items and produces aggregated results. Cannot be null. + /// An optional predicate that determines when the workflow should complete based on the current input and + /// aggregated result. If null, the workflow will not raise a . + /// A workflow that collects output from the specified executor, aggregates results, and exposes the aggregated + /// output. public static Workflow BuildWithOutput( this WorkflowBuilder builder, ExecutorIsh outputSource, From deb085de4167c259ae4d19c7c2fc41594627e5a9 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 14:00:39 -0400 Subject: [PATCH 079/232] refactor: Normalize UnitTest and Sample namespaces --- .../ReflectionSmokeTest.cs | 2 +- .../Sample/05_Simple_Workflow_ExternalRequest.cs | 4 +--- .../Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs index ae6cb303a1..5430e30d94 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs @@ -5,7 +5,7 @@ using Microsoft.Agents.Workflows.Core; using Moq; -namespace Microsoft.Agents.Orchestration.UnitTest; +namespace Microsoft.Agents.Workflows.UnitTests; public class BaseTestExecutor : Executor { diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs index ca2de73632..548790cc27 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs @@ -3,12 +3,10 @@ using System; using System.IO; using System.Threading.Tasks; -using Microsoft.Agents.Workflows; using Microsoft.Agents.Workflows.Core; using Microsoft.Agents.Workflows.Execution; -using Microsoft.Agents.Workflows.Sample; -namespace Microsoft.Agents.Workflow.UnitTests.Sample; +namespace Microsoft.Agents.Workflows.Sample; internal static class Step5EntryPoint { diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs index 7ec53c5756..12fe7f8f37 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs @@ -4,10 +4,9 @@ using System.IO; using System.Linq; using System.Threading.Tasks; -using Microsoft.Agents.Workflow.UnitTests.Sample; using Microsoft.Agents.Workflows.Sample; -namespace Microsoft.Agents.Workflow.UnitTests; +namespace Microsoft.Agents.Workflows.UnitTests; public class SampleSmokeTest { From a5b06bd8b40a44c2dd7c371fd06d537203573e98 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 14:21:28 -0400 Subject: [PATCH 080/232] fix: Formatting --- dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs | 4 ++-- dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs | 7 ------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs index 0769d0012e..4334a0651d 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs @@ -1,11 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using Microsoft.Shared.Diagnostics; -using PredicateT = System.Func; using PartitionerT = System.Func>; -using System; +using PredicateT = System.Func; namespace Microsoft.Agents.Workflows.Core; diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index e7084b2895..da80121a36 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -1,17 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. -#pragma warning disable IDE0005 // Using directive is unnecessary. using System; using System.Collections.Generic; using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Execution; using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; -using System.Collections.Concurrent; - -#pragma warning restore IDE0005 // Using directive is unnecessary. namespace Microsoft.Agents.Workflows; From 4bc956acade0c9b45d4ae1bf54930cca7d4cf1fa Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 15:15:03 -0400 Subject: [PATCH 081/232] refactor: Normalize project/folder names --- dotnet/agent-framework-dotnet.slnx | 4 ++-- .../Core/CallResult.cs | 0 .../Core/Edge.cs | 0 .../Core/Events.cs | 0 .../Core/Executor.cs | 0 .../Core/ExternalRequest.cs | 0 .../Core/ExternalResponse.cs | 0 .../Core/IIdentified.cs | 0 .../Core/IMessageHandler.cs | 0 .../Core/IMessageRouter.cs | 0 .../Core/IWorkflowContext.cs | 0 .../Core/InputPort.cs | 0 .../Core/Message.cs | 0 .../Core/MessageHandlerInfo.cs | 0 .../Core/MessageRouter.cs | 0 .../Core/RouteBuilder.cs | 0 .../Core/RouteBuilderExtensions.cs | 0 .../Core/StreamsMessageAttribute.cs | 0 .../Core/ValueTaskTypeErasure.cs | 0 .../Core/Workflow.cs | 0 .../Execution/DirectEdgeRunner.cs | 0 .../Execution/EdgeMap.cs | 0 .../Execution/EdgeRunner.cs | 0 .../Execution/ExecutorIdentity.cs | 0 .../Execution/FanInEdgeRunner.cs | 0 .../Execution/FanInEdgeState.cs | 0 .../Execution/FanOutEdgeRunner.cs | 0 .../Execution/IExternalRequestSink.cs | 0 .../Execution/IRunnerContext.cs | 0 .../Execution/IRunnerWithOutput.cs | 0 .../Execution/ISuperStepRunner.cs | 0 .../Execution/InputEdgeRunner.cs | 0 .../Execution/LocalRunner.cs | 0 .../Execution/LocalRunnerContext.cs | 0 .../Execution/StepContext.cs | 0 .../Execution/StreamingExecutionHandle.cs | 0 .../ExecutorIsh.cs | 0 .../Microsoft.Agents.Workflows.csproj | 2 +- .../Specialized/AIAgentHostExecutor.cs | 0 .../Specialized/OutputCollectorExecutor.cs | 0 .../Specialized/RequestInputExecutor.cs | 0 .../StreamingAggregators.cs | 0 .../WorkflowBuilder.cs | 0 .../WorkflowBuilderExtensions.cs | 0 .../Microsoft.Agents.Workflows.UnitTests.csproj} | 2 +- .../ReflectionSmokeTest.cs | 0 .../Sample/01_Simple_Workflow_Sequential.cs | 0 .../Sample/02_Simple_Workflow_Condition.cs | 0 .../Sample/03_Simple_Workflow_Loop.cs | 0 .../Sample/05_Simple_Workflow_ExternalRequest.cs | 0 .../SampleSmokeTest.cs | 0 51 files changed, 4 insertions(+), 4 deletions(-) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/CallResult.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/Edge.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/Events.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/Executor.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/ExternalRequest.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/ExternalResponse.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/IIdentified.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/IMessageHandler.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/IMessageRouter.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/IWorkflowContext.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/InputPort.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/Message.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/MessageHandlerInfo.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/MessageRouter.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/RouteBuilder.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/RouteBuilderExtensions.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/StreamsMessageAttribute.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/ValueTaskTypeErasure.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/Workflow.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/DirectEdgeRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/EdgeMap.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/EdgeRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/ExecutorIdentity.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/FanInEdgeRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/FanInEdgeState.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/FanOutEdgeRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/IExternalRequestSink.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/IRunnerContext.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/IRunnerWithOutput.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/ISuperStepRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/InputEdgeRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/LocalRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/LocalRunnerContext.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/StepContext.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/StreamingExecutionHandle.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/ExecutorIsh.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Microsoft.Agents.Workflows.csproj (94%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Specialized/AIAgentHostExecutor.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Specialized/OutputCollectorExecutor.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Specialized/RequestInputExecutor.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/StreamingAggregators.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/WorkflowBuilder.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/WorkflowBuilderExtensions.cs (100%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj => Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj} (92%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests => Microsoft.Agents.Workflows.UnitTests}/ReflectionSmokeTest.cs (100%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests => Microsoft.Agents.Workflows.UnitTests}/Sample/01_Simple_Workflow_Sequential.cs (100%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests => Microsoft.Agents.Workflows.UnitTests}/Sample/02_Simple_Workflow_Condition.cs (100%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests => Microsoft.Agents.Workflows.UnitTests}/Sample/03_Simple_Workflow_Loop.cs (100%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests => Microsoft.Agents.Workflows.UnitTests}/Sample/05_Simple_Workflow_ExternalRequest.cs (100%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests => Microsoft.Agents.Workflows.UnitTests}/SampleSmokeTest.cs (100%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index e79922db8d..253364f376 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -116,7 +116,7 @@ - + @@ -129,7 +129,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ExternalRequest.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/ExternalRequest.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ExternalResponse.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/ExternalResponse.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IIdentified.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IIdentified.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/IIdentified.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/IIdentified.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IMessageHandler.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/IMessageHandler.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/IMessageHandler.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IMessageRouter.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/IMessageRouter.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/IMessageRouter.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/InputPort.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/InputPort.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Message.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/Message.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandlerInfo.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandlerInfo.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageRouter.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/MessageRouter.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/StreamsMessageAttribute.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/StreamsMessageAttribute.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ValueTaskTypeErasure.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/ValueTaskTypeErasure.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/DirectEdgeRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/DirectEdgeRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/EdgeMap.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/EdgeMap.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/EdgeRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/EdgeRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutorIdentity.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/ExecutorIdentity.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutorIdentity.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/ExecutorIdentity.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeState.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeState.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeState.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeState.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IExternalRequestSink.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/IExternalRequestSink.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/IExternalRequestSink.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/IExternalRequestSink.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerContext.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerContext.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithOutput.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerWithOutput.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithOutput.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerWithOutput.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/ISuperStepRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/ISuperStepRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/InputEdgeRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/InputEdgeRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StepContext.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/StepContext.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingExecutionHandle.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingExecutionHandle.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs rename to dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj b/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj similarity index 94% rename from dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj rename to dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj index 65f8a0dfab..4cd49dd698 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj @@ -26,7 +26,7 @@ - + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Specialized/AIAgentHostExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInputExecutor.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInputExecutor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs b/dotnet/src/Microsoft.Agents.Workflows/StreamingAggregators.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs rename to dotnet/src/Microsoft.Agents.Workflows/StreamingAggregators.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs rename to dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs rename to dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj similarity index 92% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj index d384557d8f..006de84807 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj @@ -6,7 +6,7 @@ - + diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/01_Simple_Workflow_Sequential.cs rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Condition.cs rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/03_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/03_Simple_Workflow_Loop.cs rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs From 5cb98e48c975d6a3cbbb1d37542a2301535fa38d Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 16:20:25 -0400 Subject: [PATCH 082/232] feat: Remove DynamicCodeExecution from ValueTaskTypeErasure --- .../Core/ReflectionExtensions.cs | 45 ++++++++++ .../Core/ValueTaskTypeErasure.cs | 88 +++++++++++-------- 2 files changed, 97 insertions(+), 36 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs new file mode 100644 index 0000000000..8ec77241db --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics; +using System.Reflection; + +#if !NET +using System.Linq; +#endif + +namespace Microsoft.Agents.Workflows.Core; + +internal static class ReflectionExtensions +{ + public static object? ReflectionInvoke(this MethodInfo method, object? target, params object?[] arguments) + { +#if NET + return method.Invoke(target, BindingFlags.DoNotWrapExceptions, binder: null, arguments, culture: null); +#else + try + { + return method.Invoke(target, BindingFlags.Default, binder: null, arguments, culture: null); + } + catch (TargetInvocationException e) when (e.InnerException is not null) + { + // If we're targeting .NET Framework, such that BindingFlags.DoNotWrapExceptions + // is ignored, the original exception will be wrapped in a TargetInvocationException. + // Unwrap it and throw that original exception, maintaining its stack information. + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(e.InnerException).Throw(); + throw; + } +#endif + } + + public static MethodInfo GetMethodFromGenericMethodDefinition(this Type specializedType, MethodInfo genericMethodDefinition) + { + Debug.Assert(specializedType.IsGenericType && specializedType.GetGenericTypeDefinition() == genericMethodDefinition.DeclaringType, "generic member definition doesn't match type."); +#if NET + return (MethodInfo)specializedType.GetMemberWithSameMetadataDefinitionAs(genericMethodDefinition); +#else + const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; + return specializedType.GetMethods(All).First(m => m.MetadataToken == genericMethodDefinition.MetadataToken); +#endif + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs index 8166a7a5c6..238fc95fef 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs @@ -1,60 +1,76 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics; using System.Reflection; using System.Threading.Tasks; namespace Microsoft.Agents.Workflows.Core; +internal static class ValueTaskReflection +{ + private const string Nameof_AsTask = nameof(ValueTask.AsTask); + internal static readonly MethodInfo AsTask = typeof(ValueTask<>).GetMethod(Nameof_AsTask, BindingFlags.Public | BindingFlags.Instance)!; + + internal static MethodInfo ReflectAsTask(this Type specializedType) + { + Debug.Assert(specializedType.IsGenericType && + specializedType.GetGenericTypeDefinition() == typeof(ValueTask<>), "specializedType must be a ValueTask<> type."); + + return specializedType.GetMethodFromGenericMethodDefinition(AsTask); + } + + internal static bool IsValueTaskType(this Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>); +} + +internal static class TaskReflection +{ + private const string Nameof_Result = nameof(Task.Result); + internal static readonly MethodInfo Result_get = typeof(Task<>).GetProperty(Nameof_Result)!.GetMethod!; + + internal static MethodInfo ReflectResult_get(this Type specializedType) + { + Debug.Assert(specializedType.IsGenericType && + specializedType.GetGenericTypeDefinition() == typeof(Task<>), "specializedType must be a ValueTask<> type."); + + return specializedType.GetMethodFromGenericMethodDefinition(Result_get); + } + + internal static bool IsTaskType(this Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Task<>); +} + internal static class ValueTaskTypeErasure { - internal static Func> CreateErasingUnwrapper() + internal static Func> UnwrapperFor(Type expectedResultType) { return UnwrapAndEraseAsync; - static async ValueTask UnwrapAndEraseAsync(object maybeValueTask) + async ValueTask UnwrapAndEraseAsync(object maybeGenericVT) { - if (maybeValueTask is ValueTask vt) + // This method handles only ValueTask types. + Type maybeVTType = maybeGenericVT.GetType(); + + if (!maybeVTType.IsValueTaskType()) { - // If the input is a ValueTask, unwrap it. - TResult result = await vt.ConfigureAwait(false); - return (object?)result; + throw new InvalidOperationException($"Expected ValueTask or ValueTask<{expectedResultType.Name}>, but got {maybeGenericVT.GetType().Name}."); } - throw new InvalidOperationException($"Expected ValueTask or ValueTask<{typeof(TResult).Name}>, but got {maybeValueTask.GetType().Name}."); - } - } - -#if NET5_0_OR_GREATER - // This suppression is qualified because for some reason VS is not recognizing the attribute's presence, treating the - // import as an error (due to unnecessary using). - [System.Diagnostics.CodeAnalysis.RequiresDynamicCode("Calls System.Reflection.MethodInfo.MakeGenericMethod(params Type[])")] -#endif - internal static Func> UnwrapperFor(Type resultType) - { - // This method creates a type-erased unwrapper for ValueTask. - // It uses reflection to create a delegate that can handle any TResult type. + MethodInfo asTaskMethod = maybeVTType.ReflectAsTask(); + Debug.Assert(asTaskMethod.ReturnType.IsTaskType(), "AsTask must return a Task<> type."); - // TODO: AOT: This method is marked with RequiresDynamicCodeAttribute, which will not work well in NativeAOT - // scenarios; the solution is to break this up into a Cached/Reflector version (like the MessageRouter does - // with handlers), and SourceGenerate the UnwrapAndEraseAsync-equivalent method for each TResult type. + MethodInfo getResultMethod = asTaskMethod.ReturnType.ReflectResult_get(); + Type actualResultType = getResultMethod.ReturnType; - // Note that this is only necessary because ValueTask is a class-generic, rather than an interface - // type, which means that the type cannot be co/contravariantly used (e.g. ValueTask is not a valid - // supertype of ValueTask or ValueTask, T != object?). + if (!expectedResultType.IsAssignableFrom(actualResultType)) + { + throw new InvalidOperationException($"Expected ValueTask<{expectedResultType.Name}> or a compatible type, but got ValueTask<{actualResultType.Name}>."); + } - MethodInfo createMethod = - typeof(ValueTaskTypeErasure) - .GetMethod(nameof(CreateErasingUnwrapper), BindingFlags.NonPublic | BindingFlags.Static) - !.MakeGenericMethod(resultType); + Task task = (Task)asTaskMethod.ReflectionInvoke(maybeGenericVT)!; + await task.ConfigureAwait(false); // TODO: Could we need to capture the context here? + object? result = getResultMethod.ReflectionInvoke(task); - // Invoke createMethod (as static) to get the delegate. - object? maybeUnwrapper = createMethod.Invoke(null, Array.Empty()); - if (maybeUnwrapper is not Func> unwrapper) - { - throw new InvalidOperationException($"Expected a Func> delegate, but got {maybeUnwrapper?.GetType().Name ?? "null"}."); + return result; } - - return unwrapper; } } From 333aff82d1322121c0920c27ed41c53c2037eb58 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 16:34:11 -0400 Subject: [PATCH 083/232] fix: Fix ILTrim warnings --- .../Core/RouteBuilderExtensions.cs | 106 +++++++++++++----- 1 file changed, 76 insertions(+), 30 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs index 2beadc3ec5..fc4c3c9845 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs @@ -3,23 +3,56 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Reflection; using Microsoft.Shared.Diagnostics; +#if NET9_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif + namespace Microsoft.Agents.Workflows.Core; +internal static class IMessageHandlerReflection +{ + private const string Nameof_HandleAsync = nameof(IMessageHandler.HandleAsync); + internal static readonly MethodInfo HandleAsync_1 = typeof(IMessageHandler<>).GetMethod(Nameof_HandleAsync, BindingFlags.Public | BindingFlags.Instance)!; + internal static readonly MethodInfo HandleAsync_2 = typeof(IMessageHandler<,>).GetMethod(Nameof_HandleAsync, BindingFlags.Public | BindingFlags.Instance)!; + + internal static MethodInfo ReflectHandleAsync(this Type specializedType, int genericArgumentCount) + { + Debug.Assert(specializedType.IsGenericType && + (specializedType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || + specializedType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>)), + "specializedType must be an IMessageHandler<> or IMessageHandler<,> type."); + return genericArgumentCount switch + { + 1 => specializedType.GetMethodFromGenericMethodDefinition(HandleAsync_1), + 2 => specializedType.GetMethodFromGenericMethodDefinition(HandleAsync_2), + _ => throw new ArgumentOutOfRangeException(nameof(genericArgumentCount), "Must be 1 or 2.") + }; + } + + internal static int GenericArgumentCount(this Type type) + { + Debug.Assert(type.IsMessageHandlerType(), "type must be an IMessageHandler<> or IMessageHandler<,> type."); + return type.GetGenericArguments().Length; + } + + internal static bool IsMessageHandlerType(this Type type) => + type.IsGenericType && + (type.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || + type.GetGenericTypeDefinition() == typeof(IMessageHandler<,>)); +} + internal static class RouteBuilderExtensions { - [SuppressMessage("Trimming", - "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + - "of the source method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - [SuppressMessage("Trimming", - "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of " + - "method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - private static IEnumerable GetHandlerInfos(this Type executorType) + private static IEnumerable GetHandlerInfos( +#if NET9_0_OR_GREATER + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.Interfaces)] +#endif + this Type executorType) { // Handlers are defined by implementations of IMessageHandler or IMessageHandler Debug.Assert(typeof(Executor).IsAssignableFrom(executorType), "executorType must be an Executor type."); @@ -27,33 +60,37 @@ private static IEnumerable GetHandlerInfos(this Type executo foreach (Type interfaceType in executorType.GetInterfaces()) { // Check if the interface is a message handler. - if (!interfaceType.IsGenericType) + if (!interfaceType.IsMessageHandlerType()) { continue; } - if (interfaceType.IsGenericType && (interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || - interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>))) + // Get the generic arguments of the interface. + Type[] genericArguments = interfaceType.GetGenericArguments(); + if (genericArguments.Length < 1 || genericArguments.Length > 2) + { + continue; // Invalid handler signature. + } + Type inType = genericArguments[0]; + Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; + + MethodInfo? method = interfaceType.ReflectHandleAsync(genericArguments.Length); + + if (method != null) { - // Get the generic arguments of the interface. - Type[] genericArguments = interfaceType.GetGenericArguments(); - if (genericArguments.Length < 1 || genericArguments.Length > 2) - { - continue; // Invalid handler signature. - } - Type inType = genericArguments[0]; - Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; - MethodInfo? method = interfaceType.GetMethod("HandleAsync"); - - if (method != null) - { - yield return new MessageHandlerInfo(method) { InType = inType, OutType = outType }; - } + yield return new MessageHandlerInfo(method) { InType = inType, OutType = outType }; } } } - public static RouteBuilder ReflectHandlers(this RouteBuilder builder, Type executorType, Executor executor) + public static RouteBuilder ReflectHandlers(this RouteBuilder builder, +#if NET9_0_OR_GREATER + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.Interfaces)] +#endif + Type executorType, + Executor executor) { Throw.IfNull(builder); Throw.IfNull(executorType); @@ -68,6 +105,15 @@ public static RouteBuilder ReflectHandlers(this RouteBuilder builder, Type execu return builder; } - public static RouteBuilder ReflectHandlers(this RouteBuilder builder, TExecutor executor) where TExecutor : Executor - => builder.ReflectHandlers(executor.GetType(), executor); + public static RouteBuilder ReflectHandlers< +#if NET9_0_OR_GREATER + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.Interfaces)] +#endif + TExecutor + >(this RouteBuilder builder, TExecutor executor) + { + return builder.ReflectHandlers(typeof(TExecutor), (Executor)(object)executor!); + } } From b2b79f52b201705897e8b00e77ffae6b7aaf2a09 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 16:39:58 -0400 Subject: [PATCH 084/232] docs: Add missing docs and fix typos --- .../Microsoft.Agents.Workflows/Core/CallResult.cs | 2 +- dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs index 482a228e1a..79df6aba82 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs @@ -41,7 +41,7 @@ private CallResult(bool isVoid = false) } /// - /// Create a indicating a successful that returned a result (non-void). + /// Create a indicating a successful call that returned a result (non-void). /// /// The result to return. /// A indicating the result of the call. diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs index 4334a0651d..878ac98a09 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs @@ -22,9 +22,9 @@ public record DirectEdgeData( PredicateT? Condition = null) { /// - /// Converts a instance to an using an implicit conversion. + /// Converts a instance to an . /// - /// The to convert to an . Cannot be null. + /// The to convert t. public static implicit operator Edge(DirectEdgeData data) { return new Edge(Throw.IfNull(data)); @@ -44,9 +44,9 @@ public record FanOutEdgeData( PartitionerT? Partitioner = null) { /// - /// . + /// Converts a instance to an . /// - /// + /// The to convert. public static implicit operator Edge(FanOutEdgeData data) { return new Edge(data); @@ -85,9 +85,9 @@ public record FanInEdgeData( internal Guid UniqueKey { get; } = Guid.NewGuid(); /// - /// . + /// Converts a instance to an . /// - /// + /// The to convert. public static implicit operator Edge(FanInEdgeData data) { return new Edge(data); From 696e72a167dfa094730affc135c41591ae8f52e4 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 16:46:31 -0400 Subject: [PATCH 085/232] feat: Hosted Agents should report Run events --- .../Microsoft.Agents.Workflows/Core/Events.cs | 49 +++---------------- .../Specialized/AIAgentHostExecutor.cs | 1 + 2 files changed, 8 insertions(+), 42 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs index 1bbf5150a5..06d2759d53 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Extensions.AI.Agents; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; @@ -77,44 +78,8 @@ public record ExecutorCompleteEvent : ExecutorEvent public ExecutorCompleteEvent(string executorId, object? result = null) : base(executorId, result) { } } -// TODO: This is a placeholder for streaming chat message content. /// -/// . -/// -public class StreamingChatMessageContent -{ } - -/// -/// . -/// -public record AgentRunStreamingEvent : ExecutorEvent -{ - /// - /// Initializes a new instance of the class. - /// - /// The identifier of the executor that generated this event. - /// - public AgentRunStreamingEvent(string executorId, StreamingChatMessageContent? content = null) : base(executorId, data: content) - { - this.Content = content; - } - - /// - /// Gets the content of the streaming chat message. - /// - public StreamingChatMessageContent? Content { get; } -} - -// TODO: This is a placeholder for non-streaming chat message content. -/// -/// . -/// -public class ChatMessageContent -{ -} - -/// -/// . +/// Event triggered when an agent run is completed. /// public record AgentRunEvent : ExecutorEvent { @@ -122,14 +87,14 @@ public record AgentRunEvent : ExecutorEvent /// Initializes a new instance of the class. /// /// The identifier of the executor that generated this event. - /// - public AgentRunEvent(string executorId, ChatMessageContent? content = null) : base(executorId, data: content) + /// + public AgentRunEvent(string executorId, AgentRunResponse? response = null) : base(executorId, data: response) { - this.Content = content; + this.Response = response; } /// - /// Gets the content of the chat message. + /// Gets the content of the agent response. /// - public ChatMessageContent? Content { get; } + public AgentRunResponse? Response { get; } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs index 2f156d9191..15129858a6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs @@ -26,6 +26,7 @@ public async ValueTask HandleAsync(IList message, IWorkflowContext // incremental updates from the chat model. AgentRunResponse runResponse = await this.Agent.RunAsync(messageList).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunEvent(this.Id, runResponse)).ConfigureAwait(false); await context.SendMessageAsync(runResponse).ConfigureAwait(false); } } From 3e6334d722ba2c66e4bbfe5e20fa127789e7ac57 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 17:17:19 -0400 Subject: [PATCH 086/232] fix: Fix type propagation for ILTrim changes --- .../Microsoft.Agents.Workflows/Core/Events.cs | 4 +-- .../Core/Executor.cs | 34 ++++++++++++++---- .../Core/IWorkflowContext.cs | 2 +- .../Core/MessageHandlerInfo.cs | 9 +++-- .../Core/RouteBuilder.cs | 4 +-- .../Core/RouteBuilderExtensions.cs | 36 +++++-------------- .../Core/Workflow.cs | 8 ++--- .../Execution/DirectEdgeRunner.cs | 4 +-- .../Execution/FanInEdgeRunner.cs | 4 +-- .../Execution/FanOutEdgeRunner.cs | 4 +-- .../Execution/IRunnerContext.cs | 2 +- .../Execution/InputEdgeRunner.cs | 4 +-- .../Execution/LocalRunnerContext.cs | 6 ++-- .../Microsoft.Agents.Workflows/ExecutorIsh.cs | 18 +++++----- .../Microsoft.Agents.Workflows.csproj | 2 ++ .../Specialized/AIAgentHostExecutor.cs | 2 +- .../Specialized/OutputCollectorExecutor.cs | 14 ++++---- .../Specialized/RequestInputExecutor.cs | 2 +- .../WorkflowBuilder.cs | 14 ++++---- .../ReflectionSmokeTest.cs | 16 ++++----- .../Sample/01_Simple_Workflow_Sequential.cs | 4 +-- .../Sample/02_Simple_Workflow_Condition.cs | 6 ++-- .../Sample/03_Simple_Workflow_Loop.cs | 4 +-- 23 files changed, 107 insertions(+), 96 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs index 06d2759d53..0581b12ca6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs @@ -19,7 +19,7 @@ public record WorkflowStartedEvent : WorkflowEvent; /// Event triggered when a workflow completes execution. /// /// -/// The user is expected to raise this event from a terminating , or to build +/// The user is expected to raise this event from a terminating , or to build /// the workflow with output capture using . /// public record WorkflowCompletedEvent : WorkflowEvent; @@ -30,7 +30,7 @@ public record WorkflowCompletedEvent : WorkflowEvent; public record RequestInputEvent(ExternalRequest Request) : WorkflowEvent; /// -/// Base class for -scoped events. +/// Base class for -scoped events. /// public record ExecutorEvent : WorkflowEvent { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs index 8081bb9af3..284f656a2d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Threading.Tasks; @@ -12,7 +13,7 @@ namespace Microsoft.Agents.Workflows.Core; /// A component that processes messages in a . /// [DebuggerDisplay("{GetType().Name}{Id}")] -public abstract class Executor : IIdentified, IAsyncDisposable +public abstract class ExecutorBase : IIdentified, IAsyncDisposable { /// /// A unique identifier for the executor. @@ -26,7 +27,7 @@ public abstract class Executor : IIdentified, IAsyncDisposable /// /// A optional unique identifier for the executor. If null, a type-tagged /// UUID will be generated. - protected Executor(string? id = null) + protected ExecutorBase(string? id = null) { this.Id = id ?? $"{this.GetType().Name}/{Guid.NewGuid():N}"; } @@ -35,10 +36,7 @@ protected Executor(string? id = null) /// Override this method to register handlers for the executor. The deafult implementation uses reflection to /// look for implementations of and . /// - protected virtual RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) - { - return routeBuilder.ReflectHandlers(this); - } + protected abstract RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder); private MessageRouter? _router = null; internal MessageRouter Router @@ -145,3 +143,27 @@ ValueTask IAsyncDisposable.DisposeAsync() return this.DisposeAsync(); } } + +/// +/// A component that processes messages in a . +/// +/// The actual type of the . +/// This is used to reflectively discover handlers for messages without violating ILTrim requirements. +/// +public class Executor< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.Interfaces)] +TExecutor + > : ExecutorBase where TExecutor : Executor +{ + /// + protected Executor(string? id = null) : base(id) + { } + + /// + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + return routeBuilder.ReflectHandlers(this); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs index bf5528db9c..49495ca19f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs @@ -5,7 +5,7 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// Provides services for an during the execution of a workflow. +/// Provides services for an during the execution of a workflow. /// public interface IWorkflowContext { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs index da55f649c2..2b9d55fca9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs @@ -17,8 +17,6 @@ internal struct MessageHandlerInfo public MethodInfo HandlerInfo { get; init; } public Func>? Unwrapper { get; init; } = null; - [SuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality " + - "when AOT compiling.", Justification = "")] public MessageHandlerInfo(MethodInfo handlerInfo) { // The method is one of the following: @@ -118,7 +116,12 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext } } - public Func> Bind(Executor executor, bool checkType = false) + public Func> Bind< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.Interfaces)] TExecutor> + (Executor executor, bool checkType = false) + where TExecutor : Executor { MethodInfo handlerMethod = this.HandlerInfo; return MessageHandlerInfo.Bind(InvokeHandler, checkType, this.OutType, this.Unwrapper); diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs index 467a374bfa..b7d5bc22b5 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs @@ -15,10 +15,10 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// Provides a builder for configuring message type handlers for an . +/// Provides a builder for configuring message type handlers for an . /// /// -/// Override the method to customize the routing of messages to handlers. By +/// Override the method to customize the routing of messages to handlers. By /// default, uses reflection to find implementations of and /// . /// diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs index fc4c3c9845..91d594686e 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs @@ -3,13 +3,10 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using Microsoft.Shared.Diagnostics; -#if NET9_0_OR_GREATER -using System.Diagnostics.CodeAnalysis; -#endif - namespace Microsoft.Agents.Workflows.Core; internal static class IMessageHandlerReflection @@ -47,15 +44,13 @@ internal static bool IsMessageHandlerType(this Type type) => internal static class RouteBuilderExtensions { private static IEnumerable GetHandlerInfos( -#if NET9_0_OR_GREATER [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods | DynamicallyAccessedMemberTypes.Interfaces)] -#endif this Type executorType) { // Handlers are defined by implementations of IMessageHandler or IMessageHandler - Debug.Assert(typeof(Executor).IsAssignableFrom(executorType), "executorType must be an Executor type."); + Debug.Assert(typeof(ExecutorBase).IsAssignableFrom(executorType), "executorType must be an Executor type."); foreach (Type interfaceType in executorType.GetInterfaces()) { @@ -83,19 +78,18 @@ private static IEnumerable GetHandlerInfos( } } - public static RouteBuilder ReflectHandlers(this RouteBuilder builder, -#if NET9_0_OR_GREATER + public static RouteBuilder ReflectHandlers< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods | - DynamicallyAccessedMemberTypes.Interfaces)] -#endif - Type executorType, - Executor executor) + DynamicallyAccessedMemberTypes.Interfaces)] TExecutor> + (this RouteBuilder builder, Executor executor) + where TExecutor : Executor { Throw.IfNull(builder); - Throw.IfNull(executorType); - Debug.Assert(typeof(Executor).IsAssignableFrom(executorType), "executorType must be an Executor type."); + Type executorType = typeof(TExecutor); + Debug.Assert(executorType.IsAssignableFrom(executor.GetType()), + "executorType must be the same type or a base type of the executor instance."); foreach (MessageHandlerInfo handlerInfo in executorType.GetHandlerInfos()) { @@ -104,16 +98,4 @@ public static RouteBuilder ReflectHandlers(this RouteBuilder builder, return builder; } - - public static RouteBuilder ReflectHandlers< -#if NET9_0_OR_GREATER - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | - DynamicallyAccessedMemberTypes.NonPublicMethods | - DynamicallyAccessedMemberTypes.Interfaces)] -#endif - TExecutor - >(this RouteBuilder builder, TExecutor executor) - { - return builder.ReflectHandlers(typeof(TExecutor), (Executor)(object)executor!); - } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs index 0376119559..bf7c5ca3d0 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs @@ -15,7 +15,7 @@ public class Workflow /// /// A dictionary of executor providers, keyed by executor ID. /// - public Dictionary> ExecutorProviders { get; internal init; } = new(); + public Dictionary> ExecutorProviders { get; internal init; } = new(); /// /// Gets the collection of edges grouped by their source node identifier. @@ -67,7 +67,7 @@ public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) { } - internal Workflow Promote(OutputSink outputSource) + internal Workflow Promote(IOutputSink outputSource) { Throw.IfNull(outputSource); @@ -88,9 +88,9 @@ internal Workflow Promote(OutputSink outputSource) /// The type of the output from the workflow. public class Workflow : Workflow { - private readonly OutputSink _output; + private readonly IOutputSink _output; - internal Workflow(string startExecutorId, OutputSink outputSource) + internal Workflow(string startExecutorId, IOutputSink outputSource) : base(startExecutorId) { this._output = Throw.IfNull(outputSource); diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/DirectEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/DirectEdgeRunner.cs index df70b2b620..03ba90dbaf 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/DirectEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/DirectEdgeRunner.cs @@ -11,7 +11,7 @@ internal class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData edgeDa { public IWorkflowContext WorkflowContext { get; } = runContext.Bind(edgeData.SinkId); - private async ValueTask FindRouterAsync() + private async ValueTask FindRouterAsync() { return await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) .ConfigureAwait(false); @@ -24,7 +24,7 @@ private async ValueTask FindRouterAsync() return []; } - Executor target = await this.FindRouterAsync().ConfigureAwait(false); + ExecutorBase target = await this.FindRouterAsync().ConfigureAwait(false); if (target.CanHandle(message.GetType())) { return [await target.ExecuteAsync(message, this.WorkflowContext).ConfigureAwait(false)]; diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeRunner.cs index 9b790bd0e6..ed7847a1c4 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeRunner.cs @@ -22,8 +22,8 @@ internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData return null; } - Executor target = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) - .ConfigureAwait(false); + ExecutorBase target = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); if (target.CanHandle(message.GetType())) { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs index 7a21accf8b..e8508b90c9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs @@ -27,8 +27,8 @@ internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeDa async Task ProcessTargetAsync(string targetId) { - Executor executor = await this.RunContext.EnsureExecutorAsync(targetId) - .ConfigureAwait(false); + ExecutorBase executor = await this.RunContext.EnsureExecutorAsync(targetId) + .ConfigureAwait(false); if (executor.CanHandle(message.GetType())) { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerContext.cs index 692abdf3af..59d9afdb02 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerContext.cs @@ -14,5 +14,5 @@ internal interface IRunnerContext : IExternalRequestSink StepContext Advance(); IWorkflowContext Bind(string executorId); - ValueTask EnsureExecutorAsync(string executorId); + ValueTask EnsureExecutorAsync(string executorId); } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/InputEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/InputEdgeRunner.cs index bfe002b9bd..19dd1a3be9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/InputEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/InputEdgeRunner.cs @@ -19,14 +19,14 @@ public static InputEdgeRunner ForPort(IRunnerContext runContext, InputPort port) return new InputEdgeRunner(runContext, port.Id); } - private async ValueTask FindExecutorAsync() + private async ValueTask FindExecutorAsync() { return await this.RunContext.EnsureExecutorAsync(this.EdgeData).ConfigureAwait(false); } public async ValueTask ChaseAsync(object message) { - Executor target = await this.FindExecutorAsync().ConfigureAwait(false); + ExecutorBase target = await this.FindExecutorAsync().ConfigureAwait(false); if (target.CanHandle(message.GetType())) { return await target.ExecuteAsync(message, this.WorkflowContext) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs index 71ccc6d1d1..7b4786cd22 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs @@ -15,8 +15,8 @@ namespace Microsoft.Agents.Workflows.Execution; internal class LocalRunnerContext : IRunnerContext { private StepContext _nextStep = new(); - private readonly Dictionary> _executorProviders; - private readonly Dictionary _executors = new(); + private readonly Dictionary> _executorProviders; + private readonly Dictionary _executors = new(); private readonly Dictionary _externalRequests = new(); public LocalRunnerContext(Workflow workflow, ILogger? logger = null) @@ -24,7 +24,7 @@ public LocalRunnerContext(Workflow workflow, ILogger? logger = null) this._executorProviders = Throw.IfNull(workflow).ExecutorProviders; } - public async ValueTask EnsureExecutorAsync(string executorId) + public async ValueTask EnsureExecutorAsync(string executorId) { if (!this._executors.TryGetValue(executorId, out var executor)) { diff --git a/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs index 428fec3491..7c11ca15ee 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.Workflows; /// -/// A tagged union representing an object that can function like an in a , +/// A tagged union representing an object that can function like an in a , /// or a reference to one by ID. /// public sealed class ExecutorIsh : @@ -47,14 +47,14 @@ public enum Type public Type ExecutorType { get; init; } private readonly string? _idValue; - private readonly Executor? _executorValue; + private readonly ExecutorBase? _executorValue; internal readonly InputPort? _inputPortValue; private readonly AIAgent? _aiAgentValue; /// /// Initializes a new instance of the class as an unbound reference by ID. /// - /// A unique identifier for an in the + /// A unique identifier for an in the public ExecutorIsh(string id) { this.ExecutorType = Type.Unbound; @@ -65,7 +65,7 @@ public ExecutorIsh(string id) /// Initializes a new instance of the ExecutorIsh class using the specified executor. /// /// The executor instance to be wrapped. - public ExecutorIsh(Executor executor) + public ExecutorIsh(ExecutorBase executor) { this.ExecutorType = Type.Executor; this._executorValue = Throw.IfNull(executor); @@ -104,10 +104,10 @@ public ExecutorIsh(AIAgent aiAgent) }; /// - /// Gets an that can be used to obtain an instance + /// Gets an that can be used to obtain an instance /// corresponding to this . /// - public ExecutorProvider ExecutorProvider => this.ExecutorType switch + public ExecutorProvider ExecutorProvider => this.ExecutorType switch { Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), Type.Executor => () => this._executorValue!, @@ -117,10 +117,10 @@ public ExecutorIsh(AIAgent aiAgent) }; /// - /// Defines an implicit conversion from an instance to an object. + /// Defines an implicit conversion from an instance to an object. /// - /// The instance to convert to . - public static implicit operator ExecutorIsh(Executor executor) => new(executor); + /// The instance to convert to . + public static implicit operator ExecutorIsh(ExecutorBase executor) => new(executor); /// /// Defines an implicit conversion from an to an instance. diff --git a/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj b/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj index 4cd49dd698..4af22a5e8b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj @@ -9,6 +9,8 @@ true true + true + diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs index 15129858a6..ab29118d03 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.Workflows.Specialized; -internal class AIAgentHostExecutor : Executor, IMessageHandler> +internal class AIAgentHostExecutor : Executor, IMessageHandler> { private AIAgent Agent { get; set; } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs index a1a6099572..2cabca3bda 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs @@ -7,19 +7,21 @@ namespace Microsoft.Agents.Workflows.Specialized; -internal class OutputSink : Executor +internal interface IOutputSink { - public TResult? Result { get; protected set; } = default; - - internal OutputSink(string? id = null) : base(id) - { } + TResult? Result { get; } } -internal class OutputCollectorExecutor : OutputSink, IMessageHandler +internal class OutputCollectorExecutor : + Executor>, + IMessageHandler, + IOutputSink { private readonly StreamingAggregator _aggregator; private readonly Func? _completionCondition; + public TResult? Result { get; private set; } + public OutputCollectorExecutor(StreamingAggregator aggregator, Func? completionCondition = null, string? id = null) : base(id) { this._aggregator = Throw.IfNull(aggregator); diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInputExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInputExecutor.cs index 9cd3a8e2c9..20b5968639 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInputExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInputExecutor.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.Workflows.Specialized; -internal class RequestInputExecutor : Executor, IMessageHandler, IMessageHandler +internal class RequestInputExecutor : Executor, IMessageHandler, IMessageHandler { private InputPort Port { get; } private IExternalRequestSink? RequestSink { get; set; } diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs index da80121a36..e005d08caa 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs @@ -14,7 +14,7 @@ namespace Microsoft.Agents.Workflows; /// The executor type. /// A new instance. public delegate TExecutor ExecutorProvider() - where TExecutor : Executor; + where TExecutor : ExecutorBase; /// /// Provides a builder for constructing and configuring a workflow by defining executors and the connections between @@ -31,7 +31,7 @@ private record struct EdgeId(string SourceId, string TargetId) public override string ToString() => $"{this.SourceId} -> {this.TargetId}"; } - private readonly Dictionary> _executors = new(); + private readonly Dictionary> _executors = new(); private readonly Dictionary> _edges = new(); private readonly HashSet _unboundExecutors = new(); private readonly HashSet _conditionlessEdges = new(); @@ -50,7 +50,7 @@ public WorkflowBuilder(ExecutorIsh start) private ExecutorIsh Track(ExecutorIsh executorish) { - ExecutorProvider provider = executorish.ExecutorProvider; + ExecutorProvider provider = executorish.ExecutorProvider; // If the executor is unbound, create an entry for it, unless it already exists. // Otherwise, update the entry for it, and remove the unbound tag @@ -75,7 +75,7 @@ private ExecutorIsh Track(ExecutorIsh executorish) return executorish; } - private void UpdateExecutor(string id, ExecutorProvider provider) + private void UpdateExecutor(string id, ExecutorProvider provider) { this._executors[id] = provider; } @@ -86,7 +86,7 @@ private void UpdateExecutor(string id, ExecutorProvider provider) /// The executor instance to bind. The executor must exist in the workflow and not be already bound. /// The current instance, enabling fluent configuration. /// Thrown if the specified executor is already bound or does not exist in the workflow. - public WorkflowBuilder BindExecutor(Executor executor) + public WorkflowBuilder BindExecutor(ExecutorBase executor) { if (!this._unboundExecutors.Contains(executor.Id)) { @@ -216,14 +216,14 @@ public Workflow Build() } // Grab the start node, and make sure it has the right type? - if (!this._executors.TryGetValue(this._startExecutorId, out ExecutorProvider? startProvider)) + if (!this._executors.TryGetValue(this._startExecutorId, out ExecutorProvider? startProvider)) { // TODO: This should never be able to be hit throw new InvalidOperationException($"Start executor with ID '{this._startExecutorId}' is not bound."); } // TODO: Delay-instantiate the start executor, and ensure it is of type T. - Executor startExecutor = startProvider(); + ExecutorBase startExecutor = startProvider(); if (!startExecutor.InputTypes.Any(t => t.IsAssignableFrom(typeof(T)))) { diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs index 5430e30d94..fde763b3b0 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.Workflows.UnitTests; -public class BaseTestExecutor : Executor +public class BaseTestExecutor : Executor where TActual : Executor { protected void OnInvokedHandler() { @@ -21,7 +21,7 @@ public bool InvokedHandler } = false; } -public class DefaultHandler : BaseTestExecutor, IMessageHandler +public class DefaultHandler : BaseTestExecutor, IMessageHandler { public ValueTask HandleAsync(object message, IWorkflowContext context) { @@ -36,7 +36,7 @@ public Func Handler } = (message, context) => default; } -public class TypedHandler : BaseTestExecutor, IMessageHandler +public class TypedHandler : BaseTestExecutor>, IMessageHandler { public ValueTask HandleAsync(TInput message, IWorkflowContext context) { @@ -51,7 +51,7 @@ public Func Handler } = (message, context) => default; } -public class TypedHandlerWithOutput : BaseTestExecutor, IMessageHandler +public class TypedHandlerWithOutput : BaseTestExecutor>, IMessageHandler { public ValueTask HandleAsync(TInput message, IWorkflowContext context) { @@ -67,7 +67,7 @@ public Func> Handler public class RoutingReflectionTests { - private async ValueTask RunTestReflectAndRouteMessageAsync(BaseTestExecutor executor, TInput? input = default) where TInput : new() + private async ValueTask RunTestReflectAndRouteMessageAsync(BaseTestExecutor executor, TInput? input = default) where TInput : new() where TE : Executor { MessageRouter router = executor.Router; @@ -88,7 +88,7 @@ public async Task Test_ReflectAndExecute_DefaultHandlerAsync() { DefaultHandler executor = new(); - CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor); + CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor); Assert.NotNull(result); Assert.True(result.IsSuccess); @@ -102,7 +102,7 @@ public async Task Test_ReflectAndExecute_HandlerReturnsVoidAsync() { TypedHandler executor = new(); - CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor, 3); + CallResult? result = await this.RunTestReflectAndRouteMessageAsync>(executor, 3); Assert.NotNull(result); Assert.True(result.IsSuccess); @@ -123,7 +123,7 @@ public async Task Test_ReflectAndExecute_HandlerReturnsValueAsync() }; const string Expected = "3"; - CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor, int.Parse(Expected)); + CallResult? result = await this.RunTestReflectAndRouteMessageAsync>(executor, int.Parse(Expected)); Assert.NotNull(result); Assert.True(result.IsSuccess); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs index 5af83b752a..cf74362684 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs @@ -32,7 +32,7 @@ public static async ValueTask RunAsync(TextWriter writer) } } -internal sealed class UppercaseExecutor() : Executor("UppercaseExecutor"), IMessageHandler +internal sealed class UppercaseExecutor() : Executor("UppercaseExecutor"), IMessageHandler { public async ValueTask HandleAsync(string message, IWorkflowContext context) { @@ -43,7 +43,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext cont } } -internal sealed class ReverseTextExecutor() : Executor("ReverseTextExecutor"), IMessageHandler +internal sealed class ReverseTextExecutor() : Executor("ReverseTextExecutor"), IMessageHandler { public async ValueTask HandleAsync(string message, IWorkflowContext context) { diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs index c6f52d7166..62365d6beb 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs @@ -46,7 +46,7 @@ public static async ValueTask RunAsync(TextWriter writer, string input = } } -internal sealed class DetectSpamExecutor : Executor, IMessageHandler +internal sealed class DetectSpamExecutor : Executor, IMessageHandler { public string[] SpamKeywords { get; } @@ -68,7 +68,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext contex } } -internal sealed class RespondToMessageExecutor : Executor, IMessageHandler +internal sealed class RespondToMessageExecutor : Executor, IMessageHandler { public const string ActionResult = "Message processed successfully."; @@ -87,7 +87,7 @@ await context.AddEventAsync(new WorkflowCompletedEvent { Data = RespondToMessage } } -internal sealed class RemoveSpamExecutor : Executor, IMessageHandler +internal sealed class RemoveSpamExecutor : Executor, IMessageHandler { public const string ActionResult = "Spam message removed."; diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs index ead24833e4..5bfabb92f2 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs @@ -50,7 +50,7 @@ internal enum NumberSignal Matched } -internal sealed class GuessNumberExecutor : Executor, IMessageHandler +internal sealed class GuessNumberExecutor : Executor, IMessageHandler { public int LowerBound { get; private set; } public int UpperBound { get; private set; } @@ -87,7 +87,7 @@ await context.AddEventAsync(new WorkflowCompletedEvent { Data = $"Guessed the nu } } -internal sealed class JudgeExecutor : Executor, IMessageHandler +internal sealed class JudgeExecutor : Executor, IMessageHandler { private readonly int _targetNumber; From c93249d2cea67d8004ea0b6e5bead7794a33fc98 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Fri, 8 Aug 2025 11:15:08 -0400 Subject: [PATCH 087/232] refactor: Simplify DynamicallyAccessedMembers annotations --- dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs | 7 +++---- .../Core/MessageHandlerInfo.cs | 7 ++++--- .../Core/ReflectionExtensions.cs | 9 +++++++++ .../Core/RouteBuilderExtensions.cs | 10 ++++------ 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs index 284f656a2d..c6a8f7825c 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs @@ -151,10 +151,9 @@ ValueTask IAsyncDisposable.DisposeAsync() /// This is used to reflectively discover handlers for messages without violating ILTrim requirements. /// public class Executor< - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | - DynamicallyAccessedMemberTypes.NonPublicMethods | - DynamicallyAccessedMemberTypes.Interfaces)] -TExecutor + [DynamicallyAccessedMembers( + ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation) + ] TExecutor > : ExecutorBase where TExecutor : Executor { /// diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs index 2b9d55fca9..07a41ef5aa 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs @@ -117,9 +117,10 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext } public Func> Bind< - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | - DynamicallyAccessedMemberTypes.NonPublicMethods | - DynamicallyAccessedMemberTypes.Interfaces)] TExecutor> + [DynamicallyAccessedMembers( + ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation) + ] TExecutor + > (Executor executor, bool checkType = false) where TExecutor : Executor { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs index 8ec77241db..30ceb72f61 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Reflection; #if !NET @@ -10,6 +11,14 @@ namespace Microsoft.Agents.Workflows.Core; +internal static class ReflectionDemands +{ + internal const DynamicallyAccessedMemberTypes ReflectedMethods = DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods; + internal const DynamicallyAccessedMemberTypes ReflectedInterfaces = DynamicallyAccessedMemberTypes.Interfaces; + + internal const DynamicallyAccessedMemberTypes RuntimeInterfaceDiscoveryAndInvocation = ReflectedMethods | ReflectedInterfaces; +} + internal static class ReflectionExtensions { public static object? ReflectionInvoke(this MethodInfo method, object? target, params object?[] arguments) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs index 91d594686e..deebf89599 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs @@ -44,9 +44,7 @@ internal static bool IsMessageHandlerType(this Type type) => internal static class RouteBuilderExtensions { private static IEnumerable GetHandlerInfos( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | - DynamicallyAccessedMemberTypes.NonPublicMethods | - DynamicallyAccessedMemberTypes.Interfaces)] + [DynamicallyAccessedMembers(ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation)] this Type executorType) { // Handlers are defined by implementations of IMessageHandler or IMessageHandler @@ -79,9 +77,9 @@ private static IEnumerable GetHandlerInfos( } public static RouteBuilder ReflectHandlers< - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | - DynamicallyAccessedMemberTypes.NonPublicMethods | - DynamicallyAccessedMemberTypes.Interfaces)] TExecutor> + [DynamicallyAccessedMembers( + ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation) + ] TExecutor> (this RouteBuilder builder, Executor executor) where TExecutor : Executor { From 5c1c062075878ea39df7d8e3afc23e0ed42b89b1 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Fri, 8 Aug 2025 11:15:53 -0400 Subject: [PATCH 088/232] sample: Use static-Type construction of InputPort --- .../Sample/05_Simple_Workflow_ExternalRequest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs index 548790cc27..10412f377a 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs @@ -12,7 +12,7 @@ internal static class Step5EntryPoint { public static async ValueTask RunAsync(TextWriter writer, Func userGuessCallback) { - InputPort guessNumber = new("GuessNumber", typeof(NumberSignal), typeof(int)); + InputPort guessNumber = InputPort.Create("GuessNumber"); JudgeExecutor judge = new(42); // Let's say the target number is 42 Workflow workflow = new WorkflowBuilder(guessNumber) From b932cf14f9ea4fa2e5e5ff4f07d1808be66c7463 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Fri, 8 Aug 2025 12:23:18 -0400 Subject: [PATCH 089/232] feat: Support non-Streaming Run Mode --- .../Execution/LocalRunner.cs | 59 +++++-- .../Execution/Run.cs | 155 ++++++++++++++++++ ...mingExecutionHandle.cs => StreamingRun.cs} | 62 +++++-- .../Sample/02_Simple_Workflow_Condition.cs | 2 +- .../Sample/03_Simple_Workflow_Loop.cs | 2 +- .../05_Simple_Workflow_ExternalRequest.cs | 2 +- 6 files changed, 249 insertions(+), 33 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflows/Execution/Run.cs rename dotnet/src/Microsoft.Agents.Workflows/Execution/{StreamingExecutionHandle.cs => StreamingRun.cs} (74%) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs index 729b95bedb..3210169abc 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs @@ -86,24 +86,40 @@ private bool IsResponse(object message) /// /// Initiates an asynchronous streaming execution using the specified input. /// - /// The returned provides methods to observe and control + /// The returned provides methods to observe and control /// the ongoing streaming execution. The operation will continue until the streaming execution is finished or /// cancelled. - /// The input message to be processed as part of the streaming execution. + /// The input message to be processed as part of the streaming run. /// A that can be used to cancel the streaming operation. - /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming execution. - public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) + /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. + public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) { await this.RunContext.AddExternalMessageAsync(input).ConfigureAwait(false); - return new StreamingExecutionHandle(this); + return new StreamingRun(this); + } + + /// + /// Initiates a non-streaming execution of the workflow with the specified input. + /// + /// The workflow will run until its first halt, and the returned will capture + /// all outgoing events. Use the Run instance to resume execution with responses to outgoing events. + /// The input message to be processed as part of the run. + /// A that can be used to cancel the streaming operation. + /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. + public async ValueTask RunAsync(TInput input, CancellationToken cancellation = default) + { + StreamingRun streamingRun = await this.StreamAsync(input, cancellation).ConfigureAwait(false); + cancellation.ThrowIfCancellationRequested(); + + return await Run.CaptureStreamAsync(streamingRun, cancellation).ConfigureAwait(false); } bool ISuperStepRunner.HasUnservicedRequests => this.RunContext.HasUnservicedRequests; bool ISuperStepRunner.HasUnprocessedMessages => this.RunContext.NextStepHasActions; - //private StepContext? _currentStep = null; async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cancellation) { cancellation.ThrowIfCancellationRequested(); @@ -185,18 +201,35 @@ public LocalRunner(Workflow workflow) /// /// Initiates an asynchronous streaming execution for the specified input. /// - /// The returned can be used to retrieve results + /// The returned can be used to retrieve results /// as they become available. If the operation is cancelled via the token, the /// streaming execution will be terminated. - /// The input value to be processed by the streaming execution. + /// The input value to be processed by the streaming run. /// A that can be used to cancel the streaming operation. - /// A that provides access to the results of the streaming - /// execution. - public async ValueTask> StreamAsync(TInput input, CancellationToken cancellation = default) + /// A that provides access to the results of the streaming + /// run. + public async ValueTask> StreamAsync(TInput input, CancellationToken cancellation = default) { await this._innerRunner.EnqueueMessageAsync(input).ConfigureAwait(false); - return new StreamingExecutionHandle(this); + return new StreamingRun(this); + } + + /// + /// Initiates a non-streaming execution of the workflow with the specified input. + /// + /// The workflow will run until its first halt, and the returned will capture + /// all outgoing events. Use the Run instance to resume execution with responses to outgoing events. + /// The input message to be processed as part of the run. + /// A that can be used to cancel the streaming operation. + /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. + public async ValueTask RunAsync(TInput input, CancellationToken cancellation = default) + { + StreamingRun streamingRun = await this.StreamAsync(input, cancellation).ConfigureAwait(false); + cancellation.ThrowIfCancellationRequested(); + + return await Run.CaptureStreamAsync(streamingRun, cancellation).ConfigureAwait(false); } /// diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/Run.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/Run.cs new file mode 100644 index 0000000000..8da8ccceeb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/Run.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +/// +/// Specifies the current operational state of a workflow run. +/// +public enum RunStatus +{ + /// + /// The run has halted, has no outstanding requets, but has not received a . + /// + Idle, + + /// + /// The run has halted, and has at least one outstanding . + /// + PendingRequests, + + /// + /// The run has halted after receiving a . + /// + Completed, + + /// + /// The workflow is currently running, and may receive events or requests. + /// + Running +} + +/// +/// Represents a workflow run that tracks execution status and emitted workflow events, supporting resumption +/// with responses to . +/// +public class Run +{ + internal static async ValueTask CaptureStreamAsync(StreamingRun run, CancellationToken cancellation = default) + { + Run result = new(run); + await result.RunToNextHaltAsync(cancellation).ConfigureAwait(false); + return result; + } + + private readonly List _eventSink = new(); + private readonly StreamingRun _streamingRun; + internal Run(StreamingRun streamingRun) + { + this._streamingRun = streamingRun; + } + + internal async ValueTask RunToNextHaltAsync(CancellationToken cancellation = default) + { + bool hadEvents = false; + bool hadCompletion = false; + this.Status = RunStatus.Running; + await foreach (WorkflowEvent evt in this._streamingRun.WatchStreamAsync(blockOnPendingRequest: false, cancellation).ConfigureAwait(false)) + { + hadEvents = true; + if (evt is WorkflowCompletedEvent) + { + hadCompletion = true; + } + + this._eventSink.Add(evt); + } + + // TODO: bookmark every halt for history visualization? + + this.Status = + hadCompletion + ? RunStatus.Completed + : this._streamingRun.HasUnservicedRequests + ? RunStatus.PendingRequests + : RunStatus.Idle; + + return hadEvents; + } + + /// + /// Gets the current execution status of the workflow run. + /// + public RunStatus Status { get; private set; } + + /// + /// Gets all events emitted by the workflow. + /// + public IEnumerable OutgoingEvents => this._eventSink; + + private int _lastBookmark = 0; + + /// + /// Gets all events emitted by the workflow since the last access to . + /// + public IEnumerable NewEvents + { + get + { + if (this._lastBookmark >= this._eventSink.Count) + { + return []; + } + + int currentBookmark = this._lastBookmark; + this._lastBookmark = this._eventSink.Count; + + return this._eventSink.Skip(currentBookmark); + } + } + + /// + /// Resume execution of the workflow with the provided external responses. + /// + /// A that can be used to cancel the workflow execution. + /// An array of objects to send to the workflow. + /// true if the workflow had any output events, false otherwise. + public async ValueTask ResumeAsync(CancellationToken cancellation = default, params ExternalResponse[] responses) + { + foreach (ExternalResponse response in responses) + { + await this._streamingRun.SendResponseAsync(response).ConfigureAwait(false); + } + + return await this.RunToNextHaltAsync(cancellation).ConfigureAwait(false); + } +} + +/// +/// Represents a workflow run that tracks execution status and emitted workflow events, supporting resumption +/// with responses to , and retrieval of the running output of the workflow. +/// +/// The type of the workflow output. +public sealed class Run : Run +{ + internal static async ValueTask> CaptureStreamAsync(StreamingRun run, CancellationToken cancellation = default) + { + Run result = new(run); + await result.RunToNextHaltAsync(cancellation).ConfigureAwait(false); + return result; + } + + private readonly StreamingRun _streamingRun; + private Run(StreamingRun streamingRun) : base(streamingRun) + { + this._streamingRun = streamingRun; + } + + /// + public TResult? RunningOutput => this._streamingRun.RunningOutput; +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingRun.cs similarity index 74% rename from dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingExecutionHandle.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingRun.cs index 6a93989a85..dfb274d6e9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingRun.cs @@ -11,15 +11,21 @@ namespace Microsoft.Agents.Workflows.Execution; /// -/// Provides a handle for managing and interacting with a streaming workflow execution, enabling asynchronous response -/// delivery and event monitoring. +/// A run instance supporting a streaming form of receiving workflow events, and providing +/// a mechanism to send responses back to the workflow. /// -public class StreamingExecutionHandle +public class StreamingRun { private TaskCompletionSource? _waitForResponseSource = null; private readonly ISuperStepRunner _stepRunner; - internal StreamingExecutionHandle(ISuperStepRunner stepRunner) + /// + /// Gets a value indicating whether there are any outstanding s for which a + /// has not been sent. + /// + public bool HasUnservicedRequests => this._stepRunner.HasUnservicedRequests; + + internal StreamingRun(ISuperStepRunner stepRunner) { this._stepRunner = Throw.IfNull(stepRunner); } @@ -49,7 +55,13 @@ public ValueTask SendResponseAsync(ExternalResponse response) /// requested, the stream will end and no further events will be yielded. /// An asynchronous stream of objects representing significant workflow state changes. /// The stream ends when the workflow completes or when cancellation is requested. - public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancellation] CancellationToken cancellation = default) + public IAsyncEnumerable WatchStreamAsync( + CancellationToken cancellation = default) + => this.WatchStreamAsync(blockOnPendingRequest: true, cancellation); + + internal async IAsyncEnumerable WatchStreamAsync( + bool blockOnPendingRequest, + [EnumeratorCancellation] CancellationToken cancellation = default) { List eventSink = new(); @@ -61,6 +73,10 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell { // Drain SuperSteps while there are steps to run await this._stepRunner.RunSuperStepAsync(cancellation).ConfigureAwait(false); + if (cancellation.IsCancellationRequested) + { + yield break; // Exit if cancellation is requested + } bool hadCompletionEvent = false; List outputEvents = Interlocked.Exchange(ref eventSink, new()); @@ -68,6 +84,11 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell { yield return raisedEvent; + if (cancellation.IsCancellationRequested) + { + yield break; // Exit if cancellation is requested + } + // TODO: Do we actually want to interpret this as a termination request? if (raisedEvent is WorkflowCompletedEvent) { @@ -84,7 +105,8 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell // If we do not have any actions to take on the Workflow, but have unprocessed // requests, wait for the responses to come in before exiting out of the workflow // execution. - if (!this._stepRunner.HasUnprocessedMessages && + if (blockOnPendingRequest && + !this._stepRunner.HasUnprocessedMessages && this._stepRunner.HasUnservicedRequests) { if (this._waitForResponseSource == null) @@ -92,6 +114,11 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell this._waitForResponseSource = new(); } + using CancellationTokenRegistration registration = cancellation.Register(() => + { + this._waitForResponseSource?.SetResult(new()); + }); + await this._waitForResponseSource.Task.ConfigureAwait(false); this._waitForResponseSource = null; } @@ -110,14 +137,15 @@ void OnWorkflowEvent(object? sender, WorkflowEvent e) } /// -/// Represents a handle for managing and retrieving the result of a streaming execution operation. +/// A run instance supporting a streaming form of receiving workflow events, providing +/// a mechanism to send responses back to the workflow, and retrieving the result of workflow execution. /// -/// -public class StreamingExecutionHandle : StreamingExecutionHandle +/// The type of the workflow output. +public class StreamingRun : StreamingRun { private readonly IRunnerWithOutput _resultSource; - internal StreamingExecutionHandle(IRunnerWithOutput runner) + internal StreamingRun(IRunnerWithOutput runner) : base(Throw.IfNull(runner.StepRunner)) { this._resultSource = runner; @@ -128,9 +156,9 @@ internal StreamingExecutionHandle(IRunnerWithOutput runner) } /// -/// Provides extension methods for processing and executing workflows using streaming execution handles. +/// Provides extension methods for processing and executing workflows using streaming runs. /// -public static class ExecutionHandleExtensions +public static class StreamingRunExtensions { /// /// Processes all events from the workflow execution stream until completion. @@ -138,14 +166,14 @@ public static class ExecutionHandleExtensions /// This method continuously monitors the workflow execution stream provided by and invokes the for each event. If the callback returns a /// non- response, the response is sent back to the workflow using the handle. - /// The representing the workflow execution stream to monitor. + /// The representing the workflow execution stream to monitor. /// An optional callback function invoked for each received from the stream. /// The callback can return a response object to be sent back to the workflow, or if no response /// is required. /// A to observe while waiting for events. /// A that represents the asynchronous operation. The task completes when the workflow /// execution stream is fully processed. - public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) + public static async ValueTask RunToCompletionAsync(this StreamingRun handle, Func? eventCallback = null, CancellationToken cancellation = default) { Throw.IfNull(handle); @@ -160,21 +188,21 @@ public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle } /// - /// Executes the workflow associated with the specified until it + /// Executes the workflow associated with the specified until it /// completes and returns the final result. /// /// This method ensures that the workflow runs to completion before returning the result. If an /// is provided, it will be invoked for each event emitted during the workflow's /// execution, allowing for custom event handling. /// The type of the result produced by the workflow. - /// The representing the workflow to execute. + /// The representing the workflow to execute. /// An optional callback function that is invoked for each /// emitted during execution. The callback can process the event and return an object, or /// if no response is required. /// A that can be used to cancel the workflow execution. /// A that represents the asynchronous operation. The task's result is the final /// result of the workflow execution. - public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) + public static async ValueTask RunToCompletionAsync(this StreamingRun handle, Func? eventCallback = null, CancellationToken cancellation = default) { Throw.IfNull(handle); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs index 62365d6beb..8b479fd0c5 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs @@ -26,7 +26,7 @@ public static async ValueTask RunAsync(TextWriter writer, string input = LocalRunner runner = new(workflow); - StreamingExecutionHandle handle = await runner.StreamAsync(input).ConfigureAwait(false); + StreamingRun handle = await runner.StreamAsync(input).ConfigureAwait(false); await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) { switch (evt) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs index 5bfabb92f2..5180bae49b 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs @@ -21,7 +21,7 @@ public static async ValueTask RunAsync(TextWriter writer) .Build(); LocalRunner runner = new(workflow); - StreamingExecutionHandle handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); + StreamingRun handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) { diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs index 10412f377a..d7d24fbbe7 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs @@ -21,7 +21,7 @@ public static async ValueTask RunAsync(TextWriter writer, Func(judge, ComputeStreamingOutput, (NumberSignal s, string? _) => s == NumberSignal.Matched); LocalRunner runner = new(workflow); - StreamingExecutionHandle handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); + StreamingRun handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) { From 67110603cdafca5005309e58e4656d6efa91e3d3 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Fri, 8 Aug 2025 12:33:36 -0400 Subject: [PATCH 090/232] test: Add test for non-streaming execution --- .../Sample/01_Simple_Workflow_Sequential.cs | 1 + .../Sample/01a_Simple_Workflow_Sequential.cs | 37 +++++++++++++++++++ .../SampleSmokeTest.cs | 18 +++++++++ 3 files changed, 56 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs index cf74362684..67c515bdac 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs @@ -52,6 +52,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext cont string result = new(charArray); await context.SendMessageAsync(result).ConfigureAwait(false); + await context.AddEventAsync(new WorkflowCompletedEvent() { Data = result }).ConfigureAwait(false); return result; } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs new file mode 100644 index 0000000000..449b171517 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.IO; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; + +namespace Microsoft.Agents.Workflows.Sample; + +internal static class Step1aEntryPoint +{ + public static async ValueTask RunAsync(TextWriter writer) + { + UppercaseExecutor uppercase = new(); + ReverseTextExecutor reverse = new(); + + WorkflowBuilder builder = new(uppercase); + builder.AddEdge(uppercase, reverse); + + Workflow workflow = builder.Build(); + LocalRunner runner = new(workflow); + + //var handle = await runner.StreamAsync("Hello, World!").ConfigureAwait(false); + + Run run = await runner.RunAsync("Hello, World!").ConfigureAwait(false); + + Assert.Equal(RunStatus.Completed, run.Status); + + foreach (WorkflowEvent evt in run.NewEvents) + { + if (evt is ExecutorCompleteEvent executorComplete) + { + writer.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}"); + } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs index 12fe7f8f37..dc08df598e 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs @@ -28,6 +28,24 @@ public async Task Test_RunSample_Step1Async() ); } + [Fact] + public async Task Test_RunSample_Step1aAsync() + { + using StringWriter writer = new(); + + await Step1aEntryPoint.RunAsync(writer); + + string result = writer.ToString(); + string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); + + const string INPUT = "Hello, World!"; + + Assert.Collection(lines, + line => Assert.Contains($"UppercaseExecutor: {INPUT.ToUpperInvariant()}", line), + line => Assert.Contains($"ReverseTextExecutor: {new string(INPUT.ToUpperInvariant().Reverse().ToArray())}", line) + ); + } + [Fact] public async Task Test_RunSample_Step2Async() { From 9488cb2aefc90af530949efaeed8a4902b33b150 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 11 Aug 2025 15:18:07 -0400 Subject: [PATCH 091/232] refactor: Remove unused types --- .../Core/Message.cs | 117 ------------------ 1 file changed, 117 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/Message.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Message.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Message.cs deleted file mode 100644 index e94fad7848..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Message.cs +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using Microsoft.Shared.Diagnostics; -using ExecutorId = string; -// TODO: Unclear whether this should be forcibly a serializable type. -using MetadataValueT = object; -using RetryExceptionT = System.InvalidOperationException; -using TopicId = string; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// . -/// -public record MessageMetadata -{ - /// - /// . - /// - public string CorrelationId { get; init; } = Guid.NewGuid().ToString(); - /// - /// . - /// - public ExecutorId? SourceId { get; init; } - /// - /// . - /// - public ExecutorId? TargetId { get; init; } - /// - /// . - /// - public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; - /// - /// . - /// - public string IsoTimestamp => this.Timestamp.ToString("o"); - /// - /// . - /// - public TopicId? Topic { get; init; } - /// - /// . - /// - public int Priority { get; init; } = 0; // Higher values indicate higher priority. - /// - /// . - /// - public TimeSpan? Timeout { get; init; } = null; - - /// - /// . - /// - public int Retries { get; init; } = 0; - /// - /// . - /// - public int MaxRetries { get; init; } = 3; - - /// - /// . - /// - public IDictionary CustomData { get; init; } = new Dictionary(); -} - -/// -/// . -/// -/// -public record Message -{ - /// - /// . - /// - public TContent Content { get; init; } - - /// - /// . - /// - public Type ContentType => typeof(TContent); - - /// - /// . - /// - public MessageMetadata Metadata { get; init; } - - /// - /// . - /// - /// - /// - /// - public Message(TContent content, MessageMetadata metadata) - { - this.Content = Throw.IfNull(content); - this.Metadata = Throw.IfNull(metadata); - } - - /// - /// Creates a new message instance for a new target. - /// - /// The identifier of the target executor to associate with the message. - /// A new instance with the updated target identifier. - public Message WithTarget(ExecutorId targetId) - => this with { Metadata = this.Metadata with { TargetId = targetId } }; - - /// - /// Create a copy of this message for next retry attempt. - /// - /// A copy of this message with incremented retry count. - /// If the maximum number of retries has been exceeded. - public Message WithRetry() - => this.Metadata.Retries < this.Metadata.MaxRetries - ? this with { Metadata = this.Metadata with { Retries = this.Metadata.Retries + 1 } } - : throw new RetryExceptionT($"Maximum retries ({this.Metadata.MaxRetries}) exceeded for message with ID '{this.Metadata.CorrelationId}'."); -} From 5fb949f4659c9411cfc0468793341a6cd842cd81 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 11 Aug 2025 15:23:53 -0400 Subject: [PATCH 092/232] refactor: Simplify Event and EdgeData type hierarchies --- .../Microsoft.Agents.Workflows/Core/Edge.cs | 100 ++++++++--------- .../Microsoft.Agents.Workflows/Core/Events.cs | 103 ++++++++++++------ .../Core/Executor.cs | 53 ++------- .../Execution/FanInEdgeState.cs | 29 ++--- .../Execution/FanOutEdgeRunner.cs | 4 +- .../Specialized/OutputCollectorExecutor.cs | 2 +- .../WorkflowBuilder.cs | 21 ++-- .../ReflectionSmokeTest.cs | 6 - .../Sample/01_Simple_Workflow_Sequential.cs | 2 +- .../Sample/02_Simple_Workflow_Condition.cs | 4 +- .../Sample/03_Simple_Workflow_Loop.cs | 2 +- 11 files changed, 153 insertions(+), 173 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs index 878ac98a09..e2c6bdbebd 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs @@ -13,85 +13,75 @@ namespace Microsoft.Agents.Workflows.Core; /// Represents a directed edge between two nodes, optionally associated with a condition that determines whether the /// edge is active. /// -/// The id of the source executor node. -/// The id of the target executor node. -/// A predicate determining whether the edge is active for a given message. -public record DirectEdgeData( - string SourceId, - string SinkId, - PredicateT? Condition = null) +/// The id of the source executor node. +/// The id of the target executor node. +/// A predicate determining whether the edge is active for a given message. +public sealed class DirectEdgeData(string sourceId, string sinkId, PredicateT? condition = null) { /// - /// Converts a instance to an . + /// The Id of the source node. /// - /// The to convert t. - public static implicit operator Edge(DirectEdgeData data) - { - return new Edge(Throw.IfNull(data)); - } + public string SourceId => sourceId; + + /// + /// The Id of the destination node. + /// + public string SinkId => sinkId; + + /// + /// An optional predicate determining whether the edge is active for a given message. If , + /// the edge is always active when a message is generated by the source. + /// + public PredicateT? Condition => condition; } /// /// Represents a connection from a single node to a set of nodes, optionally associated with a paritition selector /// function which maps incoming messages to a subset of the target set. /// -/// The id of the source executor node. -/// A list of ids of the target executor nodes. -/// A function that maps an incoming message to a subset of the target executor nodes. -public record FanOutEdgeData( - string SourceId, - List SinkIds, - PartitionerT? Partitioner = null) +/// The id of the source executor node. +/// A list of ids of the target executor nodes. +/// A function that maps an incoming message to a subset of the target executor nodes. +public sealed class FanOutEdgeData( + string sourceId, + List sinkIds, + PartitionerT? partitioner = null) { /// - /// Converts a instance to an . + /// The Id of the source node. /// - /// The to convert. - public static implicit operator Edge(FanOutEdgeData data) - { - return new Edge(data); - } -} + public string SourceId => sourceId; -/// -/// Specifies the condition under which a fan-in operation is triggered in a workflow. -/// Use to trigger the operation when all incoming edges have data, or -/// to trigger when any incoming edge has data. -/// -public enum FanInTrigger -{ /// - /// Trigger when all incoming edges have data. + /// The ordered list of Ids of the destination nodes. /// - WhenAll, + public List SinkIds => sinkIds; + /// - /// Trigger when any incoming edge has data. + /// A function mapping an incoming message to a subset of the target executor nodes (or optionally all of them). + /// If , all destination nodes are selected. /// - WhenAny + public PartitionerT? PartitionAssigner => partitioner; } /// -/// Represents a connection from a set of nodes to a single node. It can trigger either when all edges have data -/// or when any of them have data. +/// Represents a connection from a set of nodes to a single node. It will trigger either when all edges have data. /// -/// An enumeration of ids of the source executor nodes. -/// The id of the target executor node. -/// The that determines when the fan-in edge is activated. -public record FanInEdgeData( - IEnumerable SourceIds, - string SinkId, - FanInTrigger Trigger = FanInTrigger.WhenAll) +/// An enumeration of ids of the source executor nodes. +/// The id of the target executor node. +public sealed class FanInEdgeData(List sourceIds, string sinkId) { - internal Guid UniqueKey { get; } = Guid.NewGuid(); + /// + /// The ordered list of Ids of the source nodes. + /// + public List SourceIds => sourceIds; /// - /// Converts a instance to an . + /// The Id of the destination node. /// - /// The to convert. - public static implicit operator Edge(FanInEdgeData data) - { - return new Edge(data); - } + public string SinkId => sinkId; + + internal Guid UniqueKey { get; } = Guid.NewGuid(); } /// @@ -103,7 +93,7 @@ public static implicit operator Edge(FanInEdgeData data) /// additional information relevant to the edge, and its concrete type depends on the value of , functioning as a tagged union. /// -public class Edge +public sealed class Edge { /// /// Specified the edge type. diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs index 0581b12ca6..8aeb50e4b8 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs @@ -1,19 +1,37 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using Microsoft.Extensions.AI.Agents; -using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; /// /// Base class for -scoped events. /// -public record WorkflowEvent(object? Data = null); +public class WorkflowEvent(object? data = null) +{ + /// + /// Optional payload + /// + public object? Data => data; + + /// + public override string ToString() + { + if (this.Data != null) + { + return $"{this.GetType().Name}(Data: {this.Data.GetType()} = {this.Data})"; + } + + return $"{this.GetType().Name}()"; + } +} /// /// Event triggered when a workflow starts execution. /// -public record WorkflowStartedEvent : WorkflowEvent; +/// The message triggering the start of workflow execution. +public sealed class WorkflowStartedEvent(object? message = null) : WorkflowEvent(data: message); /// /// Event triggered when a workflow completes execution. @@ -22,66 +40,81 @@ public record WorkflowStartedEvent : WorkflowEvent; /// The user is expected to raise this event from a terminating , or to build /// the workflow with output capture using . /// -public record WorkflowCompletedEvent : WorkflowEvent; +/// The result of the execution of the workflow. +public sealed class WorkflowCompletedEvent(object? result = null) : WorkflowEvent(data: result); + +/// +/// Event triggered when a workflow encounters an error. +/// +/// +/// Optionally, the representing the error. +/// +public sealed class WorkflowErrorEvent(Exception? e) : WorkflowEvent(e); + +/// +/// Event triggered when a workflow encounters a warning-condition. +/// +/// The warning message. +public sealed class WorkflowWarningEvent(string message) : WorkflowEvent(message); /// /// Event triggered when a workflow executor request external information. /// -public record RequestInputEvent(ExternalRequest Request) : WorkflowEvent; +public sealed class RequestInputEvent(ExternalRequest request) : WorkflowEvent(request) +{ + /// + /// The request to be serviced and data payload associated with it. + /// + public ExternalRequest Request => request; +} /// /// Base class for -scoped events. /// -public record ExecutorEvent : WorkflowEvent +public class ExecutorEvent(string executorId, object? data) : WorkflowEvent(data) { /// /// The identifier of the executor that generated this event. /// - public string ExecutorId { get; } + public string ExecutorId => executorId; - /// - /// Initializes a new instance of the class with the specified executor identifier and - /// optional event data. - /// - /// The unique identifier of the executor associated with this event. Cannot be null. - /// Optional event data to associate with the event. May be null if no additional data is required. - public ExecutorEvent(string executorId, object? data = null) : base(data) + /// + public override string ToString() { - this.ExecutorId = Throw.IfNull(executorId); + if (this.Data != null) + { + return $"{this.GetType().Name}(Executor = {this.ExecutorId}, Data: {this.Data.GetType()} = {this.Data})"; + } + + return $"{this.GetType().Name}(Executor = {this.ExecutorId})"; } } /// /// Event triggered when an executor handler is invoked. /// -public record ExecutorInvokeEvent : ExecutorEvent -{ - /// - /// Initializes a new instance of the class. - /// - public ExecutorInvokeEvent(string executorId, object? data = null) : base(executorId, data) - { - } -} +/// The unique identifier of the executor being invoked. +/// The invocation message. +public sealed class ExecutorInvokeEvent(string executorId, object message) : ExecutorEvent(executorId, data: message); /// /// Event triggered when an executor handler has completed. /// -public record ExecutorCompleteEvent : ExecutorEvent -{ - /// - /// Initializes a new instance of the class to signal that an executor has - /// completed its operation. - /// - /// The unique identifier of the executor that has completed. Cannot be null or empty. - /// The result produced by the executor upon completion, or null if no result is available. - public ExecutorCompleteEvent(string executorId, object? result = null) : base(executorId, result) { } -} +/// The unique identifier of the executor that has completed. +/// The result produced by the executor upon completion, or null if no result is available. +public sealed class ExecutorCompleteEvent(string executorId, object? result) : ExecutorEvent(executorId, data: result); + +/// +/// Event triggered when an executor handler fails. +/// +/// The unique identifier of the executor that has failed. +/// The exception representing the error. +public sealed class ExecutorFailureEvent(string executorId, Exception? err) : ExecutorEvent(executorId, data: err); /// /// Event triggered when an agent run is completed. /// -public record AgentRunEvent : ExecutorEvent +public class AgentRunEvent : ExecutorEvent { /// /// Initializes a new instance of the class. diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs index c6a8f7825c..5b0e4d3f74 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs @@ -13,15 +13,13 @@ namespace Microsoft.Agents.Workflows.Core; /// A component that processes messages in a . /// [DebuggerDisplay("{GetType().Name}{Id}")] -public abstract class ExecutorBase : IIdentified, IAsyncDisposable +public abstract class ExecutorBase : IIdentified { /// /// A unique identifier for the executor. /// public string Id { get; } - private Dictionary State { get; } = new(); - /// /// Initialize the executor with a unique identifier /// @@ -63,17 +61,22 @@ internal MessageRouter Router /// An exception is generated while handling the message. public async ValueTask ExecuteAsync(object message, IWorkflowContext context) { - await context.AddEventAsync(new ExecutorInvokeEvent(this.Id)).ConfigureAwait(false); + await context.AddEventAsync(new ExecutorInvokeEvent(this.Id, message)).ConfigureAwait(false); CallResult? result = await this.Router.RouteMessageAsync(message, context, requireRoute: true) .ConfigureAwait(false); - ExecutorCompleteEvent completeEvent = new(this.Id) + ExecutorEvent executionResult; + if (result == null || result.IsSuccess) + { + executionResult = new ExecutorCompleteEvent(this.Id, result?.Result); + } + else { - Data = result == null ? null : result.IsSuccess ? result.Result : result.Exception - }; + executionResult = new ExecutorFailureEvent(this.Id, result.Exception); + } - await context.AddEventAsync(completeEvent).ConfigureAwait(false); + await context.AddEventAsync(executionResult).ConfigureAwait(false); if (result == null) { @@ -94,23 +97,6 @@ internal MessageRouter Router return result.Result; } - private bool _initialized = false; - - /// - /// Ensures that the executor has been initialized before performing operations. - /// - /// This method checks the internal state of the executor and throws an exception if it has not - /// been initialized. Call InitializeAsync before invoking any operations that require - /// initialization. - /// Thrown if the executor has not been initialized by calling InitializeAsync. - protected void CheckInitialized() - { - if (!this._initialized) - { - throw new InvalidOperationException($"Executor {this.GetType().Name} is not initialized. Call InitializeAsync first."); - } - } - /// /// A set of s, representing the messages this executor can handle. /// @@ -119,7 +105,7 @@ protected void CheckInitialized() /// /// A set of s, representing the messages this executor can produce as output. /// - public virtual ISet OutputTypes => new HashSet([typeof(object)]); + public virtual ISet OutputTypes { get; } = new HashSet([typeof(object)]); /// /// Checks if the executor can handle a specific message type. @@ -127,21 +113,6 @@ protected void CheckInitialized() /// /// public bool CanHandle(Type messageType) => this.Router.CanHandle(messageType); - - /// - protected virtual async ValueTask DisposeAsync() - { - this._initialized = false; - } - - /// - ValueTask IAsyncDisposable.DisposeAsync() - { - GC.SuppressFinalize(this); // Should we be suppressing the finalizer here? CodeAnalysis seems to want it (CA1816) - - // Chain to the virtual call to DisposeAsync. - return this.DisposeAsync(); - } } /// diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeState.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeState.cs index 2747969d91..65b98b433f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeState.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeState.cs @@ -7,32 +7,25 @@ namespace Microsoft.Agents.Workflows.Execution; internal record FanInEdgeState(FanInEdgeData EdgeData) { - private List? _pendingMessages - = EdgeData.Trigger == FanInTrigger.WhenAll ? [] : null; + private List? _pendingMessages = []; - private HashSet? _unseen - = EdgeData.Trigger == FanInTrigger.WhenAll ? new(EdgeData.SourceIds) : null; + private HashSet? _unseen = new(EdgeData.SourceIds); public IEnumerable? ProcessMessage(string sourceId, object message) { - if (this.EdgeData.Trigger == FanInTrigger.WhenAll) - { - this._pendingMessages!.Add(message); - this._unseen!.Remove(sourceId); - - if (this._unseen.Count == 0) - { - List result = this._pendingMessages; + this._pendingMessages!.Add(message); + this._unseen!.Remove(sourceId); - this._pendingMessages = []; - this._unseen = new(this.EdgeData.SourceIds); + if (this._unseen.Count == 0) + { + List result = this._pendingMessages; - return result; - } + this._pendingMessages = []; + this._unseen = new(this.EdgeData.SourceIds); - return null; + return result; } - return [message]; + return null; } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs index e8508b90c9..c12b9fd256 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs @@ -18,9 +18,9 @@ internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeDa public async ValueTask> ChaseAsync(object message) { List targets = - this.EdgeData.Partitioner == null + this.EdgeData.PartitionAssigner == null ? this.EdgeData.SinkIds - : this.EdgeData.Partitioner(message, this.BoundContexts.Count).Select(i => this.EdgeData.SinkIds[i]).ToList(); + : this.EdgeData.PartitionAssigner(message, this.BoundContexts.Count).Select(i => this.EdgeData.SinkIds[i]).ToList(); object?[] result = await Task.WhenAll(targets.Select(ProcessTargetAsync)).ConfigureAwait(false); return result.Where(r => r is not null); diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs index 2cabca3bda..6b24b25f99 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs @@ -35,7 +35,7 @@ public ValueTask HandleAsync(TInput message, IWorkflowContext context) if (this._completionCondition is not null && this._completionCondition!(message, this.Result)) { - return context.AddEventAsync(new WorkflowCompletedEvent() { Data = this.Result }); + return context.AddEventAsync(new WorkflowCompletedEvent(this.Result)); } return default; diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs index e005d08caa..200fcd0f35 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs @@ -138,8 +138,9 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func this.Track(target).Id).ToList(), - partitioner)); + partitioner); + + this.EnsureEdgesFor(source.Id).Add(new(fanOutEdge)); return this; } @@ -177,23 +179,20 @@ public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func /// The target executor that receives input from the specified source executors. Cannot be null. - /// An optional trigger condition that determines when the fan-in edge activates. Defaults to - /// . /// One or more source executors that provide input to the target. Cannot be null or empty. /// The current instance of . - public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = FanInTrigger.WhenAll, params ExecutorIsh[] sources) + public WorkflowBuilder AddFanInEdge(ExecutorIsh target, params ExecutorIsh[] sources) { Throw.IfNull(target); Throw.IfNullOrEmpty(sources); FanInEdgeData edgeData = new( sources.Select(source => this.Track(source).Id).ToList(), - this.Track(target).Id, - trigger); + this.Track(target).Id); foreach (string sourceId in edgeData.SourceIds) { - this.EnsureEdgesFor(sourceId).Add(edgeData); + this.EnsureEdgesFor(sourceId).Add(new(edgeData)); } return this; diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs index fde763b3b0..adb45c15fd 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs @@ -93,8 +93,6 @@ public async Task Test_ReflectAndExecute_DefaultHandlerAsync() Assert.NotNull(result); Assert.True(result.IsSuccess); Assert.True(result.IsVoid); - - await ((IAsyncDisposable)executor).DisposeAsync(); } [Fact] @@ -107,8 +105,6 @@ public async Task Test_ReflectAndExecute_HandlerReturnsVoidAsync() Assert.NotNull(result); Assert.True(result.IsSuccess); Assert.True(result.IsVoid); - - await ((IAsyncDisposable)executor).DisposeAsync(); } [Fact] @@ -130,7 +126,5 @@ public async Task Test_ReflectAndExecute_HandlerReturnsValueAsync() Assert.False(result.IsVoid); Assert.Equal(Expected, result.Result); - - await ((IAsyncDisposable)executor).DisposeAsync(); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs index 67c515bdac..1a262eb4ce 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs @@ -52,7 +52,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext cont string result = new(charArray); await context.SendMessageAsync(result).ConfigureAwait(false); - await context.AddEventAsync(new WorkflowCompletedEvent() { Data = result }).ConfigureAwait(false); + await context.AddEventAsync(new WorkflowCompletedEvent(result)).ConfigureAwait(false); return result; } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs index 8b479fd0c5..883eebad07 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs @@ -82,7 +82,7 @@ public async ValueTask HandleAsync(bool message, IWorkflowContext context) await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay - await context.AddEventAsync(new WorkflowCompletedEvent { Data = RespondToMessageExecutor.ActionResult }) + await context.AddEventAsync(new WorkflowCompletedEvent(RespondToMessageExecutor.ActionResult)) .ConfigureAwait(false); } } @@ -101,7 +101,7 @@ public async ValueTask HandleAsync(bool message, IWorkflowContext context) await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay - await context.AddEventAsync(new WorkflowCompletedEvent { Data = RemoveSpamExecutor.ActionResult }) + await context.AddEventAsync(new WorkflowCompletedEvent(RemoveSpamExecutor.ActionResult)) .ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs index 5180bae49b..da3affc589 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs @@ -69,7 +69,7 @@ public async ValueTask HandleAsync(NumberSignal message, IWorkflowContext c switch (message) { case NumberSignal.Matched: - await context.AddEventAsync(new WorkflowCompletedEvent { Data = $"Guessed the number: {this._currGuess}" }) + await context.AddEventAsync(new WorkflowCompletedEvent($"Guessed the number: {this._currGuess}")) .ConfigureAwait(false); break; From 201349f82614e474de6194afbc9a95ecb1766239 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 12 Aug 2025 11:42:31 -0400 Subject: [PATCH 093/232] feat: Add Switch (=Conditional Edge Group) control flow --- .../SwitchBuilder.cs | 115 ++++++++++++++++++ .../WorkflowBuilderExtensions.cs | 22 ++++ 2 files changed, 137 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs new file mode 100644 index 0000000000..daf6d70e89 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows; + +/// +/// Provides a builder for constructing a switch-like control flow that maps predicates to one or more executors. +/// Enables the configuration of case-based and default execution logic for dynamic input handling. +/// +public class SwitchBuilder +{ + private readonly List _executors = []; + private readonly Dictionary _executorIndicies = []; + private readonly List<(Func Predicate, HashSet OutgoingIndicies)> _caseMap = []; + private readonly List _defaultIndicies = []; + + /// + /// Adds a case to the switch builder that associates a predicate with one or more executors. + /// + /// + /// Cases are evaluated in the order they are added. + /// + /// A function that determines whether the associated executors should be considered for execution. The function + /// receives an input object and returns to select the case; otherwise, . + /// One or more executors to associate with the predicate. Each executor will be invoked if the predicate matches. + /// Cannot be null. + /// The current instance, allowing for method chaining. + public SwitchBuilder AddCase(Func predicate, params ExecutorIsh[] executors) + { + Throw.IfNull(predicate); + Throw.IfNull(executors); + + HashSet indicies = []; + + foreach (ExecutorIsh executor in executors) + { + if (!this._executorIndicies.TryGetValue(executor.Id, out int index)) + { + index = this._executors.Count; + this._executors.Add(executor); + this._executorIndicies[executor.Id] = index; + } + else if (indicies.Contains(index)) + { + // If this executor is already in the case list, skip it. + continue; + } + + indicies.Add(index); + } + + this._caseMap.Add((predicate, indicies)); + + return this; + } + + /// + /// Adds one or more executors to be used as the default case when no other predicates match. + /// + /// + /// + public SwitchBuilder WithDefault(params ExecutorIsh[] executors) + { + Throw.IfNull(executors); + + foreach (ExecutorIsh executor in executors) + { + if (!this._executorIndicies.TryGetValue(executor.Id, out int index)) + { + index = this._executors.Count; + this._executors.Add(executor); + this._executorIndicies[executor.Id] = index; + } + else if (this._defaultIndicies.Contains(index)) + { + // If this executor is already in the default list, skip it. + // TODO: Throw? + continue; + } + + this._defaultIndicies.Add(index); + } + + return this; + } + + internal WorkflowBuilder ReduceToFanOut(WorkflowBuilder builder, ExecutorIsh source) + { + List<(Func Predicate, HashSet OutgoingIndicies)> caseMap = this._caseMap; + List defaultIndicies = this._defaultIndicies; + + return builder.AddFanOutEdge(source, CasePartitioner, this._executors.ToArray()); + + IEnumerable CasePartitioner(object? input, int targetCount) + { + Debug.Assert(targetCount == this._executors.Count); + + for (int i = 0; i < caseMap.Count; i++) + { + (Func predicate, HashSet outgoingIndicies) = caseMap[i]; + if (predicate(input)) + { + return outgoingIndicies; + } + } + + return defaultIndicies; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs index c765bc53ca..62110bd304 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs @@ -76,6 +76,28 @@ public static WorkflowBuilder AddExternalCall(this Workflow .AddEdge(port, source); } + /// + /// Adds a switch step to the workflow, allowing conditional branching based on the specified source executor. + /// + /// Use this method to introduce conditional logic into a workflow, enabling execution to follow + /// different paths based on the outcome of the source executor. The switch configuration defines the available + /// branches and their associated conditions. + /// The workflow builder to which the switch step will be added. Cannot be null. + /// The source executor that determines the branching condition for the switch. Cannot be null. + /// An action used to configure the switch builder, specifying the branches and their conditions. Cannot be null. + /// The workflow builder instance with the configured switch step added. + public static WorkflowBuilder AddSwitch(this WorkflowBuilder builder, ExecutorIsh source, Action configureSwitch) + { + Throw.IfNull(builder); + Throw.IfNull(source); + Throw.IfNull(configureSwitch); + + SwitchBuilder switchBuilder = new(); + configureSwitch(switchBuilder); + + return switchBuilder.ReduceToFanOut(builder, source); + } + /// /// Builds a workflow that collects output from the specified executor, aggregates results using the provided /// streaming aggregator, and optionally completes based on a custom condition. From c8bed684a708fcd9d8081a14279c4b0960a46218 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 12 Aug 2025 09:07:46 -0700 Subject: [PATCH 094/232] Fix unit-tests --- .../ReflectionSmokeTest.cs | 15 --------------- .../Sample/01_Simple_Workflow_Sequential.cs | 4 ---- .../Sample/02_Simple_Workflow_Condition.cs | 8 -------- .../Sample/03_Simple_Workflow_Loop.cs | 4 ---- 4 files changed, 31 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs index 4aa7efc006..adb45c15fd 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs @@ -93,11 +93,6 @@ public async Task Test_ReflectAndExecute_DefaultHandlerAsync() Assert.NotNull(result); Assert.True(result.IsSuccess); Assert.True(result.IsVoid); -<<<<<<< HEAD - - await ((IAsyncDisposable)executor).DisposeAsync(); -======= ->>>>>>> dev/dotnet_workflow } [Fact] @@ -110,11 +105,6 @@ public async Task Test_ReflectAndExecute_HandlerReturnsVoidAsync() Assert.NotNull(result); Assert.True(result.IsSuccess); Assert.True(result.IsVoid); -<<<<<<< HEAD - - await ((IAsyncDisposable)executor).DisposeAsync(); -======= ->>>>>>> dev/dotnet_workflow } [Fact] @@ -136,10 +126,5 @@ public async Task Test_ReflectAndExecute_HandlerReturnsValueAsync() Assert.False(result.IsVoid); Assert.Equal(Expected, result.Result); -<<<<<<< HEAD - - await ((IAsyncDisposable)executor).DisposeAsync(); -======= ->>>>>>> dev/dotnet_workflow } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs index b10bf36e5f..1a262eb4ce 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs @@ -52,11 +52,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext cont string result = new(charArray); await context.SendMessageAsync(result).ConfigureAwait(false); -<<<<<<< HEAD - await context.AddEventAsync(new WorkflowCompletedEvent() { Data = result }).ConfigureAwait(false); -======= await context.AddEventAsync(new WorkflowCompletedEvent(result)).ConfigureAwait(false); ->>>>>>> dev/dotnet_workflow return result; } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs index ed8d4c1491..883eebad07 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs @@ -82,11 +82,7 @@ public async ValueTask HandleAsync(bool message, IWorkflowContext context) await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay -<<<<<<< HEAD - await context.AddEventAsync(new WorkflowCompletedEvent { Data = RespondToMessageExecutor.ActionResult }) -======= await context.AddEventAsync(new WorkflowCompletedEvent(RespondToMessageExecutor.ActionResult)) ->>>>>>> dev/dotnet_workflow .ConfigureAwait(false); } } @@ -105,11 +101,7 @@ public async ValueTask HandleAsync(bool message, IWorkflowContext context) await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay -<<<<<<< HEAD - await context.AddEventAsync(new WorkflowCompletedEvent { Data = RemoveSpamExecutor.ActionResult }) -======= await context.AddEventAsync(new WorkflowCompletedEvent(RemoveSpamExecutor.ActionResult)) ->>>>>>> dev/dotnet_workflow .ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs index 0b3124e4d3..da3affc589 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs @@ -69,11 +69,7 @@ public async ValueTask HandleAsync(NumberSignal message, IWorkflowContext c switch (message) { case NumberSignal.Matched: -<<<<<<< HEAD - await context.AddEventAsync(new WorkflowCompletedEvent { Data = $"Guessed the number: {this._currGuess}" }) -======= await context.AddEventAsync(new WorkflowCompletedEvent($"Guessed the number: {this._currGuess}")) ->>>>>>> dev/dotnet_workflow .ConfigureAwait(false); break; From 0bc9abc43788a817255926ae3f7264bf21e59aca Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 12 Aug 2025 09:24:25 -0700 Subject: [PATCH 095/232] Add sample --- dotnet/agent-framework-dotnet.slnx | 1 + .../DeclarativeWorkflow.csproj | 35 +++++++ dotnet/demos/DeclarativeWorkflow/Program.cs | 94 +++++++++++++++++++ .../demos/DeclarativeWorkflow/demo250729.yaml | 57 +++++++++++ dotnet/demos/DeclarativeWorkflow/readme.md | 24 +++++ 5 files changed, 211 insertions(+) create mode 100644 dotnet/demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj create mode 100644 dotnet/demos/DeclarativeWorkflow/Program.cs create mode 100644 dotnet/demos/DeclarativeWorkflow/demo250729.yaml create mode 100644 dotnet/demos/DeclarativeWorkflow/readme.md diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 077f1a0846..53f1d3e057 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -13,6 +13,7 @@ + diff --git a/dotnet/demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj b/dotnet/demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj new file mode 100644 index 0000000000..6d79ff0350 --- /dev/null +++ b/dotnet/demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj @@ -0,0 +1,35 @@ + + + + Exe + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 + + + + true + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs new file mode 100644 index 0000000000..428ec80283 --- /dev/null +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Threading.Tasks; +using Azure.Identity; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Declarative; +using Microsoft.Agents.Workflows.Execution; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.Diagnostics; + +namespace Demo.DeclarativeWorkflow; + +internal static class Program +{ + public static async Task Main(string[] args) + { + // Load configuration and create kernel with Azure OpenAI Chat Completion service + IConfiguration config = InitializeConfig(); + + Notify("PROCESS INIT\n"); + + Stopwatch timer = Stopwatch.StartNew(); + + ////////////////////////////////////////////////////// + // + // HOW TO: Create a workflow from a YAML file. + // + using StreamReader yamlReader = File.OpenText("demo250729.yaml"); + // + // DeclarativeWorkflowContext provides the components for workflow execution. + // + DeclarativeWorkflowContext workflowContext = + new() + { + LoggerFactory = NullLoggerFactory.Instance, + ActivityChannel = System.Console.Out, + ProjectEndpoint = Throw.IfNull(config["AzureAI:Endpoint"]), + ProjectCredentials = new AzureCliCredential(), + }; + // + // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. + // + Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); + // + ////////////////////////////////////////////////////// + + Notify($"\nPROCESS DEFINED: {timer.Elapsed}\n"); + + Notify("\nPROCESS INVOKE\n"); + + ////////////////////////////////////////////// + // Run the workflow, just like any other workflow + LocalRunner runner = new(workflow); + StreamingRun handle = await runner.StreamAsync(""); + await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + { + if (evt is ExecutorInvokeEvent executorInvoked) + { + Debug.WriteLine($"!!! ENTER #{executorInvoked.ExecutorId}"); + } + else if (evt is ExecutorCompleteEvent executorComplete) + { + Debug.WriteLine($"!!! EXIT #{executorComplete.ExecutorId}"); + } + } + ////////////////////////////////////////////// + + Notify("\nPROCESS DONE"); + } + + // Load configuration from user-secrets + private static IConfigurationRoot InitializeConfig() => + new ConfigurationBuilder() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .Build(); + + private static void Notify(string message) + { + Console.ForegroundColor = ConsoleColor.Cyan; + try + { + Console.WriteLine(message); + } + finally + { + Console.ResetColor(); + } + } +} diff --git a/dotnet/demos/DeclarativeWorkflow/demo250729.yaml b/dotnet/demos/DeclarativeWorkflow/demo250729.yaml new file mode 100644 index 0000000000..611ec7912b --- /dev/null +++ b/dotnet/demos/DeclarativeWorkflow/demo250729.yaml @@ -0,0 +1,57 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + actions: + + # Capture optional agent instructions + - kind: SetVariable + id: setVariable_NZ2u0l + variable: Topic.Instructions + value: =System.LastMessage.Text + + # Assign a list of inputs in JSON format to a variable + - kind: SetVariable + id: setVariable_aASlmF + displayName: List all of questions for LLM + variable: Topic.Questions + value: |- + =[ + "Why is the sky blue?", + "What is the capital of France?", + "Where do rainbows come from?", + ] + + # Loop over each question in the list + - kind: Foreach + id: foreach_mVIecC + items: =Topic.Questions + index: Topic.LoopIndex + value: Topic.Question + actions: + + # Display the current question + - kind: SendActivity + id: sendActivity_lMn07p + activity: "Question {Topic.LoopIndex + 1} - {Topic.Question}" + + # Use AI to answer the question + - kind: AnswerQuestionWithAI + id: question_wEJ456 + variable: Topic.Answer + userInput: =Topic.Question + additionalInstructions: "{Topic.Instructions}" + + # Display the AI's answer + - kind: SendActivity + id: sendActivity_zA3f0p + activity: "AI - {Topic.Answer}" + + # After processing all questions, display a completion message + - kind: SendActivity + id: sendActivity_SVoNSV + activity: Complete! + + # End the conversation + - kind: EndConversation + id: end_8nXE8H diff --git a/dotnet/demos/DeclarativeWorkflow/readme.md b/dotnet/demos/DeclarativeWorkflow/readme.md new file mode 100644 index 0000000000..dc4d8ee9b6 --- /dev/null +++ b/dotnet/demos/DeclarativeWorkflow/readme.md @@ -0,0 +1,24 @@ +# Summary + +This demo showcases the ability to parse a YAML workflow based on Copilo Studio actions +and produce a `KernelProcess` that can be executed in the same fashion as any other `KernelProcess`. + +## Key Features + +This demo illustrates the following capabilities: + +- Parse YAML workflow actions using `Microsoft.Bot.ObjectModel` +- Store and retrieve variable state +- Evaluate expressions using `Microsoft.PowerFx.Interpreter` +- Support control flow (foreach, goto, etc...) +- Generate response from LLM using _Semantic Kernel_ + +## Status Details + +- This is using a POC based on the _Process Framework_ from the _Semantic Kernel_ repo. + - When the redesigned _Process Framework_ is available in the _Agent Framework_ repo it must + be re-implemented using the new API patterns. + - Capturing and restoring workflow state is not yet available in either version of the _Process Framework_. + - The ability to emit events from the _KernelProcess_ to the host API is not yet supported. +- `Microsoft.Bot.ObjectModel` is not (yet) available as a dependency that may be referenced by a _GitHub_ repository. +- The full set of CPSDL actions to be supported is not fully defined, nor are the "Pri-0" samples. From 83fee75d9c49c5d2aba90bbbe9c13003bc74ac5f Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 12 Aug 2025 10:29:47 -0700 Subject: [PATCH 096/232] Comment cleanup --- dotnet/demos/DeclarativeWorkflow/Program.cs | 6 ++--- .../Workflows/Workflows_Declarative.cs | 13 +++++----- .../DeclarativeWorkflowBuilder.cs | 10 ++++---- .../DeclarativeWorkflowContext.cs | 4 +-- .../Execution/AnswerQuestionWithAIExecutor.cs | 4 +-- .../Execution/ClearAllVariablesExecutor.cs | 2 +- .../Execution/DeclarativeWorkflowExecutor.cs | 4 +-- .../Execution/EditTableV2Executor.cs | 2 +- .../Execution/ParseValueExecutor.cs | 2 +- .../Execution/ResetVariableExecutor.cs | 4 +-- .../Execution/SendActivityExecutor.cs | 18 ++++--------- .../Execution/SetVariableExecutor.cs | 2 +- .../Execution/WorkflowActionExecutor.cs | 25 ++++++++----------- .../Extensions/DataValueExtensions.cs | 5 ++-- .../Extensions/FormulaValueExtensions.cs | 16 ++++++------ .../Interpreter/WorkflowActionVisitor.cs | 11 ++++---- .../Interpreter/WorkflowModel.cs | 10 +++----- .../PowerFx/WorkflowExpressionEngine.cs | 3 +-- .../PowerFx/WorkflowScopesType.cs | 2 +- .../Extensions/FormulaValueExtensionsTests.cs | 21 +++++++++++----- 20 files changed, 78 insertions(+), 86 deletions(-) diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index 428ec80283..4f226b5406 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -49,9 +49,9 @@ public static async Task Main(string[] args) // ////////////////////////////////////////////////////// - Notify($"\nPROCESS DEFINED: {timer.Elapsed}\n"); + Notify($"PROCESS DEFINED: {timer.Elapsed}\n"); - Notify("\nPROCESS INVOKE\n"); + Notify("PROCESS INVOKE\n"); ////////////////////////////////////////////// // Run the workflow, just like any other workflow @@ -70,7 +70,7 @@ public static async Task Main(string[] args) } ////////////////////////////////////////////// - Notify("\nPROCESS DONE"); + Notify("PROCESS DONE"); } // Load configuration from user-secrets diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index c3c208ed24..d9f8a9f68c 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -2,6 +2,7 @@ #if NET +using System.Diagnostics; using System.Text.Json; using Azure.Identity; using Microsoft.Agents.Orchestration; @@ -44,7 +45,7 @@ public async Task RunWorkflow(string fileName, bool enableApiIntercept = false) customClient = new(new InterceptHandler(), disposeHandler: true); } - Console.WriteLine("WORKFLOW INIT\n"); + Debug.WriteLine("WORKFLOW INIT\n"); ////////////////////////////////////////////////////// // @@ -78,14 +79,14 @@ public async Task RunWorkflow(string fileName, bool enableApiIntercept = false) { if (evt is ExecutorInvokeEvent executorInvoked) { - Console.WriteLine($"!!! ENTER #{executorInvoked.ExecutorId}"); + Debug.WriteLine($"!!! ENTER #{executorInvoked.ExecutorId}"); } else if (evt is ExecutorCompleteEvent executorComplete) { - Console.WriteLine($"!!! EXIT #{executorComplete.ExecutorId}"); + Debug.WriteLine($"!!! EXIT #{executorComplete.ExecutorId}"); } } - Console.WriteLine("\nWORKFLOW DONE"); + Debug.WriteLine("\nWORKFLOW DONE"); } finally { @@ -104,7 +105,7 @@ protected override async Task SendAsync(HttpRequestMessage HttpResponseMessage response = await base.SendAsync(request, cancellationToken); // Intercept and modify the response - Console.WriteLine($"{request.Method} {request.RequestUri}"); + Debug.WriteLine($"{request.Method} {request.RequestUri}"); if (response.Content != null) { string responseContent; @@ -123,7 +124,7 @@ protected override async Task SendAsync(HttpRequestMessage } response.Content = new StringContent(responseContent); - Console.WriteLine($"API:{Environment.NewLine}" + responseContent); // %%% RAISE EVENT + Debug.WriteLine($"API:{Environment.NewLine}" + responseContent); } return response; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 7455670233..c1cab1fd47 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -using System; +using System.Diagnostics; using System.IO; using Microsoft.Agents.Workflows.Core; using Microsoft.Agents.Workflows.Declarative.Execution; @@ -24,16 +24,16 @@ public static class DeclarativeWorkflowBuilder /// The that corresponds with the YAML object model. public static Workflow Build(TextReader yamlReader, DeclarativeWorkflowContext? context = null) { - Console.WriteLine("@ PARSING YAML"); + Debug.WriteLine("@ PARSING YAML"); BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new UnknownActionException("Unable to parse workflow."); string rootId = $"root_{GetRootId(rootElement)}"; - Console.WriteLine("@ INITIALIZING BUILDER"); + Debug.WriteLine("@ INITIALIZING BUILDER"); context ??= DeclarativeWorkflowContext.Default; WorkflowScopes scopes = new(); - DeclarativeWorkflowExecutor rootExecutor = new(scopes, rootId); + DeclarativeWorkflowExecutor rootExecutor = new(rootId, scopes); - Console.WriteLine("@ INTERPRETING WORKFLOW"); + Debug.WriteLine("@ INTERPRETING WORKFLOW"); WorkflowActionVisitor visitor = new(rootExecutor, context, scopes); WorkflowElementWalker walker = new(rootElement, visitor); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs index 9078b1ad06..5f31b6ba40 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs @@ -54,7 +54,7 @@ public sealed class DeclarativeWorkflowContext /// /// Gets the used for activity output and diagnostics. /// - public TextWriter ActivityChannel { get; init; } = Console.Out; // %%% REMOVE: For POC only + public TextWriter ActivityChannel { get; init; } = TextWriter.Null; internal WorkflowExecutionContext CreateActionContext(string rootId, WorkflowScopes scopes) => new(RecalcEngineFactory.Create(scopes, this.MaximumExpressionLength), @@ -69,7 +69,7 @@ private PersistentAgentsClient CreateClient() if (this.HttpClient is not null) { clientOptions.Transport = new HttpClientTransport(this.HttpClient); - //clientOptions.RetryPolicy = new RetryPolicy(maxRetries: 0); + // %%% CONSIDER: clientOptions.RetryPolicy = new RetryPolicy(maxRetries: 0); } return new PersistentAgentsClient(this.ProjectEndpoint, this.ProjectCredentials, clientOptions); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs index 9518041cf1..ad1b73c61f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs @@ -22,13 +22,13 @@ protected override async ValueTask ExecuteAsync(CancellationToken cancellationTo StringExpression userInputExpression = Throw.IfNull(this.Model.UserInput, $"{nameof(this.Model)}.{nameof(this.Model.UserInput)}"); PersistentAgentsClient client = this.Context.ClientFactory.Invoke(); - using NewPersistentAgentsChatClient chatClient = new(client, "asst_ueIjfGxAjsnZ4A61LlbjG9vJ"); // %%% HACK - AGENT ID + using NewPersistentAgentsChatClient chatClient = new(client, "asst_ueIjfGxAjsnZ4A61LlbjG9vJ"); // %%% HAXX - AGENT ID ChatClientAgent agent = new(chatClient); string? userInput = null; if (this.Model.UserInput is not null) { - EvaluationResult result = this.Context.ExpressionEngine.GetValue(userInputExpression, this.Context.Scopes); // %%% FAILURE CASE (CATCH) + EvaluationResult result = this.Context.ExpressionEngine.GetValue(userInputExpression, this.Context.Scopes); userInput = result.Value; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs index 9e4f4e4006..6fe9ef6fcb 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs @@ -12,7 +12,7 @@ internal sealed class ClearAllVariablesExecutor(ClearAllVariables model) : Workf { protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) { - EvaluationResult result = this.Context.ExpressionEngine.GetValue(this.Model.Variables, this.Context.Scopes); // %%% FAILURE CASE (CATCH) + EvaluationResult result = this.Context.ExpressionEngine.GetValue(this.Model.Variables, this.Context.Scopes); result.Value.Handle(new ScopeHandler(this.Context)); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs index 9322dd08e7..b6271e7f0f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs @@ -11,9 +11,9 @@ namespace Microsoft.Agents.Workflows.Declarative.Execution; /// /// The root executor for a declarative workflow. /// -/// // %%% COMMENT / DESIGN /// The unique identifier for the workflow. -internal sealed class DeclarativeWorkflowExecutor(WorkflowScopes scopes, string workflowId) : +/// Scoped variable state for workflow execution. +internal sealed class DeclarativeWorkflowExecutor(string workflowId,WorkflowScopes scopes) : Executor(workflowId), IMessageHandler { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs index 3b49b836ac..fcdac7c7cb 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs @@ -25,7 +25,7 @@ protected async override ValueTask ExecuteAsync(CancellationToken cancellationTo if (changeType is AddItemOperation addItemOperation) { ValueExpression addItemValue = Throw.IfNull(addItemOperation.Value, $"{nameof(this.Model)}.{nameof(this.Model.ChangeType)}"); - EvaluationResult result = this.Context.ExpressionEngine.GetValue(addItemValue, this.Context.Scopes); // %%% FAILURE CASE (CATCH) + EvaluationResult result = this.Context.ExpressionEngine.GetValue(addItemValue, this.Context.Scopes); RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), result.Value.ToFormulaValue()); await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); this.AssignTarget(this.Context, variablePath, tableValue); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs index e72f0bbf89..e1f22067bf 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs @@ -20,7 +20,7 @@ protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); ValueExpression valueExpression = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); - EvaluationResult result = this.Context.ExpressionEngine.GetValue(valueExpression, this.Context.Scopes); // %%% FAILURE CASE (CATCH) + EvaluationResult result = this.Context.ExpressionEngine.GetValue(valueExpression, this.Context.Scopes); FormulaValue? parsedResult = null; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ResetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ResetVariableExecutor.cs index 65dad3e58a..854eec3e49 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ResetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ResetVariableExecutor.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -using System; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Extensions; @@ -18,7 +18,7 @@ protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) PropertyPath variablePath = Throw.IfNull(this.Model.Variable, $"{nameof(this.Model)}.{nameof(model.Variable)}"); this.Context.Engine.ClearScopedVariable(this.Context.Scopes, this.Model.Variable); - Console.WriteLine( // %%% LOGGER + Debug.WriteLine( $""" !!! CLEAR {this.GetType().Name} [{this.Id}] NAME: {this.Model.Variable!.Format()} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs index 352dc96fc5..23e438fe83 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs @@ -16,21 +16,13 @@ protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) { if (this.Model.Activity is MessageActivityTemplate messageActivity) { - Console.ForegroundColor = ConsoleColor.Yellow; - try + if (!string.IsNullOrEmpty(messageActivity.Summary)) { - if (!string.IsNullOrEmpty(messageActivity.Summary)) - { - activityWriter.WriteLine($"\t{messageActivity.Summary}"); - } - - string? activityText = this.Context.Engine.Format(messageActivity.Text)?.Trim(); - activityWriter.WriteLine(activityText + Environment.NewLine); - } - finally - { - Console.ResetColor(); + activityWriter.WriteLine($"\t{messageActivity.Summary}"); } + + string? activityText = this.Context.Engine.Format(messageActivity.Text)?.Trim(); + activityWriter.WriteLine(activityText + Environment.NewLine); } return new ValueTask(); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetVariableExecutor.cs index 67ec55a739..729aa4adab 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetVariableExecutor.cs @@ -22,7 +22,7 @@ protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) } else { - EvaluationResult result = this.Context.ExpressionEngine.GetValue(this.Model.Value, this.Context.Scopes); // %%% FAILURE CASE (CATCH) + EvaluationResult result = this.Context.ExpressionEngine.GetValue(this.Model.Value, this.Context.Scopes); this.AssignTarget(this.Context, variablePath, result.Value.ToFormulaValue()); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs index 59b42571fe..7e4ce56bfd 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; @@ -46,7 +47,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext context) { if (this.Model.Disabled) { - Console.WriteLine($"!!! DISABLED {this.GetType().Name} [{this.Id}]"); // %%% LOGGER + Debug.WriteLine($"!!! DISABLED {this.GetType().Name} [{this.Id}]"); return; } @@ -58,12 +59,12 @@ public async ValueTask HandleAsync(string message, IWorkflowContext context) } catch (WorkflowExecutionException) { - Console.WriteLine($"*** STEP [{this.Id}] ERROR - Action failure"); // %%% LOGGER + Debug.WriteLine($"*** STEP [{this.Id}] ERROR - Action failure"); throw; } catch (Exception exception) { - Console.WriteLine($"*** STEP [{this.Id}] ERROR - {exception.GetType().Name}\n{exception.Message}"); // %%% LOGGER + Debug.WriteLine($"*** STEP [{this.Id}] ERROR - {exception.GetType().Name}\n{exception.Message}"); throw new WorkflowExecutionException($"Unhandled workflow failure - #{this.Id} ({this.Model.GetType().Name})", exception); } } @@ -75,17 +76,11 @@ protected void AssignTarget(WorkflowExecutionContext context, PropertyPath targe context.Engine.SetScopedVariable(context.Scopes, targetPath, result); string? resultValue = result.Format(); string valuePosition = (resultValue?.IndexOf('\n') ?? -1) >= 0 ? Environment.NewLine : " "; - context.Logger.LogDebug( - """ - !!! ASSIGN {ActionName} [{ActionId}] - NAME: {TargetName} - VALUE:{ValuePosition}{Result} ({ResultType}) - """, - this.GetType().Name, - this.Id, - targetPath.Format(), - valuePosition, - result.Format(), - result.GetType().Name); + Debug.WriteLine( + $""" + !!! ASSIGN {this.GetType().Name} [{this.Id}] + NAME: {targetPath.Format()} + VALUE:{valuePosition}{result.Format()} ({result.GetType().Name}) + """); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs index 1f7acc7dfc..243502aeb0 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs @@ -21,7 +21,7 @@ public static FormulaValue ToFormulaValue(this DataValue? value) => DateTimeDataValue dateTimeValue => FormulaValue.New(dateTimeValue.Value.DateTime), DateDataValue dateValue => FormulaValue.NewDateOnly(dateValue.Value), TimeDataValue timeValue => FormulaValue.New(timeValue.Value), - TableDataValue tableValue => FormulaValue.NewTable(ParseRecordType(tableValue.Values.First()), tableValue.Values.Select(value => value.ToRecordValue())), // %%% TODO: RecordType + TableDataValue tableValue => FormulaValue.NewTable(ParseRecordType(tableValue.Values.First()), tableValue.Values.Select(value => value.ToRecordValue())), RecordDataValue recordValue => recordValue.ToRecordValue(), //FileDataValue // %%% SUPPORT ??? //OptionDataValue // %%% SUPPORT - Enum ??? @@ -43,8 +43,7 @@ public static FormulaType ToFormulaType(this DataType? type) => RecordDataType => RecordType.Empty(), //FileDataType // %%% SUPPORT ??? //OptionDataType // %%% SUPPORT - Enum ??? - DataType dataType => FormulaType.Blank, // %%% HANDLE ??? (FALLTHROUGH???) - //_ => FormulaType.Unknown, + DataType dataType => FormulaType.Blank, }; public static RecordValue ToRecordValue(this RecordDataValue recordDataValue) => diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs index b1cc7440aa..a7a2ae8913 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -30,8 +30,8 @@ public static DataValue GetDataValue(this FormulaValue value) => VoidValue voidValue => voidValue.ToDataValue(), TableValue tableValue => tableValue.ToDataValue(), RecordValue recordValue => recordValue.ToDataValue(), - //BlobValue // %%% DataValue ??? - //ErrorValue // %%% DataValue ??? + //BlobValue // %%% SUPPORT: DataValue ??? + //ErrorValue // %%% SUPPORT: DataValue ??? _ => throw new NotSupportedException($"Unsupported FormulaValue type: {value.GetType().Name}"), }; @@ -49,8 +49,8 @@ public static DataType GetDataType(this FormulaValue value) => GuidType => DataType.String, BlankType => DataType.String, RecordType => DataType.EmptyRecord, - //BlobValue // %%% DataType ??? - //ErrorValue // %%% DataType ??? + //BlobValue // %%% SUPPORT: DataType ??? + //ErrorValue // %%% SUPPORT: DataType ??? UnknownType => DataType.Unspecified, _ => DataType.Unspecified, }; @@ -68,10 +68,10 @@ public static DataType GetDataType(this FormulaValue value) => GuidValue guidValue => $"{guidValue.Value}", BlankValue blankValue => string.Empty, VoidValue voidValue => string.Empty, - TableValue tableValue => tableValue.ToString(), // %%% WORK ??? - RecordValue recordValue => recordValue.ToString(), - //BlobValue // %%% DataValue ??? - //ErrorValue // %%% DataValue ??? + TableValue tableValue => tableValue.ToString(), // %%% TODO: JSON + RecordValue recordValue => recordValue.ToString(), // %%% TODO: JSON + //BlobValue // %%% SUPPORT: DataValue ??? + //ErrorValue // %%% SUPPORT: DataValue ??? _ => throw new NotSupportedException($"Unsupported FormulaValue type: {value.GetType().Name}"), }; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 8636e12db6..93311040a4 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; @@ -74,7 +75,7 @@ public override void VisitConditionItem(ConditionItem item) if (item.Condition is not null) { - // %%% VERIFY IF ONLY ONE CONDITION IS EXPECTED / ALLOWED + // %%% BUG: ONLY ONE (FIRST) CONDITION condition = new((_) => { @@ -227,7 +228,7 @@ protected override void Visit(ResetVariable item) this.ContinueWith(new ResetVariableExecutor(item)); } - protected override void Visit(EditTable item) // %%% TODO + protected override void Visit(EditTable item) // %%% SUPPORT: EditTable { this.Trace(item); } @@ -478,13 +479,13 @@ private WorkflowDelegateExecutor CreateStep(string actionId, string name, Action private void NotSupported(DialogAction item) { - Console.WriteLine($"> UNKNOWN: {new string('\t', this._workflowModel.GetDepth(item.GetParentId()))}{FormatItem(item)} => {FormatParent(item)}"); // %%% LOGGER + Debug.WriteLine($"> UNKNOWN: {new string('\t', this._workflowModel.GetDepth(item.GetParentId()))}{FormatItem(item)} => {FormatParent(item)}"); this.HasUnsupportedActions = true; } private void Trace(BotElement item) { - Console.WriteLine($"> VISIT: {new string('\t', this._workflowModel.GetDepth(item.GetParentId()))}{FormatItem(item)} => {FormatParent(item)}"); // %%% LOGGER + Debug.WriteLine($"> VISIT: {new string('\t', this._workflowModel.GetDepth(item.GetParentId()))}{FormatItem(item)} => {FormatParent(item)}"); } private void Trace(DialogAction item) @@ -494,7 +495,7 @@ private void Trace(DialogAction item) { parentId = $"root_{parentId}"; } - Console.WriteLine($"> VISIT: {new string('\t', this._workflowModel.GetDepth(parentId))}{FormatItem(item)} => {FormatParent(item)}"); // %%% LOGGER + Debug.WriteLine($"> VISIT: {new string('\t', this._workflowModel.GetDepth(parentId))}{FormatItem(item)} => {FormatParent(item)}"); } private static string FormatItem(BotElement element) => $"{element.GetType().Name} ({element.GetId()})"; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs index 2c26ff4050..1712efc151 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs @@ -3,15 +3,11 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics; using Microsoft.Agents.Workflows.Core; namespace Microsoft.Agents.Workflows.Declarative.Interpreter; -/// -/// %%% COMMENT -/// -internal delegate void Action(); - /// /// Provides dynamic model for constructing a declarative workflow. /// @@ -88,7 +84,7 @@ public void ConnectNodes(WorkflowBuilder workflowBuilder) { if (node.CompletionHandler is not null) { - Console.WriteLine($"> CLOSE: {node.Id} (x{node.Children.Count})"); // %%% LOGGER + Debug.WriteLine($"> CLOSE: {node.Id} (x{node.Children.Count})"); node.CompletionHandler.Invoke(); } @@ -101,7 +97,7 @@ public void ConnectNodes(WorkflowBuilder workflowBuilder) throw new WorkflowModelException($"Unresolved target for {link.Source.Id}: {link.TargetId}."); } - Console.WriteLine($"> CONNECT: {link.Source.Id} => {link.TargetId}{(link.Condition is null ? string.Empty : " (?)")}"); // %%% LOGGER + Debug.WriteLine($"> CONNECT: {link.Source.Id} => {link.TargetId}{(link.Condition is null ? string.Empty : " (?)")}"); workflowBuilder.AddEdge(link.Source.Executor, targetNode.Executor, link.Condition); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs index e148b5f981..cb6cd83fc7 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs @@ -121,7 +121,7 @@ private EvaluationResult GetValue(StringExpression expression, T if (expressionResult.Value is RecordValue recordValue) { - JsonSerializerContext context = null!; // %%% HACK + JsonSerializerContext context = null!; // %%% HAXX - AOT //context.Options = s_options; return new EvaluationResult(JsonSerializer.Serialize(recordValue, typeof(RecordValue), context), expressionResult.Sensitivity); } @@ -203,7 +203,6 @@ private EvaluationResult GetValue(EnumExpression StringValue s when s.Value is not null => new EvaluationResult(EnumWrapper.Create(s.Value), expressionResult.Sensitivity), StringValue => new EvaluationResult(EnumWrapper.Create(0), expressionResult.Sensitivity), NumberValue number => new EvaluationResult(EnumWrapper.Create((int)number.Value), expressionResult.Sensitivity), - //OptionDataValue option => new EvaluationResult(EnumWrapper.Create(option.Value.Value), expressionResult.Sensitivity), // %%% SUPPORT _ => throw new InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.String), }; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopesType.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopesType.cs index 52c0274783..692516fd48 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopesType.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopesType.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.Workflows.Declarative.PowerFx; /// /// Describes the type of action scope. /// -internal sealed class WorkflowScopeType // %%% NEEDED +internal sealed class WorkflowScopeType { // https://msazure.visualstudio.com/CCI/_git/ObjectModel?path=/src/ObjectModel/Nodes/VariableScopeNames.cs&_a=contents&version=GBmain public static readonly WorkflowScopeType Env = new(VariableScopeNames.Environment); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs index 59b3b97929..90c0ef3a2f 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs @@ -83,27 +83,29 @@ public void VoidValues() [Fact] public void DateValues() { - DateValue formulaValue = FormulaValue.NewDateOnly(DateTime.UtcNow.Date); + DateTime timestamp = DateTime.UtcNow.Date; + DateValue formulaValue = FormulaValue.NewDateOnly(timestamp); DateDataValue dataValue = formulaValue.ToDataValue(); Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), dataValue.Value); DateValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); Assert.Equal(dataValue.Value, formulaCopy.GetConvertedValue(TimeZoneInfo.Utc)); - //Assert.Equal("45.3", formulaValue.Format()); // %%% TEST ASSERT ??? + Assert.Equal($"{timestamp}", formulaValue.Format()); } [Fact] public void DateTimeValues() { - DateTimeValue formulaValue = FormulaValue.New(DateTime.UtcNow); + DateTime timestamp = DateTime.UtcNow; + DateTimeValue formulaValue = FormulaValue.New(timestamp); DateTimeDataValue dataValue = formulaValue.ToDataValue(); Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), dataValue.Value); DateTimeValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); Assert.Equal(dataValue.Value, formulaCopy.GetConvertedValue(TimeZoneInfo.Utc)); - //Assert.Equal("45.3", formulaValue.Format()); // %%% TEST ASSERT ??? + Assert.Equal($"{timestamp}", formulaValue.Format()); } [Fact] @@ -126,7 +128,6 @@ public void RecordValues() new NamedValue("FieldA", FormulaValue.New("Value1")), new NamedValue("FieldB", FormulaValue.New("Value2")), new NamedValue("FieldC", FormulaValue.New("Value3"))); - RecordDataValue dataValue = formulaValue.ToDataValue(); Assert.Equal(formulaValue.Fields.Count(), dataValue.Properties.Count); foreach (KeyValuePair property in dataValue.Properties) @@ -141,6 +142,14 @@ public void RecordValues() Assert.Contains(field.Name, dataValue.Properties.Keys); } - //Assert.Equal("45.3", formulaValue.Format()); // %%% TEST ASSERT ??? + Assert.Equal( + """ + { + "FieldA": "Value1", + "FieldB": "Value2", + "FieldC": "Value3" + } + """, + formulaValue.Format()); } } From 03ce0f00749900c3d417705d57281c0aee21fcbc Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 12 Aug 2025 10:32:37 -0700 Subject: [PATCH 097/232] Fix debug output --- .../Interpreter/WorkflowActionVisitor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 93311040a4..e036a106ce 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -80,7 +80,7 @@ public override void VisitConditionItem(ConditionItem item) new((_) => { bool result = this._executionContext.Engine.Eval(item.Condition.ExpressionText ?? "true").AsBoolean(); - Console.WriteLine($"!!! CONDITION: {item.Condition.ExpressionText ?? "true"}={result}"); + Debug.WriteLine($"!!! CONDITION: {item.Condition.ExpressionText ?? "true"}={result}"); return result; }); } From c356f17765cff95cbe562a28f792e18df2d27811 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 12 Aug 2025 15:02:44 -0700 Subject: [PATCH 098/232] Formating helpers --- .../Workflows/Workflows_Declarative.cs | 2 +- .../Extensions/FormulaValueExtensions.cs | 85 ++++++++++++++----- .../Extensions/FormulaValueExtensionsTests.cs | 31 ++++++- 3 files changed, 94 insertions(+), 24 deletions(-) diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index d9f8a9f68c..a50ef232e7 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -71,7 +71,7 @@ public async Task RunWorkflow(string fileName, bool enableApiIntercept = false) // ////////////////////////////////////////////////////// - Console.WriteLine("\nWORKFLOW INVOKE\n"); + Debug.WriteLine("\nWORKFLOW INVOKE\n"); LocalRunner runner = new(workflow); StreamingRun handle = await runner.StreamAsync(""); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs index a7a2ae8913..a057982271 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -3,18 +3,19 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; -using System.Drawing; using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx.Types; using BlankType = Microsoft.PowerFx.Types.BlankType; namespace Microsoft.Agents.Workflows.Declarative.Extensions; -internal delegate object? GetFormulaValue(FormulaValue value); - internal static class FormulaValueExtensions { + private static readonly JsonSerializerOptions s_options = new() { WriteIndented = true }; + public static DataValue GetDataValue(this FormulaValue value) => value switch { @@ -25,13 +26,13 @@ public static DataValue GetDataValue(this FormulaValue value) => DateTimeValue datetimeValue => datetimeValue.ToDataValue(), TimeValue timeValue => timeValue.ToDataValue(), StringValue stringValue => stringValue.ToDataValue(), - GuidValue guidValue => guidValue.ToDataValue(), // %%% CORRECT ??? BlankValue blankValue => blankValue.ToDataValue(), VoidValue voidValue => voidValue.ToDataValue(), TableValue tableValue => tableValue.ToDataValue(), RecordValue recordValue => recordValue.ToDataValue(), - //BlobValue // %%% SUPPORT: DataValue ??? - //ErrorValue // %%% SUPPORT: DataValue ??? + //GuidValue guidValue => guidValue.ToDataValue(), // %%% SUPPORT: DataValue ??? + //BlobValue => // %%% SUPPORT: DataValue ??? + //ErrorValue => // %%% SUPPORT: DataValue ??? _ => throw new NotSupportedException($"Unsupported FormulaValue type: {value.GetType().Name}"), }; @@ -46,16 +47,16 @@ public static DataType GetDataType(this FormulaValue value) => DateTimeType => DataType.DateTime, TimeType => DataType.Time, StringType => DataType.String, - GuidType => DataType.String, BlankType => DataType.String, RecordType => DataType.EmptyRecord, - //BlobValue // %%% SUPPORT: DataType ??? - //ErrorValue // %%% SUPPORT: DataType ??? + //GuidType => DataType.String, // %%% SUPPORT: DataType ??? + //BlobValue => // %%% SUPPORT: DataType ??? + //ErrorValue => // %%% SUPPORT: DataType ??? UnknownType => DataType.Unspecified, _ => DataType.Unspecified, }; - public static string? Format(this FormulaValue value) => + public static string Format(this FormulaValue value) => value switch { BooleanValue booleanValue => $"{booleanValue.Value}", @@ -68,26 +69,24 @@ public static DataType GetDataType(this FormulaValue value) => GuidValue guidValue => $"{guidValue.Value}", BlankValue blankValue => string.Empty, VoidValue voidValue => string.Empty, - TableValue tableValue => tableValue.ToString(), // %%% TODO: JSON - RecordValue recordValue => recordValue.ToString(), // %%% TODO: JSON - //BlobValue // %%% SUPPORT: DataValue ??? - //ErrorValue // %%% SUPPORT: DataValue ??? - _ => throw new NotSupportedException($"Unsupported FormulaValue type: {value.GetType().Name}"), + TableValue tableValue => tableValue.ToJson().ToJsonString(s_options), + RecordValue recordValue => recordValue.ToJson().ToJsonString(s_options), + ErrorValue errorValue => $"Error:{Environment.NewLine}{string.Join(Environment.NewLine, errorValue.Errors.Select(error => $"{error.MessageKey}: {error.Message}"))}", + //BlobValue blobValue => NO SPECIAL FORMATTING + _ => $"[{value.GetType().Name}]", }; - // %%% TODO: Type conversion - public static BooleanDataValue ToDataValue(this BooleanValue value) => BooleanDataValue.Create(value.Value); public static NumberDataValue ToDataValue(this DecimalValue value) => NumberDataValue.Create(value.Value); public static FloatDataValue ToDataValue(this NumberValue value) => FloatDataValue.Create(value.Value); public static DateTimeDataValue ToDataValue(this DateTimeValue value) => DateTimeDataValue.Create(value.GetConvertedValue(TimeZoneInfo.Utc)); public static DateDataValue ToDataValue(this DateValue value) => DateDataValue.Create(value.GetConvertedValue(TimeZoneInfo.Utc)); public static TimeDataValue ToDataValue(this TimeValue value) => TimeDataValue.Create(value.Value); - public static StringDataValue ToDataValue(this StringValue value) => StringDataValue.Create(value.Value); - public static StringDataValue ToDataValue(this GuidValue value) => StringDataValue.Create(value.Value.ToString("N")); // %%% FORMAT ??? public static DataValue ToDataValue(this BlankValue _) => BlankDataValue.Blank(); - public static DataValue ToDataValue(this VoidValue _) => BlankDataValue.Blank(); // %%% CORRECT ??? - public static StringDataValue ToDataValue(this ColorValue value) => StringDataValue.Create(Enum.GetName(typeof(Color), value.Value)!); // %%% CORRECT ??? + public static DataValue ToDataValue(this VoidValue _) => BlankDataValue.Blank(); + public static StringDataValue ToDataValue(this StringValue value) => StringDataValue.Create(value.Value); + //public static StringDataValue ToDataValue(this GuidValue value) => StringDataValue.Create(value.Value.ToString("N")); // %%% SUPPORT: DataValue ??? + //public static StringDataValue ToDataValue(this ColorValue value) => StringDataValue.Create(Enum.GetName(typeof(Color), value.Value)!); // %%% SUPPORT: DataValue ??? public static TableDataValue ToDataValue(this TableValue value) => TableDataValue.TableFromRecords(value.Rows.Select(row => row.Value.ToDataValue()).ToImmutableArray()); @@ -96,4 +95,48 @@ public static RecordDataValue ToDataValue(this RecordValue value) => RecordDataValue.RecordFromFields(value.OriginalFields.Select(field => field.GetKeyValuePair()).ToImmutableArray()); private static KeyValuePair GetKeyValuePair(this NamedValue value) => new(value.Name, value.Value.GetDataValue()); + + public static JsonNode ToJson(this FormulaValue value) => + value switch + { + BooleanValue booleanValue => JsonValue.Create(booleanValue.Value), + DecimalValue decimalValue => JsonValue.Create(decimalValue.Value), + NumberValue numberValue => JsonValue.Create(numberValue.Value), + DateValue dateValue => JsonValue.Create(dateValue.GetConvertedValue(TimeZoneInfo.Utc)), + DateTimeValue datetimeValue => JsonValue.Create(datetimeValue.GetConvertedValue(TimeZoneInfo.Utc)), + TimeValue timeValue => JsonValue.Create($"{timeValue.Value}"), + StringValue stringValue => JsonValue.Create(stringValue.Value), + GuidValue guidValue => JsonValue.Create(guidValue.Value), + RecordValue recordValue => recordValue.ToJson(), + TableValue tableValue => tableValue.ToJson(), + BlankValue blankValue => JsonValue.Create(string.Empty), + //VoidValue voidValue => JsonValue.Create(), + //ErrorValue errorValue => $"Error:{Environment.NewLine}{string.Join(Environment.NewLine, errorValue.Errors.Select(error => $"{error.MessageKey}: {error.Message}"))}", + //BlobValue blobValue => NO SPECIAL FORMATTING + _ => $"[{value.GetType().Name}]", + }; + + public static JsonArray ToJson(this TableValue value) + { + return new([.. GetJsonElements()]); + + IEnumerable GetJsonElements() + { + foreach (DValue row in value.Rows) + { + RecordValue recordValue = row.Value; + yield return recordValue.ToJson(); + } + } + } + + public static JsonObject ToJson(this RecordValue value) + { + JsonObject jsonObject = []; + foreach (NamedValue field in value.OriginalFields) + { + jsonObject.Add(field.Name, field.Value.ToJson()); + } + return jsonObject; + } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs index 90c0ef3a2f..6075ee5432 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs @@ -145,11 +145,38 @@ public void RecordValues() Assert.Equal( """ { + "FieldA": "Value1", + "FieldB": "Value2", + "FieldC": "Value3" + } + """, + formulaValue.Format().Replace(Environment.NewLine, "\n")); + } + + [Fact] + public void TableValues() + { + RecordValue recordValue = FormulaValue.NewRecordFromFields( + new NamedValue("FieldA", FormulaValue.New("Value1")), + new NamedValue("FieldB", FormulaValue.New("Value2")), + new NamedValue("FieldC", FormulaValue.New("Value3"))); + TableValue formulaValue = TableValue.NewTable(recordValue.Type, [recordValue]); + TableDataValue dataValue = formulaValue.ToDataValue(); + Assert.Equal(formulaValue.Rows.Count(), dataValue.Values.Length); + + TableValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue(), exactMatch: false); + Assert.Equal(formulaCopy.Rows.Count(), dataValue.Values.Length); + + Assert.Equal( + """ + [ + { "FieldA": "Value1", "FieldB": "Value2", "FieldC": "Value3" - } + } + ] """, - formulaValue.Format()); + formulaValue.Format().Replace(Environment.NewLine, "\n")); } } From 30101799ee4e54dbad2a7578a191820d985c3043 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 28 Jul 2025 16:08:29 -0400 Subject: [PATCH 099/232] feat: Define Workflow and Executor APIs --- .../Core/CompletedValueTaskSource.cs | 27 ++ .../Workflows/Core/DisposableObject.cs | 59 +++ .../Workflows/Core/ExecutionContext.cs | 17 + .../Workflows/Core/Executor.cs | 275 ++++++++++++++ .../Workflows/Core/Message.cs | 117 ++++++ .../Workflows/Core/MessageHandler.cs | 37 ++ .../Workflows/Core/MessageRouting.cs | 342 +++++++++++++++++ .../Workflows/Core/TypeErasure.cs | 60 +++ .../Workflows/WorkflowBuilder.cs | 353 ++++++++++++++++++ .../Workflows/WorkflowBuilderExtensions.cs | 156 ++++++++ 10 files changed, 1443 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs new file mode 100644 index 0000000000..e33b614d20 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// Helper class to work around lack of proper ValueTask support in .NET Framework. +/// +internal static class CompletedValueTaskSource +{ + internal static ValueTask Completed => +#if NET5_0_OR_GREATER + ValueTask.CompletedTask; +#else + new(Task.CompletedTask); +#endif + + internal static ValueTask FromResult(T result) + { +#if NET5_0_OR_GREATER + return new ValueTask(result); +#else + return new ValueTask(Task.FromResult(result)); +#endif + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs new file mode 100644 index 0000000000..d147b7d78d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// Provides a base class implementing the interface using +/// the virtual Dispose pattern. +/// +public class DisposableObject : IAsyncDisposable +{ + /// + /// Implements invocation of the DisposeAsync method when the object is finalized to + /// dispose unmanaged resources properly. + /// + ~DisposableObject() + { + // Finalizer calls DisposeAsync to ensure resources are released. + // This is a safety net in case DisposeAsync was not called. +#pragma warning disable CA2012 // Use ValueTasks correctly: Uses OnCompleted to properly handle the ValueTask return. + ValueTask disposeTask = this.DisposeAsync(false); +#pragma warning restore CA2012 // Use ValueTasks correctly + + if (!disposeTask.IsCompleted) + { + using (ManualResetEvent barrier = new(false)) + { + disposeTask.GetAwaiter().OnCompleted(() => barrier.Set()); + + // Wait for the DisposeAsync to complete. + barrier.WaitOne(); // TODO: Timeout? + } + } + + Debug.Assert( + disposeTask.IsCompleted, + "DisposeAsync should have completed in order to pass to this line."); +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + disposeTask.GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + } + + /// + protected virtual ValueTask DisposeAsync(bool disposing) + { + return CompletedValueTaskSource.Completed; + } + + /// + public async ValueTask DisposeAsync() + { + await this.DisposeAsync(true).ConfigureAwait(false); + GC.SuppressFinalize(this); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs new file mode 100644 index 0000000000..6a1d3f8415 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// Provides services for subclasses. +/// +public interface IExecutionContext +{ + /// + /// . + /// + /// + Task MagicAsync(); +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs new file mode 100644 index 0000000000..d5ea990084 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs @@ -0,0 +1,275 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// A tag interface for objects that have a unique identifier within an appropriate namespace. +/// +public interface IIdentified +{ + /// + /// The unique identifier. + /// + string Id { get; } +} + +/// +/// . +/// +public record ExecutorCapabilities +{ + /// + /// . + /// + public string Id { get; init; } + /// + /// . + /// + public string Name { get; init; } + /// + /// . + /// + public Type ExecutorType { get; init; } + /// + /// . + /// + public ISet HandledMessageTypes { get; init; } + /// + /// . + /// + public bool IsInitialized { get; init; } + /// + /// . + /// + public ISet StateKeys { get; init; } + + /// + /// . + /// + public ExecutorCapabilities() + { + this.Id = string.Empty; + this.Name = string.Empty; + this.ExecutorType = typeof(Executor); + this.HandledMessageTypes = new HashSet(); + this.IsInitialized = false; + this.StateKeys = new HashSet(); + } + + /// + /// . + /// + /// + /// + /// + /// + /// + /// + public ExecutorCapabilities(string id, string name, Type executorType, ISet handledMessageTypes, bool isInitialized, ISet stateKeys) + { + this.Id = id; + this.Name = name; + this.ExecutorType = executorType; + this.HandledMessageTypes = handledMessageTypes; + this.IsInitialized = isInitialized; + this.StateKeys = stateKeys; + } +} + +/// +/// . +/// +[DebuggerDisplay("{GetType().Name}{Id}({Name})")] +public abstract class Executor : DisposableObject, IIdentified +{ + /// + /// . + /// + public string Id { get; } + + /// + /// . + /// + public string Name { get; } + + private MessageRouter MessageRouter { get; init; } + private Dictionary State { get; } = new(); + + /// + /// . + /// + /// + /// + protected Executor(string? id = null, string? name = null) + { + this.Name = name ?? this.GetType().Name; + this.Id = id ?? $"{this.Name}{Guid.NewGuid():N}"; + + this.MessageRouter = MessageRouter.BindMessageHandlers(this, checkType: true); + } + + /// + /// . + /// + /// + /// + /// + /// + /// + public async ValueTask ExecuteAsync(object message, IExecutionContext context) + { + CallResult? result = await this.MessageRouter.RouteMessageAsync(message, context, requireRoute: true) + .ConfigureAwait(false); + + if (result == null) + { + throw new NotSupportedException( + $"No handler found for message type {message.GetType().Name} in executor {this.GetType().Name}."); + } + + if (!result.IsSuccess) + { + throw new TargetInvocationException($"Error invoking handler for {message.GetType()}", result.Exception!); + } + + if (result.IsVoid) + { + return null; // Void result. + } + + return result.Result; + } + + private bool _initialized = false; + + /// + /// . + /// + public ISet InputTypes => this.MessageRouter.IncomingTypes; + + /// + /// . + /// + [SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "")] + public ISet OutputTypes => throw new NotImplementedException(); + + /// + /// . + /// + /// + /// + public bool CanHandle(Type messageType) => this.MessageRouter.CanHandle(messageType); + + /// + /// . + /// + /// + /// + public async ValueTask InitializeAsync(IExecutionContext context) + { + if (this._initialized) + { + return; + } + + await this.InitializeOverride(context).ConfigureAwait(false); + + this._initialized = true; + } + + /// + /// . + /// + public ExecutorCapabilities Capabilities + => new() + { + Id = this.Id, + Name = this.Name, + ExecutorType = this.GetType(), + HandledMessageTypes = new HashSet(this.InputTypes), + IsInitialized = this._initialized, + StateKeys = new HashSet(this.State.Keys) + }; + + /// + /// . + /// + /// + public ReadOnlyDictionary CurrentState => new(this.State); + + /// + /// . + /// + /// + /// + public void RestoreState(IDictionary state) + { + if (state == null) + { + throw new ArgumentNullException(nameof(state), "State cannot be null."); + } + + this.State.Clear(); + + foreach (KeyValuePair kvp in state) + { + this.State[kvp.Key] = kvp.Value; + } + } + + /// + /// . + /// + /// + protected virtual ValueTask PrepareForCheckpointAsync() + { + return CompletedValueTaskSource.Completed; + } + + /// + /// . + /// + /// + protected virtual ValueTask AfterCheckpointRestoreAsync() + { + return CompletedValueTaskSource.Completed; + } + + /// + /// . + /// + /// + /// + protected virtual ValueTask InitializeOverride(IExecutionContext context) + { + // Default implementation does nothing. + return CompletedValueTaskSource.Completed; + } + + private async ValueTask FlushReduceRemainingAsync() + { + return; + } + + /// + /// . + /// + /// + /// + protected override async ValueTask DisposeAsync(bool disposing = false) + { + this._initialized = false; + + await this.FlushReduceRemainingAsync().ConfigureAwait(false); + + await base.DisposeAsync(disposing).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs new file mode 100644 index 0000000000..3b8eda2482 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +using ExecutorId = string; +// TODO: Unclear whether this should be forcibly a serializable type. +using MetadataValueT = object; +using RetryExceptionT = System.InvalidOperationException; +using TopicId = string; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// . +/// +public record MessageMetadata +{ + /// + /// . + /// + public string CorrelationId { get; init; } = Guid.NewGuid().ToString(); + /// + /// . + /// + public ExecutorId? SourceId { get; init; } + /// + /// . + /// + public ExecutorId? TargetId { get; init; } + /// + /// . + /// + public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; + /// + /// . + /// + public string IsoTimestamp => this.Timestamp.ToString("o"); + /// + /// . + /// + public TopicId? Topic { get; init; } + /// + /// . + /// + public int Priority { get; init; } = 0; // Higher values indicate higher priority. + /// + /// . + /// + public TimeSpan? Timeout { get; init; } = null; + + /// + /// . + /// + public int Retries { get; init; } = 0; + /// + /// . + /// + public int MaxRetries { get; init; } = 3; + + /// + /// . + /// + public IDictionary CustomData { get; init; } = new Dictionary(); +} + +/// +/// . +/// +/// +public record Message +{ + /// + /// . + /// + public TContent Content { get; init; } + + /// + /// . + /// + public Type ContentType => typeof(TContent); + + /// + /// . + /// + public MessageMetadata Metadata { get; init; } + + /// + /// . + /// + /// + /// + /// + public Message(TContent content, MessageMetadata metadata) + { + this.Content = content ?? throw new ArgumentNullException(nameof(content)); + this.Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); + } + + /// + /// Creates a new message instance for a new target. + /// + /// The identifier of the target executor to associate with the message. + /// A new instance with the updated target identifier. + public Message WithTarget(ExecutorId targetId) + => this with { Metadata = this.Metadata with { TargetId = targetId } }; + + /// + /// Create a copy of this message for next retry attempt. + /// + /// A copy of this message with incremented retry count. + /// If the maximum number of retries has been exceeded. + public Message WithRetry() + => this.Metadata.Retries < this.Metadata.MaxRetries + ? this with { Metadata = this.Metadata with { Retries = this.Metadata.Retries + 1 } } + : throw new RetryExceptionT($"Maximum retries ({this.Metadata.MaxRetries}) exceeded for message with ID '{this.Metadata.CorrelationId}'."); +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs new file mode 100644 index 0000000000..009a5aabc6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// A message handler interface for handling messages of type . +/// +/// +public interface IMessageHandler +{ + /// + /// Handles the incoming message asynchronously. + /// + /// The message to handle. + /// The execution context. + /// A task that represents the asynchronous operation. + ValueTask HandleAsync(TMessage message, IExecutionContext context); +} + +/// +/// A message handler interface for handling messages of type and +/// returning a result. +/// +/// The type of message to handle. +/// The type of result returned after handling the message. +public interface IMessageHandler +{ + /// + /// Handles the incoming message asynchronously. + /// + /// The message to handle. + /// The execution context. + /// A task that represents the asynchronous operation. + ValueTask HandleAsync(TMessage message, IExecutionContext context); +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs new file mode 100644 index 0000000000..4fc27244d8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs @@ -0,0 +1,342 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +using HandlerInfosT = + System.Collections.Generic.Dictionary< + System.Type, + Microsoft.Agents.Orchestration.Workflows.Core.MessageHandlerInfo + >; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// This class represents the result of a call to a +/// or . +/// +public sealed class CallResult +{ + /// + /// Indicates whether the call was void (i.e., no result expected). This only applies to + /// calls to handlers. + /// + public bool IsVoid { get; init; } + + /// + /// If the call was successful, this property contains the result of the call. For calls to + /// void handlers, this will be null. + /// + public object? Result { get; init; } = null; + + /// + /// If the call failed, this property contains the exception that was raised during the call. + /// + public Exception? Exception { get; init; } = null; + + /// + /// Indicates whether the call was successful. A call is considered successful if it returned + /// without throwing an exception. + /// + public bool IsSuccess => this.Exception == null; + + private CallResult(bool isVoid = false) + { + // Private constructor to enforce use of static methods. + this.IsVoid = isVoid; + } + + /// + /// Create a indicating a successful that returned a result (non-void). + /// + /// The result to return. + /// A indicating the result of the call. + public static CallResult ReturnResult(object? result = null) + { + return new() { Result = result }; + } + + /// + /// Create a indicating a successful call that returned no result (void). + /// + /// A indicating the result of the call. + public static CallResult ReturnVoid() + { + return new(isVoid: true); + } + + /// + /// Create a indicating that an exception was raised during the call. + /// + /// A boolean specifying whether the call was void (was not expected to return + /// a value). + /// The exception that was raised during the call. + /// A indicating the result of the call. + /// Thrown when is null. + public static CallResult RaisedException(bool wasVoid, Exception exception) + { + if (exception == null) + { + throw new ArgumentNullException(nameof(exception), "Exception cannot be null."); + } + + return new(wasVoid) { Exception = exception }; + } +} + +internal struct MessageHandlerInfo +{ + public Type InType { get; init; } + public Type? OutType { get; init; } = null; + + public MethodInfo HandlerInfo { get; init; } + public Func>? Unwrapper { get; init; } = null; + + [SuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality " + + "when AOT compiling.", Justification = "")] + public MessageHandlerInfo(MethodInfo handlerInfo) + { + // The method is one of the following: + // - ValueTask HandleAsync(TMessage message, IExecutionContext context) + // - ValueTask HandleAsync(TMessage message, IExecutionContext context) + this.HandlerInfo = handlerInfo; + + ParameterInfo[] parameters = handlerInfo.GetParameters(); + if (parameters.Length != 2) + { + throw new ArgumentException("Handler method must have exactly two parameters: TMessage and IExecutionContext.", nameof(handlerInfo)); + } + + if (parameters[1].ParameterType != typeof(IExecutionContext)) + { + throw new ArgumentException("Handler method's second parameter must be of type IExecutionContext.", nameof(handlerInfo)); + } + + this.InType = parameters[0].ParameterType; + + Type decoratedReturnType = handlerInfo.ReturnType; + if (decoratedReturnType.IsGenericType && decoratedReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) + { + // If the return type is ValueTask, extract TResult. + Type[] returnRawTypes = decoratedReturnType.GetGenericArguments(); + Debug.Assert( + returnRawTypes.Length == 1, + "ValueTask should have exactly one generic argument."); + + this.OutType = returnRawTypes.Single(); + this.Unwrapper = ValueTaskTypeErasure.UnwrapperFor(this.OutType); + } + else if (decoratedReturnType == typeof(ValueTask)) + { + // If the return type is ValueTask, there is no output type. + this.OutType = null; + } + else + { + throw new ArgumentException("Handler method must return ValueTask or ValueTask.", nameof(handlerInfo)); + } + } + + public Func> Bind(Executor executor, bool checkType = false) + { + Type? resultType = this.OutType; + MethodInfo handlerMethod = this.HandlerInfo; + Func>? unwrapper = this.Unwrapper; + + return InvokeHandlerAsync; + + // Create a delegate that binds the handler to the executor. + async ValueTask InvokeHandlerAsync(object message) + { + bool expectingVoid = resultType == null || resultType == typeof(void); + + try + { + object? maybeValueTask = handlerMethod.Invoke(executor, new object[] { message, executor }); + + if (expectingVoid) + { + if (maybeValueTask is ValueTask vt) + { + await vt.ConfigureAwait(false); + return CallResult.ReturnVoid(); + } + + throw new InvalidOperationException( + $"Handler method {handlerMethod.Name} is expected to return ValueTask or ValueTask, but returned " + + $"{maybeValueTask?.GetType().Name ?? "null"}."); + } + + Debug.Assert(resultType != null, "Expected resultType to be non-null when not expecting void."); + if (unwrapper == null) + { + throw new InvalidOperationException( + $"Handler method {handlerMethod.Name} is expected to return ValueTask<{resultType!.Name}>, but no unwrapper is available."); + } + + if (maybeValueTask == null) + { + throw new InvalidOperationException( + $"Handler method {handlerMethod.Name} returned null, but a ValueTask<{resultType!.Name}> was expected."); + } + + object? result = await unwrapper(maybeValueTask).ConfigureAwait(false); + + if (checkType && result != null && !resultType.IsInstanceOfType(result)) + { + throw new InvalidOperationException( + $"Handler method {handlerMethod.Name} returned an incompatible type: expected {resultType.Name}, got {result.GetType().Name}."); + } + + return CallResult.ReturnResult(result); + } + catch (Exception ex) + { + // If the handler throws an exception, return it in the CallResult. + return CallResult.RaisedException(wasVoid: expectingVoid, exception: ex); + } + } + } +} + +internal class MessageRouter +{ + // TODO: The goal of the cache is to allow SourceGenerators to do the reflection to bind the handlers in the router. + internal static readonly Dictionary> s_routerFactoryCache = new(); + + private Dictionary>> BoundHandlers { get; init; } = new(); + + [SuppressMessage("Trimming", + "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + + "of the source method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + [SuppressMessage("Trimming", + "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of " + + "method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + private static HandlerInfosT ReflectHandlers(Type executorType) + { + // This method reflects over the methods of the executor type to find message handlers. + HandlerInfosT handlers = new(); + + // Get all implementations of IMessageHandler or IMessageHandler + // and create a MessageHandlerInfo for each. + if (!typeof(Executor).IsAssignableFrom(executorType)) + { + throw new ArgumentException($"Type {executorType.FullName} is not a valid Executor type.", nameof(executorType)); + } + + if (executorType.IsAbstract || executorType.IsInterface) + { + throw new ArgumentException($"Type {executorType.FullName} cannot be abstract or an interface.", nameof(executorType)); + } + + // Iterate all interfaces implemented by the executor type. + foreach (Type interfaceType in executorType.GetInterfaces()) + { + // Check if the interface is a message handler. + if (!interfaceType.IsGenericType) + { + continue; + } + + if (interfaceType.IsGenericType && (interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || + interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>))) + { + // Get the generic arguments of the interface. + Type[] genericArguments = interfaceType.GetGenericArguments(); + if (genericArguments.Length < 1 || genericArguments.Length > 2) + { + continue; // Invalid handler signature. + } + Type inType = genericArguments[0]; + Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; + MethodInfo? method = interfaceType.GetMethod("HandleAsync"); + if (method != null) + { + MessageHandlerInfo info = new(method) { InType = inType, OutType = outType }; + handlers[inType] = info; + } + } + } + + return handlers; + } + + [SuppressMessage("Trimming", + "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + + "of the source method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + internal static HandlerInfosT ReflectHandlers() where TExecutor : Executor + => ReflectHandlers(typeof(TExecutor)); + + internal static MessageRouter BindMessageHandlers(Executor executor, bool checkType) + { + if (s_routerFactoryCache.TryGetValue(executor.GetType(), out var factory)) + { + return factory(); + } + + // If no factory is found, reflect over the handlers + HandlerInfosT handlers = ReflectHandlers(executor.GetType()); + + Dictionary>> boundHandlers = new(); + foreach (Type inType in handlers.Keys) + { + MessageHandlerInfo handlerInfo = handlers[inType]; + Func> boundHandler = handlerInfo.Bind(executor, checkType); + boundHandlers.Add(inType, boundHandler); // TODO: Turn the error here into something more actionable. + } + + return new MessageRouter(boundHandlers); + } + + internal MessageRouter(Dictionary>> handlers) + { + this.BoundHandlers = handlers; + } + + /// + /// . + /// + /// + /// + /// + /// + /// + /// + public async ValueTask RouteMessageAsync(object message, IExecutionContext context, bool requireRoute = true) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message), "Message cannot be null."); + } + + // TODO: Implement base type delegation + CallResult? result = null; + if (this.BoundHandlers.TryGetValue(message.GetType(), out Func>? handler)) + { + result = await handler(message).ConfigureAwait(false); + } + + return result; + } + + public bool CanHandle(Type candidateType) + { + if (candidateType == null) + { + throw new ArgumentNullException(nameof(candidateType), "Candidate type cannot be null."); + } + + // Check if the router can handle the candidate type. + return this.BoundHandlers.ContainsKey(candidateType); + } + + public HashSet IncomingTypes => [.. this.BoundHandlers.Keys]; +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs new file mode 100644 index 0000000000..62edbb4d85 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Reflection; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +internal static class ValueTaskTypeErasure +{ + internal static Func> CreateErasingUnwrapper() + { + return UnwrapAndEraseAsync; + + static async ValueTask UnwrapAndEraseAsync(object maybeValueTask) + { + if (maybeValueTask is ValueTask vt) + { + // If the input is a ValueTask, unwrap it. + TResult result = await vt.ConfigureAwait(false); + return (object?)result; + } + + throw new InvalidOperationException($"Expected ValueTask or ValueTask<{typeof(TResult).Name}>, but got {maybeValueTask.GetType().Name}."); + } + } + +#if NET5_0_OR_GREATER + // This suppression is qualified because for some reason VS is not recognizing the attribute's presence, treating the + // import as an error (due to unnecessary using). + [System.Diagnostics.CodeAnalysis.RequiresDynamicCode("Calls System.Reflection.MethodInfo.MakeGenericMethod(params Type[])")] +#endif + internal static Func> UnwrapperFor(Type resultType) + { + // This method creates a type-erased unwrapper for ValueTask. + // It uses reflection to create a delegate that can handle any TResult type. + + // TODO: AOT: This method is marked with RequiresDynamicCodeAttribute, which will not work well in NativeAOT + // scenarios; the solution is to break this up into a Cached/Reflector version (like the MessageRouter does + // with handlers), and SourceGenerate the UnwrapAndEraseAsync-equivalent method for each TResult type. + + // Note that this is only necessary because ValueTask is a class-generic, rather than an interface + // type, which means that the type cannot be co/contravariantly used (e.g. ValueTask is not a valid + // supertype of ValueTask or ValueTask, T != object?). + + MethodInfo createMethod = + typeof(ValueTaskTypeErasure) + .GetMethod(nameof(CreateErasingUnwrapper), BindingFlags.NonPublic | BindingFlags.Static) + !.MakeGenericMethod(resultType); + + // Invoke createMethod (as static) to get the delegate. + object? maybeUnwrapper = createMethod.Invoke(null, Array.Empty()); + if (maybeUnwrapper is not Func> unwrapper) + { + throw new InvalidOperationException($"Expected a Func> delegate, but got {maybeUnwrapper?.GetType().Name ?? "null"}."); + } + + return unwrapper; + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs new file mode 100644 index 0000000000..cc17f2b335 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs @@ -0,0 +1,353 @@ +// Copyright (c) Microsoft. All rights reserved. + +#pragma warning disable IDE0005 // Using directive is unnecessary. +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Workflows.Core; +#pragma warning restore IDE0005 // Using directive is unnecessary. + +using ConditionalT = System.Func; + +namespace Microsoft.Agents.Orchestration.Workflows; + +internal delegate TExecutor ExecutorProvider() + where TExecutor : Executor; + +internal struct EdgeKey : IEquatable +{ + public string SourceId { get; init; } + public string TargetId { get; init; } + + public EdgeKey(string sourceId, string targetId) + { + this.SourceId = sourceId ?? throw new ArgumentNullException(nameof(sourceId)); + this.TargetId = targetId ?? throw new ArgumentNullException(nameof(targetId)); + } + + public bool Equals(EdgeKey other) => this.SourceId == other.SourceId && this.TargetId == other.TargetId; + public override bool Equals(object? obj) => obj is EdgeKey other && this.Equals(other); + public override int GetHashCode() => HashCode.Combine(this.SourceId, this.TargetId); +} + +/// +/// . +/// +public class ExecutionResult +{ +} + +internal sealed class ExecutorIsh : + IIdentified, + IEquatable, + IEquatable, + IEquatable +{ + public enum Type + { + Unbound, + Executor, + //Function, + //Agent, + //ProcessStep + } + + public Type ExecutorType { get; init; } + + private readonly string? _idValue; + private readonly Executor? _executorValue; + //private readonly Func? _functionValue; + + public ExecutorIsh(Executor executor) + { + this.ExecutorType = Type.Executor; + this._executorValue = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public ExecutorIsh(string id) + { + this.ExecutorType = Type.Unbound; + this._idValue = id ?? throw new ArgumentNullException(nameof(id)); + } + + public bool IsUnbound => this.ExecutorType == Type.Unbound; + + public string Id => this.ExecutorType switch + { + Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), + Type.Executor => this._executorValue!.Id, + //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), + //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), + //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), + _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + }; + + public ExecutorProvider ExecutorProvider => this.ExecutorType switch + { + Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), + Type.Executor => () => this._executorValue!, + //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), + //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), + //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), + _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + }; + + //public ExecutorIsh(Func function) + //{ + // this.ExecutorType = Type.Function; + // this._functionValue = function ?? throw new ArgumentNullException(nameof(function)); + //} + + // Implicit conversions into ExecutorIsh + public static implicit operator ExecutorIsh(Executor executor) + { + return new ExecutorIsh(executor); + } + + // How do we AoT compile this? + //public static implicit operator ExecutorIsh(Func function) + //{ + // return new ExecutorIsh(function); + //} + + public static implicit operator ExecutorIsh(string id) + { + return new ExecutorIsh(id); + } + + public bool Equals(ExecutorIsh? other) + { + return other is not null && + other.Id == this.Id; + } + + public bool Equals(IIdentified? other) + { + return other is not null && + other.Id == this.Id; + } + + public bool Equals(string? other) + { + return other is not null && + other == this.Id; + } + + public override bool Equals(object? obj) + { + if (obj is null) + { + return false; + } + + if (obj is ExecutorIsh ish) + { + return this.Equals(ish); + } + else if (obj is IIdentified identified) + { + return this.Equals(identified); + } + else if (obj is string str) + { + return this.Equals(str); + } + + return false; + } + + public override int GetHashCode() + { + return this.Id.GetHashCode(); + } + + public override string ToString() + { + return this.ExecutorType switch + { + Type.Unbound => $"'{this.Id}':", + Type.Executor => $"'{this.Id}':{this._executorValue!.GetType()}", + //Type.Function => $"ExecutorIsh for Function with ID '{this.Id}'", + //Type.Agent => $"ExecutorIsh for Agent with ID '{this.Id}'", + //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", + _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + }; + } +} + +internal class FlowEdge(ExecutorIsh source, ExecutorIsh sink, ConditionalT? conditional) : IEquatable +{ + public ExecutorIsh Source { get; init; } = source ?? throw new ArgumentNullException(nameof(source)); + public ExecutorIsh Sink { get; } = sink ?? throw new ArgumentNullException(nameof(sink)); + public Func? Condition { get; } = conditional; + + public bool Equals(FlowEdge? other) + { + return other is null + ? false + : this.Source.Equals(other.Source) && this.Sink.Equals(other.Sink); + } + + public override bool Equals(object? obj) => obj is FlowEdge other && this.Equals(other); + public override int GetHashCode() => HashCode.Combine(this.Source.GetHashCode(), this.Sink.GetHashCode()); +} + +internal class Workflow +{ + public Dictionary> Executors { get; internal init; } = new(); + public Dictionary> Edges { get; internal init; } = new(); + +#if NET9_0_OR_GREATER + required +#endif + public string StartExecutorId + { get; init; } + +#if NET9_0_OR_GREATER + required +#endif + public Type InputType + { get; init; } = typeof(object); + + public Workflow(string startExecutorId, Type type) + { + this.StartExecutorId = startExecutorId ?? throw new ArgumentNullException(nameof(startExecutorId)); + this.InputType = type ?? throw new ArgumentNullException(nameof(type)); + + // TODO: How do we (1) ensure the types are happy, and (2) work under AOT? + } + +#if NET9_0_OR_GREATER + public Workflow() + { } +#endif +} + +// Just a decorator for the purposes of keeping type type where we can +internal class Workflow : Workflow +{ + public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) + { + } + +#if NET9_0_OR_GREATER + public Workflow() + { + this.InputType = typeof(T); + } +#endif +} + +internal class WorkflowBuilder +{ + private readonly Dictionary> _executors = new(); + private readonly Dictionary> _edges = new(); + private readonly HashSet _unboundExecutors = new(); + + private readonly string _startExecutorId; + + public WorkflowBuilder(ExecutorIsh start) + { + this._startExecutorId = this.Track(start).Id; + } + + private ExecutorIsh Track(ExecutorIsh executorish) + { + ExecutorProvider provider = executorish.ExecutorProvider; + + // If the executor is unbound, create an entry for it, unless it already exists. + // Otherwise, update the entry for it, and remove the unbound tag + if (executorish.IsUnbound && !this._executors.ContainsKey(executorish.Id)) + { + // If this is an unbound executor, we need to track it separately + this._unboundExecutors.Add(executorish.Id); + this._executors[executorish.Id] = provider; + } + else if (!executorish.IsUnbound) + { + // If we already have an executor with this ID, we need to update it (todo: should we throw on double binding?) + this._executors[executorish.Id] = provider; + } + + return executorish; + } + + private void UpdateExecutor(string id, ExecutorProvider provider) + { + this._executors[id] = provider; + } + + public WorkflowBuilder BindExecutor(Executor executor) + { + if (!this._unboundExecutors.Contains(executor.Id)) + { + throw new InvalidOperationException( + $"Executor with ID '{executor.Id}' is already bound or does not exist in the workflow."); + } + + this._executors[executor.Id] = () => executor; + this._unboundExecutors.Remove(executor.Id); + return this; + } + + public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func? condition = null) + { + // Add an edge from source to target with an optional condition. + // This is a low-level builder method that does not enforce any specific executor type. + // The condition can be used to determine if the edge should be followed based on the input. + + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (target == null) + { + throw new ArgumentNullException(nameof(target)); + } + + if (!this._edges.TryGetValue(source.Id, out HashSet? edges)) + { + edges = new HashSet(); + this._edges[source.Id] = edges; + } + + edges.Add(new FlowEdge(this.Track(source), this.Track(target), condition)); + return this; + } + + public Workflow Build() + { + if (this._unboundExecutors.Count > 0) + { + throw new InvalidOperationException( + $"Workflow cannot be built because there are unbound executors: {string.Join(", ", this._unboundExecutors)}."); + } + + // Grab the start node, and make sure it has the right type? + if (!this._executors.TryGetValue(this._startExecutorId, out ExecutorProvider? startProvider)) + { + // TODO: This should never be able to be hit + throw new InvalidOperationException($"Start executor with ID '{this._startExecutorId}' is not bound."); + } + + // TODO: Delay-instantiate the start executor, and ensure it is of type T. + Executor startExecutor = startProvider(); + + if (!startExecutor.InputTypes.Any(t => t.IsAssignableFrom(typeof(T)))) + { + // We have no handlers for the input type T, which means the built workflow will not be able to + // process messages of the desired type + } + + return new Workflow(this._startExecutorId) // Why does it not see the default ctor? + { + Executors = this._executors, + Edges = this._edges, + StartExecutorId = this._startExecutorId, + InputType = typeof(T) + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs new file mode 100644 index 0000000000..69f7af5712 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Workflows.Core; + +namespace Microsoft.Agents.Orchestration.Workflows; + +internal static class Check +{ + public static T NotNull(T? value, [CallerArgumentExpression(nameof(value))] string? paramExpr = null) where T : class + { + if (value is null) + { + throw new ArgumentNullException(nameof(value), $"Value cannot be null: {paramExpr}"); + } + + return value; + } +} + +internal enum Activation +{ + WhenAll, +} + +internal static class WorkflowBuilderExtensions +{ + public static WorkflowBuilder AddLoop(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh loopBody, Func condition) + { + Check.NotNull(builder); + Check.NotNull(source); + Check.NotNull(loopBody); + Check.NotNull(condition); + + builder.AddEdge(source, loopBody, condition); + builder.AddEdge(loopBody, source); + + return builder; + } + + public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors) + { + Check.NotNull(builder); + Check.NotNull(source); + + for (int i = 0; i < executors.Length; i++) + { + Check.NotNull(executors[i], nameof(executors) + $"[{i}]"); + builder.AddEdge(source, executors[i]); + source = executors[i]; + } + + return builder; + } + + private class FanOutMessage(object message) + { + public object Content = message ?? throw new ArgumentNullException(nameof(message), "Message cannot be null"); + } + + private class FanInMessage(IEnumerable? message = null) + { + public static readonly FanInMessage Pending = new(); + + public bool IsCompleted => this.Result is not null; + public IEnumerable? Result = message; + } + + private class FanOutExecutor : Executor, IMessageHandler + { + public ValueTask HandleAsync(object message, IExecutionContext context) + { + return new ValueTask(new FanOutMessage(message)); + } + } + + public static WorkflowBuilder AddFanOut(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] targets) + { + Check.NotNull(builder); + Check.NotNull(source); + + FanOutExecutor fanOut = new(); + builder.AddEdge(source, fanOut); + + foreach (var target in targets) + { + Check.NotNull(target); + builder.AddEdge(fanOut, target); + } + + return builder; + } + + private class FanInExecutor : Executor, + IMessageHandler + { +#if NET9_0_OR_GREATER + required +#endif + public int SourceCount + { get; init; } + + public Activation Activation { get; init; } = Activation.WhenAll; + + private readonly List _messages = []; + public ValueTask HandleAsync(FanOutMessage message, IExecutionContext context) + { + this._messages.Add(message.Content); + + if (this._messages.Count >= this.SourceCount) + { + return new ValueTask(new FanInMessage(this._messages.ToArray())); + } + + return CompletedValueTaskSource.FromResult(FanInMessage.Pending); + } + } + + private class FanInUnwrapper : Executor, + IMessageHandler> + { + public ValueTask> HandleAsync(FanInMessage message, IExecutionContext context) + { + return CompletedValueTaskSource.FromResult(message.Result!); + } + } + + public static WorkflowBuilder AddFanIn(this WorkflowBuilder builder, ExecutorIsh target, Activation activation = Activation.WhenAll, params ExecutorIsh[] sources) + { + Check.NotNull(builder); + Check.NotNull(target); + + FanInExecutor fanIn = new() + { + Activation = activation, + SourceCount = sources.Length + }; + FanInUnwrapper unwrapper = new(); + + builder.AddEdge(fanIn, unwrapper, IsFanInCompleted); + builder.AddEdge(unwrapper, target); + + foreach (var source in sources) + { + Check.NotNull(source); + builder.AddEdge(source, fanIn); + } + + return builder; + + static bool IsFanInCompleted(object? message) => message is FanInMessage fanIn && fanIn.IsCompleted; + } +} From a3117c75b7e4464819d0602e38769d75df37b195 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 28 Jul 2025 18:59:59 -0400 Subject: [PATCH 100/232] feat: Define IExecutionContext and Events --- .../Workflows/Core/Events.cs | 152 ++++++++++++++++++ .../Workflows/Core/ExecutionContext.cs | 44 ++++- .../Workflows/Core/MessageRouting.cs | 21 +++ .../Workflows/WorkflowBuilderExtensions.cs | 3 +- 4 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs new file mode 100644 index 0000000000..51a23842c8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Orchestration.Workflows.Core; + +/// +/// . +/// +public record WorkflowEvent(object? Data = null); + +/// +/// . +/// +public record WorkflowStartedEvent : WorkflowEvent; + +/// +/// . +/// +public record WorkflowCompletedEvent : WorkflowEvent; + +/// +/// . +/// +public record ExecutorEvent : WorkflowEvent +{ + /// + /// The identifier of the executor that generated this event. + /// +#if NET9_0_OR_GREATER + required +#endif + public string ExecutorId + { get; init; } + + /// + /// . + /// + public ExecutorEvent(string executorId, object? data = null) : base(data) + { + this.ExecutorId = executorId ?? throw new ArgumentNullException(nameof(executorId), "Executor ID cannot be null."); + } + +#if NET9_0_OR_GREATER + /// + /// . + /// + public ExecutorEvent() + { } +#endif +} + +/// +/// . +/// +public record ExecutorInvokeEvent : ExecutorEvent +{ + /// + /// . + /// + public ExecutorInvokeEvent(string executorId, object? data = null) : base(data) + { + this.ExecutorId = executorId ?? throw new ArgumentNullException(nameof(executorId), "Executor ID cannot be null."); + } + +#if NET9_0_OR_GREATER + /// + /// . + /// + public ExecutorInvokeEvent() + { } +#endif +} + +/// +/// . +/// +public record ExecutorCompleteEvent : ExecutorEvent +{ + /// + /// . + /// + public ExecutorCompleteEvent(string executorId, object? data = null) : base(data) + { + this.ExecutorId = executorId ?? throw new ArgumentNullException(nameof(executorId), "Executor ID cannot be null."); + } + +#if NET9_0_OR_GREATER + /// + /// . + /// + public ExecutorCompleteEvent() + { } +#endif +} + +// TODO: This is a placeholder for streaming chat message content. +/// +/// . +/// +public class StreamingChatMessageContent +{ } + +/// +/// . +/// +public record AgentRunStreamingEvent : ExecutorEvent +{ + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the executor that generated this event. + /// + public AgentRunStreamingEvent(string executorId, StreamingChatMessageContent? content = null) : base(executorId, data: content) + { + this.Content = content; + } + + /// + /// Gets the content of the streaming chat message. + /// + public StreamingChatMessageContent? Content { get; } +} + +// TODO: This is a placeholder for non-streaming chat message content. +/// +/// . +/// +public class ChatMessageContent +{ +} + +/// +/// . +/// +public record AgentRunEvent : ExecutorEvent +{ + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the executor that generated this event. + /// + public AgentRunEvent(string executorId, ChatMessageContent? content = null) : base(executorId, data: content) + { + this.Content = content; + } + + /// + /// Gets the content of the chat message. + /// + public ChatMessageContent? Content { get; } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs index 6a1d3f8415..60beaa88ee 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; using System.Threading.Tasks; namespace Microsoft.Agents.Orchestration.Workflows.Core; @@ -10,8 +11,45 @@ namespace Microsoft.Agents.Orchestration.Workflows.Core; public interface IExecutionContext { /// - /// . + /// Send a message from the executor to the context. /// - /// - Task MagicAsync(); + /// The id of the sender of the message. + /// The message to be sent. + /// A representing the asynchronous operation. + ValueTask SendMessageAsync(string sourceId, object message); + + /// + /// Drain all messages from the context. + /// + /// A representing the asynchronous operation, containing + /// a dictionary mapping executor IDs to lists of messages. + ValueTask>> DrainMessagesAsync(); + + /// + /// Check if there are any message in the context. + /// + /// A representing the asynchronous operation, containing + /// true if there are messages. false if there are not. + ValueTask HasMessagesAsync(); + + /// + /// Add an event to the execution context. + /// + /// The event to be added. + /// A representing the asynchronous operation. + ValueTask AddEventAsync(WorkflowEvent workflowEvent); + + /// + /// Drain all events from the context. + /// + /// A representing the asynchronous operation, containing + /// a list of all events. + ValueTask> DrainEventsAsync(); + + /// + /// Check if there are any events in the context. + /// + /// A representing the asynchronous operation, containing + /// true if there are events. false if there are not. + ValueTask HasEventsAsync(); } diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs index 4fc27244d8..53a22b88d4 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs @@ -16,6 +16,27 @@ namespace Microsoft.Agents.Orchestration.Workflows.Core; +/// +/// This attribute indicates that a message handler streams messages during its execution. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public sealed class StreamsMessageAttribute : Attribute +{ + /// + /// The type of the message that the handler yields. + /// + public Type Type { get; } + + /// + /// Indicates that the message handler yields streaming messages during the course of execution. + /// + public StreamsMessageAttribute(Type type) + { + // This attribute is used to mark executors that yield messages. + this.Type = type ?? throw new ArgumentNullException(nameof(type), "Type cannot be null."); + } +} + /// /// This class represents the result of a call to a /// or . diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs index 69f7af5712..a0084486b9 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs @@ -28,12 +28,11 @@ internal enum Activation internal static class WorkflowBuilderExtensions { - public static WorkflowBuilder AddLoop(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh loopBody, Func condition) + public static WorkflowBuilder AddLoop(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh loopBody, Func? condition = null) { Check.NotNull(builder); Check.NotNull(source); Check.NotNull(loopBody); - Check.NotNull(condition); builder.AddEdge(source, loopBody, condition); builder.AddEdge(loopBody, source); From 610b5e613ef0d60ea610d467163ae324bb9c4df0 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 28 Jul 2025 19:00:15 -0400 Subject: [PATCH 101/232] feat: Simple Workflow Demos --- .../Sample/02_Simple_Workflow_Sequential.cs | 42 +++++++++ .../Sample/02a_Simple_Workflow_Condition.cs | 85 +++++++++++++++++ .../Sample/02b_Simple_Workflow_Loop.cs | 93 +++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs create mode 100644 dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs new file mode 100644 index 0000000000..66fc4c5c85 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Workflows.Core; + +namespace Microsoft.Agents.Orchestration.Workflows.Sample; + +internal static class Step2EntryPoint +{ + public static ValueTask RunAsync() + { + UppercaseExecutor uppercase = new(); + ReverseTextExecutor reverse = new(); + + WorkflowBuilder builder = new(uppercase); + builder.AddEdge(uppercase, reverse); + + Workflow workflow = builder.Build(); + // async foreach (var event in workflow.RunAsync("hello world")) + // await Console.Out.WriteLineAsync(event); + + return CompletedValueTaskSource.Completed; + } +} + +internal class UppercaseExecutor : Executor, IMessageHandler +{ + public ValueTask HandleAsync(string message, IExecutionContext context) + { + return CompletedValueTaskSource.FromResult(message.ToUpperInvariant()); + } +} + +internal class ReverseTextExecutor : Executor, IMessageHandler +{ + public ValueTask HandleAsync(string message, IExecutionContext context) + { + char[] charArray = message.ToCharArray(); + System.Array.Reverse(charArray); + return CompletedValueTaskSource.FromResult(new string(charArray)); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs new file mode 100644 index 0000000000..47c8620ade --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Workflows.Core; + +namespace Microsoft.Agents.Orchestration.Workflows.Sample; + +internal static class Step2aEntryPoint +{ + public static ValueTask RunAsync() + { + string[] spamKeywords = { "spam", "advertisement", "offer" }; + + DetectSpamExecutor detectSpam = new(spamKeywords); + RespondToMessageExecutor respondToMessage = new(); + RemoveSpamExecutor removeSpam = new(); + + Workflow workflow = new WorkflowBuilder(detectSpam) + .AddEdge(detectSpam, respondToMessage, isSpam => isSpam is true) // If not spam, respond + .AddEdge(detectSpam, removeSpam, isSpam => isSpam is false) // If spam, remove + .Build(); + + // async foreach (var event in workflow.RunAsync("This is a spam message.")) + // await Console.Out.WriteLineAsync(event); + + return CompletedValueTaskSource.Completed; + } +} + +internal class DetectSpamExecutor : Executor, IMessageHandler +{ + public string[] SpamKeywords { get; } + + public DetectSpamExecutor(params string[] spamKeywords) + { + this.SpamKeywords = spamKeywords; + } + + public ValueTask HandleAsync(string message, IExecutionContext context) + { +#if NET5_0_OR_GREATER + bool isSpam = this.SpamKeywords.Any(keyword => message.Contains(keyword, StringComparison.OrdinalIgnoreCase)); +#else + bool isSpam = this.SpamKeywords.Any(keyword => message.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0); +#endif + + return CompletedValueTaskSource.FromResult(isSpam); + } +} + +internal class RespondToMessageExecutor : Executor, IMessageHandler +{ + public async ValueTask HandleAsync(bool message, IExecutionContext context) + { + if (message) + { + // This is SPAM, and should not have been routed here + throw new InvalidOperationException("Received a spam message that should not be getting a reply."); + } + + await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay + + await context.AddEventAsync(new WorkflowCompletedEvent { Data = "Message processed successfully." }) + .ConfigureAwait(false); + } +} + +internal class RemoveSpamExecutor : Executor, IMessageHandler +{ + public async ValueTask HandleAsync(bool message, IExecutionContext context) + { + if (!message) + { + // This is NOT SPAM, and should not have been routed here + throw new InvalidOperationException("Received a non-spam message that should not be getting removed."); + } + + await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay + + await context.AddEventAsync(new WorkflowCompletedEvent { Data = "Spam message removed." }) + .ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs new file mode 100644 index 0000000000..328b20702f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Orchestration.Workflows.Core; + +namespace Microsoft.Agents.Orchestration.Workflows.Sample; + +internal static class Step2bEntryPoint +{ + public static ValueTask RunAsync() + { + GuessNumberExecutor guessNumber = new(1, 100); + JudgeExecutor judge = new(42); // Let's say the target number is 42 + + Workflow workflow = new WorkflowBuilder(guessNumber) + .AddLoop(guessNumber, judge) + .Build(); + + // async foreach (var event in workflow.RunAsync(NumberSignal.Init)) + // await Console.Out.WriteLineAsync(event); + + return CompletedValueTaskSource.Completed; + } +} + +internal enum NumberSignal +{ + Init, + Above, + Below, + Matched +} + +internal class GuessNumberExecutor : Executor, IMessageHandler +{ + public int LowerBound { get; private set; } + public int UpperBound { get; private set; } + + public GuessNumberExecutor(int lowerBound, int upperBound) + { + this.LowerBound = lowerBound; + this.UpperBound = upperBound; + } + + private int NextGuess => (this.LowerBound + this.UpperBound) / 2; + + private int _currGuess = -1; + public async ValueTask HandleAsync(NumberSignal message, IExecutionContext context) + { + switch (message) + { + case NumberSignal.Matched: + await context.AddEventAsync(new WorkflowCompletedEvent { Data = $"Guessed the number: {this._currGuess}" }) + .ConfigureAwait(false); + break; + + case NumberSignal.Above: + this.UpperBound = this._currGuess - 1; + break; + case NumberSignal.Below: + this.LowerBound = this._currGuess + 1; + break; + } + + return this._currGuess = this.NextGuess; + } +} + +internal class JudgeExecutor : Executor, IMessageHandler +{ + private readonly int _targetNumber; + + public JudgeExecutor(int targetNumber) + { + this._targetNumber = targetNumber; + } + + public ValueTask HandleAsync(int message, IExecutionContext context) + { + if (message == this._targetNumber) + { + return CompletedValueTaskSource.FromResult(NumberSignal.Matched); + } + else if (message < this._targetNumber) + { + return CompletedValueTaskSource.FromResult(NumberSignal.Below); + } + else + { + return CompletedValueTaskSource.FromResult(NumberSignal.Above); + } + } +} From 2cde301ab1cc3dce039ce81d883ffe600e137d5c Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 29 Jul 2025 15:28:41 -0400 Subject: [PATCH 102/232] refactor: Move Workflows classes to separate assembly --- dotnet/agent-framework-dotnet.slnx | 2 ++ .../Core/CompletedValueTaskSource.cs | 2 +- .../Core/DisposableObject.cs | 2 +- .../Core/Events.cs | 12 ++----- .../Core/ExecutionContext.cs | 2 +- .../Core/Executor.cs | 2 +- .../Core/Message.cs | 2 +- .../Core/MessageHandler.cs | 2 +- .../Core/MessageRouting.cs | 4 +-- .../Core/TypeErasure.cs | 2 +- .../Microsoft.Agents.Workflow.csproj | 32 +++++++++++++++++++ .../WorkflowBuilder.cs | 4 +-- .../WorkflowBuilderExtensions.cs | 4 +-- ...Microsoft.Agents.Workflow.UnitTests.csproj | 17 ++++++++++ .../Sample/02_Simple_Workflow_Sequential.cs | 8 ++--- .../Sample/02a_Simple_Workflow_Condition.cs | 10 +++--- .../Sample/02b_Simple_Workflow_Loop.cs | 8 ++--- 17 files changed, 80 insertions(+), 35 deletions(-) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/CompletedValueTaskSource.cs (91%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/DisposableObject.cs (97%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/Events.cs (89%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/ExecutionContext.cs (97%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/Executor.cs (99%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/Message.cs (98%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/MessageHandler.cs (96%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/MessageRouting.cs (99%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/Core/TypeErasure.cs (97%) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/WorkflowBuilder.cs (99%) rename dotnet/src/{Microsoft.Agents.Orchestration/Workflows => Microsoft.Agents.Workflow}/WorkflowBuilderExtensions.cs (97%) create mode 100644 dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj rename dotnet/{src/Microsoft.Agents.Orchestration/Workflows => tests/Microsoft.Agents.Workflow.UnitTests}/Sample/02_Simple_Workflow_Sequential.cs (79%) rename dotnet/{src/Microsoft.Agents.Orchestration/Workflows => tests/Microsoft.Agents.Workflow.UnitTests}/Sample/02a_Simple_Workflow_Condition.cs (88%) rename dotnet/{src/Microsoft.Agents.Orchestration/Workflows => tests/Microsoft.Agents.Workflow.UnitTests}/Sample/02b_Simple_Workflow_Loop.cs (89%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 6420da7f0a..91cbf5db1f 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -116,6 +116,7 @@ + @@ -128,6 +129,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs similarity index 91% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs index e33b614d20..543ead1e98 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/CompletedValueTaskSource.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs @@ -2,7 +2,7 @@ using System.Threading.Tasks; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// Helper class to work around lack of proper ValueTask support in .NET Framework. diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs similarity index 97% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs index d147b7d78d..a754870660 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/DisposableObject.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs @@ -5,7 +5,7 @@ using System.Threading; using System.Threading.Tasks; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// Provides a base class implementing the interface using diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs similarity index 89% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs index 51a23842c8..041944ea36 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs @@ -2,7 +2,7 @@ using System; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// . @@ -58,10 +58,7 @@ public record ExecutorInvokeEvent : ExecutorEvent /// /// . /// - public ExecutorInvokeEvent(string executorId, object? data = null) : base(data) - { - this.ExecutorId = executorId ?? throw new ArgumentNullException(nameof(executorId), "Executor ID cannot be null."); - } + public ExecutorInvokeEvent(string executorId, object? data = null) : base(executorId, data) { } #if NET9_0_OR_GREATER /// @@ -80,10 +77,7 @@ public record ExecutorCompleteEvent : ExecutorEvent /// /// . /// - public ExecutorCompleteEvent(string executorId, object? data = null) : base(data) - { - this.ExecutorId = executorId ?? throw new ArgumentNullException(nameof(executorId), "Executor ID cannot be null."); - } + public ExecutorCompleteEvent(string executorId, object? data = null) : base(executorId, data) { } #if NET9_0_OR_GREATER /// diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs similarity index 97% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs index 60beaa88ee..2c165505eb 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/ExecutionContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Threading.Tasks; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// Provides services for subclasses. diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs similarity index 99% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index d5ea990084..46084d5afc 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -8,7 +8,7 @@ using System.Reflection; using System.Threading.Tasks; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// A tag interface for objects that have a unique identifier within an appropriate namespace. diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs similarity index 98% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs index 3b8eda2482..c3c6bd2bbe 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/Message.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs @@ -9,7 +9,7 @@ using RetryExceptionT = System.InvalidOperationException; using TopicId = string; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// . diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs similarity index 96% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs index 009a5aabc6..a02c1fa042 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageHandler.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs @@ -2,7 +2,7 @@ using System.Threading.Tasks; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// A message handler interface for handling messages of type . diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs similarity index 99% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs index 53a22b88d4..9818599161 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/MessageRouting.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs @@ -11,10 +11,10 @@ using HandlerInfosT = System.Collections.Generic.Dictionary< System.Type, - Microsoft.Agents.Orchestration.Workflows.Core.MessageHandlerInfo + Microsoft.Agents.Workflows.Core.MessageHandlerInfo >; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; /// /// This attribute indicates that a message handler streams messages during its execution. diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/TypeErasure.cs similarity index 97% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/TypeErasure.cs index 62edbb4d85..8166a7a5c6 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Core/TypeErasure.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/TypeErasure.cs @@ -4,7 +4,7 @@ using System.Reflection; using System.Threading.Tasks; -namespace Microsoft.Agents.Orchestration.Workflows.Core; +namespace Microsoft.Agents.Workflows.Core; internal static class ValueTaskTypeErasure { diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj new file mode 100644 index 0000000000..8d7a3265b0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj @@ -0,0 +1,32 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + Microsoft.Agents.Workflow + alpha + + + + true + true + + + + + + + Microsoft Agent Workflow Framework + Contains the Microsoft Agent Workflow Framework. + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs similarity index 99% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs rename to dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index cc17f2b335..d7167c53c6 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -6,12 +6,12 @@ using System.Linq; using System.Text; using System.Threading.Tasks; -using Microsoft.Agents.Orchestration.Workflows.Core; +using Microsoft.Agents.Workflows.Core; #pragma warning restore IDE0005 // Using directive is unnecessary. using ConditionalT = System.Func; -namespace Microsoft.Agents.Orchestration.Workflows; +namespace Microsoft.Agents.Workflows; internal delegate TExecutor ExecutorProvider() where TExecutor : Executor; diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs similarity index 97% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs rename to dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index a0084486b9..b0a14f0f0e 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -4,9 +4,9 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Threading.Tasks; -using Microsoft.Agents.Orchestration.Workflows.Core; +using Microsoft.Agents.Workflows.Core; -namespace Microsoft.Agents.Orchestration.Workflows; +namespace Microsoft.Agents.Workflows; internal static class Check { diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj new file mode 100644 index 0000000000..f4e4b48d84 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj @@ -0,0 +1,17 @@ + + + + $(ProjectsTargetFrameworks) + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs similarity index 79% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs rename to dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs index 66fc4c5c85..d9af8f9f3a 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System.Threading.Tasks; -using Microsoft.Agents.Orchestration.Workflows.Core; +using Microsoft.Agents.Workflows.Core; -namespace Microsoft.Agents.Orchestration.Workflows.Sample; +namespace Microsoft.Agents.Workflows.Sample; internal static class Step2EntryPoint { @@ -23,7 +23,7 @@ public static ValueTask RunAsync() } } -internal class UppercaseExecutor : Executor, IMessageHandler +internal sealed class UppercaseExecutor : Executor, IMessageHandler { public ValueTask HandleAsync(string message, IExecutionContext context) { @@ -31,7 +31,7 @@ public ValueTask HandleAsync(string message, IExecutionContext context) } } -internal class ReverseTextExecutor : Executor, IMessageHandler +internal sealed class ReverseTextExecutor : Executor, IMessageHandler { public ValueTask HandleAsync(string message, IExecutionContext context) { diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs similarity index 88% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs rename to dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs index 47c8620ade..42f23a7dd4 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02a_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs @@ -3,9 +3,9 @@ using System; using System.Linq; using System.Threading.Tasks; -using Microsoft.Agents.Orchestration.Workflows.Core; +using Microsoft.Agents.Workflows.Core; -namespace Microsoft.Agents.Orchestration.Workflows.Sample; +namespace Microsoft.Agents.Workflows.Sample; internal static class Step2aEntryPoint { @@ -29,7 +29,7 @@ public static ValueTask RunAsync() } } -internal class DetectSpamExecutor : Executor, IMessageHandler +internal sealed class DetectSpamExecutor : Executor, IMessageHandler { public string[] SpamKeywords { get; } @@ -50,7 +50,7 @@ public ValueTask HandleAsync(string message, IExecutionContext context) } } -internal class RespondToMessageExecutor : Executor, IMessageHandler +internal sealed class RespondToMessageExecutor : Executor, IMessageHandler { public async ValueTask HandleAsync(bool message, IExecutionContext context) { @@ -67,7 +67,7 @@ await context.AddEventAsync(new WorkflowCompletedEvent { Data = "Message process } } -internal class RemoveSpamExecutor : Executor, IMessageHandler +internal sealed class RemoveSpamExecutor : Executor, IMessageHandler { public async ValueTask HandleAsync(bool message, IExecutionContext context) { diff --git a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs similarity index 89% rename from dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs rename to dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs index 328b20702f..c235352725 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/Workflows/Sample/02b_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System.Threading.Tasks; -using Microsoft.Agents.Orchestration.Workflows.Core; +using Microsoft.Agents.Workflows.Core; -namespace Microsoft.Agents.Orchestration.Workflows.Sample; +namespace Microsoft.Agents.Workflows.Sample; internal static class Step2bEntryPoint { @@ -31,7 +31,7 @@ internal enum NumberSignal Matched } -internal class GuessNumberExecutor : Executor, IMessageHandler +internal sealed class GuessNumberExecutor : Executor, IMessageHandler { public int LowerBound { get; private set; } public int UpperBound { get; private set; } @@ -66,7 +66,7 @@ await context.AddEventAsync(new WorkflowCompletedEvent { Data = $"Guessed the nu } } -internal class JudgeExecutor : Executor, IMessageHandler +internal sealed class JudgeExecutor : Executor, IMessageHandler { private readonly int _targetNumber; From ab3f250cd01001c567a3e61e8914f54ab6d76b61 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 30 Jul 2025 12:34:40 -0400 Subject: [PATCH 103/232] feat: Move FanOut/In to LowLevel API with new semantics --- .../WorkflowBuilder.cs | 165 +++++++++++++++--- .../WorkflowBuilderExtensions.cs | 133 +------------- 2 files changed, 143 insertions(+), 155 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index d7167c53c6..f62008f15f 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -7,6 +7,8 @@ using System.Text; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; + #pragma warning restore IDE0005 // Using directive is unnecessary. using ConditionalT = System.Func; @@ -16,21 +18,21 @@ namespace Microsoft.Agents.Workflows; internal delegate TExecutor ExecutorProvider() where TExecutor : Executor; -internal struct EdgeKey : IEquatable -{ - public string SourceId { get; init; } - public string TargetId { get; init; } +//internal struct EdgeKey : IEquatable +//{ +// public string SourceId { get; init; } +// public string TargetId { get; init; } - public EdgeKey(string sourceId, string targetId) - { - this.SourceId = sourceId ?? throw new ArgumentNullException(nameof(sourceId)); - this.TargetId = targetId ?? throw new ArgumentNullException(nameof(targetId)); - } +// public EdgeKey(string sourceId, string targetId) +// { +// this.SourceId = sourceId ?? throw new ArgumentNullException(nameof(sourceId)); +// this.TargetId = targetId ?? throw new ArgumentNullException(nameof(targetId)); +// } - public bool Equals(EdgeKey other) => this.SourceId == other.SourceId && this.TargetId == other.TargetId; - public override bool Equals(object? obj) => obj is EdgeKey other && this.Equals(other); - public override int GetHashCode() => HashCode.Combine(this.SourceId, this.TargetId); -} +// public bool Equals(EdgeKey other) => this.SourceId == other.SourceId && this.TargetId == other.TargetId; +// public override bool Equals(object? obj) => obj is EdgeKey other && this.Equals(other); +// public override int GetHashCode() => HashCode.Combine(this.SourceId, this.TargetId); +//} /// /// . @@ -177,6 +179,83 @@ public override string ToString() } } +internal record DirectEdgeData( + ExecutorIsh Source, + ExecutorIsh Sink, + Func? Condition) +{ + public static implicit operator FlowEdgeEx(DirectEdgeData data) + { + return new FlowEdgeEx(data); + } +} + +internal record FanOutEdgeData( + ExecutorIsh Source, + IEnumerable Sinks, + Func>? Partitioner) // TODO: Should this be IList (to imply an ordering?)? +{ + public static implicit operator FlowEdgeEx(FanOutEdgeData data) + { + return new FlowEdgeEx(data); + } +} + +internal enum FanInTrigger +{ + WhenAll, + WhenAny +} + +internal record FanInEdgeData( + IEnumerable Sources, + ExecutorIsh Sink, + FanInTrigger Trigger = FanInTrigger.WhenAll) +{ + public static implicit operator FlowEdgeEx(FanInEdgeData data) + { + return new FlowEdgeEx(data); + } +} + +internal class FlowEdgeEx +{ + public enum Type + { + Direct, + FanOut, + FanIn + } + + public Type EdgeType { get; init; } + public object Data { get; init; } + + public FlowEdgeEx(DirectEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.Direct; + } + + public FlowEdgeEx(FanOutEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.FanOut; + } + + public FlowEdgeEx(FanInEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.FanIn; + } + + public DirectEdgeData? DirectEdgeData => this.Data as DirectEdgeData; + public FanOutEdgeData? FanOutEdgeData => this.Data as FanOutEdgeData; + public FanInEdgeData? FanInEdgeData => this.Data as FanInEdgeData; +} + internal class FlowEdge(ExecutorIsh source, ExecutorIsh sink, ConditionalT? conditional) : IEquatable { public ExecutorIsh Source { get; init; } = source ?? throw new ArgumentNullException(nameof(source)); @@ -197,7 +276,7 @@ public bool Equals(FlowEdge? other) internal class Workflow { public Dictionary> Executors { get; internal init; } = new(); - public Dictionary> Edges { get; internal init; } = new(); + public Dictionary> Edges { get; internal init; } = new(); #if NET9_0_OR_GREATER required @@ -243,7 +322,7 @@ public Workflow() internal class WorkflowBuilder { private readonly Dictionary> _executors = new(); - private readonly Dictionary> _edges = new(); + private readonly Dictionary> _edges = new(); private readonly HashSet _unboundExecutors = new(); private readonly string _startExecutorId; @@ -292,29 +371,57 @@ public WorkflowBuilder BindExecutor(Executor executor) return this; } + private HashSet EnsureEdgesFor(string sourceId) + { + // Ensure that there is a set of edges for the given source ID. + // If it does not exist, create a new one. + if (!this._edges.TryGetValue(sourceId, out HashSet? edges)) + { + this._edges[sourceId] = edges = new HashSet(); + } + + return edges; + } + public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func? condition = null) { // Add an edge from source to target with an optional condition. // This is a low-level builder method that does not enforce any specific executor type. // The condition can be used to determine if the edge should be followed based on the input. + Throw.IfNull(source); + Throw.IfNull(target); - if (source == null) - { - throw new ArgumentNullException(nameof(source)); - } + this.EnsureEdgesFor(source.Id) + .Add(new DirectEdgeData(this.Track(source), this.Track(target), condition)); - if (target == null) - { - throw new ArgumentNullException(nameof(target)); - } + return this; + } - if (!this._edges.TryGetValue(source.Id, out HashSet? edges)) - { - edges = new HashSet(); - this._edges[source.Id] = edges; - } + public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func>? partitioner = null, params ExecutorIsh[] targets) + { + Throw.IfNull(source); + Throw.IfNullOrEmpty(targets); + + this.EnsureEdgesFor(source.Id) + .Add(new FanOutEdgeData( + this.Track(source), + targets.Select(target => this.Track(target)), + partitioner)); + + return this; + } + + public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = default, params ExecutorIsh[] sources) + { + Throw.IfNull(target); + Throw.IfNullOrEmpty(sources); + + this.EnsureEdgesFor(target.Id) + .Add(new FanInEdgeData( + sources.Select(source => this.Track(source)), + this.Track(target), + trigger)); - edges.Add(new FlowEdge(this.Track(source), this.Track(target), condition)); return this; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index b0a14f0f0e..1ce2649e2e 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -1,38 +1,17 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; -internal static class Check -{ - public static T NotNull(T? value, [CallerArgumentExpression(nameof(value))] string? paramExpr = null) where T : class - { - if (value is null) - { - throw new ArgumentNullException(nameof(value), $"Value cannot be null: {paramExpr}"); - } - - return value; - } -} - -internal enum Activation -{ - WhenAll, -} - internal static class WorkflowBuilderExtensions { public static WorkflowBuilder AddLoop(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh loopBody, Func? condition = null) { - Check.NotNull(builder); - Check.NotNull(source); - Check.NotNull(loopBody); + Throw.IfNull(builder); + Throw.IfNull(source); + Throw.IfNull(loopBody); builder.AddEdge(source, loopBody, condition); builder.AddEdge(loopBody, source); @@ -42,114 +21,16 @@ public static WorkflowBuilder AddLoop(this WorkflowBuilder builder, ExecutorIsh public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors) { - Check.NotNull(builder); - Check.NotNull(source); + Throw.IfNull(builder); + Throw.IfNull(source); for (int i = 0; i < executors.Length; i++) { - Check.NotNull(executors[i], nameof(executors) + $"[{i}]"); + Throw.IfNull(executors[i], nameof(executors) + $"[{i}]"); builder.AddEdge(source, executors[i]); source = executors[i]; } return builder; } - - private class FanOutMessage(object message) - { - public object Content = message ?? throw new ArgumentNullException(nameof(message), "Message cannot be null"); - } - - private class FanInMessage(IEnumerable? message = null) - { - public static readonly FanInMessage Pending = new(); - - public bool IsCompleted => this.Result is not null; - public IEnumerable? Result = message; - } - - private class FanOutExecutor : Executor, IMessageHandler - { - public ValueTask HandleAsync(object message, IExecutionContext context) - { - return new ValueTask(new FanOutMessage(message)); - } - } - - public static WorkflowBuilder AddFanOut(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] targets) - { - Check.NotNull(builder); - Check.NotNull(source); - - FanOutExecutor fanOut = new(); - builder.AddEdge(source, fanOut); - - foreach (var target in targets) - { - Check.NotNull(target); - builder.AddEdge(fanOut, target); - } - - return builder; - } - - private class FanInExecutor : Executor, - IMessageHandler - { -#if NET9_0_OR_GREATER - required -#endif - public int SourceCount - { get; init; } - - public Activation Activation { get; init; } = Activation.WhenAll; - - private readonly List _messages = []; - public ValueTask HandleAsync(FanOutMessage message, IExecutionContext context) - { - this._messages.Add(message.Content); - - if (this._messages.Count >= this.SourceCount) - { - return new ValueTask(new FanInMessage(this._messages.ToArray())); - } - - return CompletedValueTaskSource.FromResult(FanInMessage.Pending); - } - } - - private class FanInUnwrapper : Executor, - IMessageHandler> - { - public ValueTask> HandleAsync(FanInMessage message, IExecutionContext context) - { - return CompletedValueTaskSource.FromResult(message.Result!); - } - } - - public static WorkflowBuilder AddFanIn(this WorkflowBuilder builder, ExecutorIsh target, Activation activation = Activation.WhenAll, params ExecutorIsh[] sources) - { - Check.NotNull(builder); - Check.NotNull(target); - - FanInExecutor fanIn = new() - { - Activation = activation, - SourceCount = sources.Length - }; - FanInUnwrapper unwrapper = new(); - - builder.AddEdge(fanIn, unwrapper, IsFanInCompleted); - builder.AddEdge(unwrapper, target); - - foreach (var source in sources) - { - Check.NotNull(source); - builder.AddEdge(source, fanIn); - } - - return builder; - - static bool IsFanInCompleted(object? message) => message is FanInMessage fanIn && fanIn.IsCompleted; - } } From 912dddba3a546d575724de47a2f11bed91adc403 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 4 Aug 2025 13:51:54 -0400 Subject: [PATCH 104/232] feat: Implement Local Execution --- .../Core/CompletedValueTaskSource.cs | 4 - .../Microsoft.Agents.Workflow/Core/Edges.cs | 89 +++++ .../Core/ExecutionContext.cs | 55 --- .../Core/Executor.cs | 8 +- .../Core/IWorkflowContext.cs | 27 ++ .../Core/MessageHandler.cs | 4 +- .../Core/MessageRouting.cs | 25 +- .../Core/Workflow.cs | 78 ++++ .../Execution/EdgeRunner.cs | 171 +++++++++ .../Execution/IRunnerContext.cs | 18 + .../Execution/Identity.cs | 60 ++++ .../Execution/LocalRunner.cs | 191 ++++++++++ .../Execution/LocalRunnerContext.cs | 79 ++++ .../Execution/StepContext.cs | 24 ++ .../ExecutionResult.cs | 10 + .../Microsoft.Agents.Workflow/ExecutorIsh.cs | 144 ++++++++ .../Microsoft.Agents.Workflow.csproj | 7 + .../OutputCollectorExecutor.cs | 30 ++ .../StreamingAggregators.cs | 64 ++++ .../WorkflowBuilder.cs | 339 ++---------------- .../WorkflowBuilderExtensions.cs | 36 ++ .../Sample/02_Simple_Workflow_Sequential.cs | 4 +- .../Sample/02a_Simple_Workflow_Condition.cs | 6 +- .../Sample/02b_Simple_Workflow_Loop.cs | 4 +- 24 files changed, 1077 insertions(+), 400 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs index 543ead1e98..2c8cec1e81 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs @@ -18,10 +18,6 @@ internal static class CompletedValueTaskSource internal static ValueTask FromResult(T result) { -#if NET5_0_OR_GREATER return new ValueTask(result); -#else - return new ValueTask(Task.FromResult(result)); -#endif } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs new file mode 100644 index 0000000000..b17d8ebb54 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +using PredicateT = System.Func; +using PartitionerT = System.Func>; +using System; + +namespace Microsoft.Agents.Workflows.Core; + +internal record DirectEdgeData( + string SourceId, + string SinkId, + PredicateT? Condition = null) +{ + public static implicit operator FlowEdge(DirectEdgeData data) + { + return new FlowEdge(data); + } +} + +internal record FanOutEdgeData( + string SourceId, + List SinkIds, + PartitionerT? Partitioner = null) +{ + public static implicit operator FlowEdge(FanOutEdgeData data) + { + return new FlowEdge(data); + } +} + +internal enum FanInTrigger +{ + WhenAll, + WhenAny +} + +internal record FanInEdgeData( + IEnumerable SourceIds, + string SinkId, + FanInTrigger Trigger = FanInTrigger.WhenAll) +{ + internal Guid UniqueKey { get; } = Guid.NewGuid(); + + public static implicit operator FlowEdge(FanInEdgeData data) + { + return new FlowEdge(data); + } +} + +internal class FlowEdge +{ + public enum Type + { + Direct, + FanOut, + FanIn + } + + public Type EdgeType { get; init; } + public object Data { get; init; } + + public FlowEdge(DirectEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.Direct; + } + + public FlowEdge(FanOutEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.FanOut; + } + + public FlowEdge(FanInEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.FanIn; + } + + public DirectEdgeData? DirectEdgeData => this.Data as DirectEdgeData; + public FanOutEdgeData? FanOutEdgeData => this.Data as FanOutEdgeData; + public FanInEdgeData? FanInEdgeData => this.Data as FanInEdgeData; +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs deleted file mode 100644 index 2c165505eb..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutionContext.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// Provides services for subclasses. -/// -public interface IExecutionContext -{ - /// - /// Send a message from the executor to the context. - /// - /// The id of the sender of the message. - /// The message to be sent. - /// A representing the asynchronous operation. - ValueTask SendMessageAsync(string sourceId, object message); - - /// - /// Drain all messages from the context. - /// - /// A representing the asynchronous operation, containing - /// a dictionary mapping executor IDs to lists of messages. - ValueTask>> DrainMessagesAsync(); - - /// - /// Check if there are any message in the context. - /// - /// A representing the asynchronous operation, containing - /// true if there are messages. false if there are not. - ValueTask HasMessagesAsync(); - - /// - /// Add an event to the execution context. - /// - /// The event to be added. - /// A representing the asynchronous operation. - ValueTask AddEventAsync(WorkflowEvent workflowEvent); - - /// - /// Drain all events from the context. - /// - /// A representing the asynchronous operation, containing - /// a list of all events. - ValueTask> DrainEventsAsync(); - - /// - /// Check if there are any events in the context. - /// - /// A representing the asynchronous operation, containing - /// true if there are events. false if there are not. - ValueTask HasEventsAsync(); -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 46084d5afc..1767c0cbaa 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -100,7 +100,7 @@ public abstract class Executor : DisposableObject, IIdentified /// public string Name { get; } - private MessageRouter MessageRouter { get; init; } + internal MessageRouter MessageRouter { get; init; } private Dictionary State { get; } = new(); /// @@ -124,7 +124,7 @@ protected Executor(string? id = null, string? name = null) /// /// /// - public async ValueTask ExecuteAsync(object message, IExecutionContext context) + public async ValueTask ExecuteAsync(object message, IWorkflowContext context) { CallResult? result = await this.MessageRouter.RouteMessageAsync(message, context, requireRoute: true) .ConfigureAwait(false); @@ -173,7 +173,7 @@ protected Executor(string? id = null, string? name = null) /// /// /// - public async ValueTask InitializeAsync(IExecutionContext context) + public async ValueTask InitializeAsync(IWorkflowContext context) { if (this._initialized) { @@ -248,7 +248,7 @@ protected virtual ValueTask AfterCheckpointRestoreAsync() /// /// /// - protected virtual ValueTask InitializeOverride(IExecutionContext context) + protected virtual ValueTask InitializeOverride(IWorkflowContext context) { // Default implementation does nothing. return CompletedValueTaskSource.Completed; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs new file mode 100644 index 0000000000..decf8ce8d4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// Provides services for an during the execution of a workflow. +/// +public interface IWorkflowContext +{ + /// + /// . + /// + /// + /// + ValueTask AddEventAsync(WorkflowEvent workflowEvent); + + /// + /// . + /// + /// + /// + ValueTask SendMessageAsync(object message); + + // TODO: State management +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs index a02c1fa042..1da548d9ba 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs @@ -16,7 +16,7 @@ public interface IMessageHandler /// The message to handle. /// The execution context. /// A task that represents the asynchronous operation. - ValueTask HandleAsync(TMessage message, IExecutionContext context); + ValueTask HandleAsync(TMessage message, IWorkflowContext context); } /// @@ -33,5 +33,5 @@ public interface IMessageHandler /// The message to handle. /// The execution context. /// A task that represents the asynchronous operation. - ValueTask HandleAsync(TMessage message, IExecutionContext context); + ValueTask HandleAsync(TMessage message, IWorkflowContext context); } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs index 9818599161..f7335c99e9 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; using HandlerInfosT = System.Collections.Generic.Dictionary< @@ -133,7 +134,7 @@ public MessageHandlerInfo(MethodInfo handlerInfo) throw new ArgumentException("Handler method must have exactly two parameters: TMessage and IExecutionContext.", nameof(handlerInfo)); } - if (parameters[1].ParameterType != typeof(IExecutionContext)) + if (parameters[1].ParameterType != typeof(IWorkflowContext)) { throw new ArgumentException("Handler method's second parameter must be of type IExecutionContext.", nameof(handlerInfo)); } @@ -163,7 +164,7 @@ public MessageHandlerInfo(MethodInfo handlerInfo) } } - public Func> Bind(Executor executor, bool checkType = false) + public Func> Bind(Executor executor, bool checkType = false) { Type? resultType = this.OutType; MethodInfo handlerMethod = this.HandlerInfo; @@ -172,13 +173,13 @@ public Func> Bind(Executor executor, bool checkTyp return InvokeHandlerAsync; // Create a delegate that binds the handler to the executor. - async ValueTask InvokeHandlerAsync(object message) + async ValueTask InvokeHandlerAsync(object message, IWorkflowContext workflowContext) { bool expectingVoid = resultType == null || resultType == typeof(void); try { - object? maybeValueTask = handlerMethod.Invoke(executor, new object[] { message, executor }); + object? maybeValueTask = handlerMethod.Invoke(executor, new object[] { message, workflowContext }); if (expectingVoid) { @@ -230,7 +231,7 @@ internal class MessageRouter // TODO: The goal of the cache is to allow SourceGenerators to do the reflection to bind the handlers in the router. internal static readonly Dictionary> s_routerFactoryCache = new(); - private Dictionary>> BoundHandlers { get; init; } = new(); + private Dictionary>> BoundHandlers { get; init; } = new(); [SuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + @@ -306,18 +307,18 @@ internal static MessageRouter BindMessageHandlers(Executor executor, bool checkT // If no factory is found, reflect over the handlers HandlerInfosT handlers = ReflectHandlers(executor.GetType()); - Dictionary>> boundHandlers = new(); + Dictionary>> boundHandlers = new(); foreach (Type inType in handlers.Keys) { MessageHandlerInfo handlerInfo = handlers[inType]; - Func> boundHandler = handlerInfo.Bind(executor, checkType); + Func> boundHandler = handlerInfo.Bind(executor, checkType); boundHandlers.Add(inType, boundHandler); // TODO: Turn the error here into something more actionable. } return new MessageRouter(boundHandlers); } - internal MessageRouter(Dictionary>> handlers) + internal MessageRouter(Dictionary>> handlers) { this.BoundHandlers = handlers; } @@ -331,7 +332,7 @@ internal MessageRouter(Dictionary>> han /// /// /// - public async ValueTask RouteMessageAsync(object message, IExecutionContext context, bool requireRoute = true) + public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) { if (message == null) { @@ -340,14 +341,16 @@ internal MessageRouter(Dictionary>> han // TODO: Implement base type delegation CallResult? result = null; - if (this.BoundHandlers.TryGetValue(message.GetType(), out Func>? handler)) + if (this.BoundHandlers.TryGetValue(message.GetType(), out Func>? handler)) { - result = await handler(message).ConfigureAwait(false); + result = await handler(message, context).ConfigureAwait(false); } return result; } + public bool CanHandle(object message) => this.CanHandle(Throw.IfNull(message).GetType()); + public bool CanHandle(Type candidateType) { if (candidateType == null) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs new file mode 100644 index 0000000000..bb92518d7b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Core; + +internal class Workflow +{ + public Dictionary> ExecutorProviders { get; internal init; } = new(); + public Dictionary> Edges { get; internal init; } = new(); + +#if NET9_0_OR_GREATER + required +#endif + public string StartExecutorId + { get; init; } + +#if NET9_0_OR_GREATER + required +#endif + public Type InputType + { get; init; } = typeof(object); + + public Workflow(string startExecutorId, Type type) + { + this.StartExecutorId = startExecutorId ?? throw new ArgumentNullException(nameof(startExecutorId)); + this.InputType = type ?? throw new ArgumentNullException(nameof(type)); + + // TODO: How do we (1) ensure the types are happy, and (2) work under AOT? + } + +#if NET9_0_OR_GREATER + public Workflow() + { } +#endif +} + +internal class Workflow : Workflow +{ + public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) + { + } + +#if NET9_0_OR_GREATER + public Workflow() + { + this.InputType = typeof(T); + } +#endif + + internal Workflow Promote(OutputSink outputSource) + { + Throw.IfNull(outputSource); + + return new Workflow(this.StartExecutorId, outputSource) + { + StartExecutorId = this.StartExecutorId, + ExecutorProviders = this.ExecutorProviders, + Edges = this.Edges, + InputType = this.InputType, + }; + } +} + +internal class Workflow : Workflow +{ + private readonly OutputSink _output; + + internal Workflow(string startExecutorId, OutputSink outputSource) + : base(startExecutorId) + { + this._output = Throw.IfNull(outputSource); + } + + public TResult? RunningOutput => this._output.Result; +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs new file mode 100644 index 0000000000..05f6f6f887 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Execution; + +internal abstract class EdgeRunner( + IRunnerContext runContext, TEdgeData edgeData) +{ + protected IRunnerContext RunContext { get; } = Throw.IfNull(runContext); + protected TEdgeData EdgeData { get; } = Throw.IfNull(edgeData); +} + +internal class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData edgeData) : + EdgeRunner(runContext, edgeData) +{ + public IWorkflowContext WorkflowContext { get; } = runContext.Bind(edgeData.SinkId); + + private async ValueTask FindRouterAsync() + { + Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); + + return sink.MessageRouter; + } + + public async ValueTask> ChaseAsync(object message) + { + if (this.EdgeData.Condition != null && !this.EdgeData.Condition(message)) + { + return []; + } + + MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); + if (router.CanHandle(message)) + { + return [await router.RouteMessageAsync(message, this.WorkflowContext) + .ConfigureAwait(false)]; + } + + return []; + } +} + +internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeData) : + EdgeRunner(runContext, edgeData) +{ + private Dictionary BoundContexts { get; } + = edgeData.SinkIds.ToDictionary( + sinkId => sinkId, + sinkId => runContext.Bind(sinkId)); + + public async ValueTask> ChaseAsync(object message) + { + List targets = + this.EdgeData.Partitioner == null + ? this.EdgeData.SinkIds + : this.EdgeData.Partitioner(message, this.BoundContexts.Count).Select(i => this.EdgeData.SinkIds[i]).ToList(); + + CallResult?[] result = await Task.WhenAll(targets.Select(ProcessTargetAsync)).ConfigureAwait(false); + return result.Where(r => r is not null); + + async Task ProcessTargetAsync(string targetId) + { + Executor executor = await this.RunContext.EnsureExecutorAsync(targetId) + .ConfigureAwait(false); + + MessageRouter router = executor.MessageRouter; + if (router.CanHandle(message)) + { + return await router.RouteMessageAsync(message, this.BoundContexts[targetId]) + .ConfigureAwait(false); + } + + return null; + } + } +} + +internal record FanInEdgeState(FanInEdgeData EdgeData) +{ + private List? _pendingMessages + = EdgeData.Trigger == FanInTrigger.WhenAll ? [] : null; + + private HashSet? _unseen + = EdgeData.Trigger == FanInTrigger.WhenAll ? new(EdgeData.SourceIds) : null; + + public IEnumerable? ProcessMessage(string sourceId, object message) + { + if (this.EdgeData.Trigger == FanInTrigger.WhenAll) + { + this._pendingMessages!.Add(message); + this._unseen!.Remove(sourceId); + + if (this._unseen.Count == 0) + { + List result = this._pendingMessages; + + this._pendingMessages = []; + this._unseen = new(this.EdgeData.SourceIds); + + return result; + } + + return null; + } + + return [message]; + } +} + +internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData) : + EdgeRunner(runContext, edgeData) +{ + private IWorkflowContext BoundContext { get; } = runContext.Bind(edgeData.SinkId); + + public FanInEdgeState CreateState() => new(this.EdgeData); + + public async ValueTask ChaseAsync(string sourceId, object message, FanInEdgeState state) + { + IEnumerable? releasedMessages = state.ProcessMessage(sourceId, message); + if (releasedMessages is null) + { + // Not ready to process yet. + return null; + } + + Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); + + MessageRouter router = sink.MessageRouter; + if (router.CanHandle(message)) + { + return await router.RouteMessageAsync(message, this.BoundContext) + .ConfigureAwait(false); + } + return null; + } +} + +internal class InputEdgeRuner(IRunnerContext runContext, string sinkId) + : EdgeRunner(runContext, sinkId) +{ + public IWorkflowContext WorkflowContext { get; } = runContext.Bind(sinkId); + + private async ValueTask FindRouterAsync() + { + Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData) + .ConfigureAwait(false); + + return sink.MessageRouter; + } + + public async ValueTask ChaseAsync(object message) + { + MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); + if (router.CanHandle(message)) + { + return await router.RouteMessageAsync(message, this.WorkflowContext) + .ConfigureAwait(false); + } + + // TODO: Throw instead? + + return null; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs new file mode 100644 index 0000000000..78770036a9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal interface IRunnerContext +{ + ValueTask AddEventAsync(string executorId, WorkflowEvent workflowEvent); + ValueTask SendMessageAsync(string executorId, object message); + + // TODO: State Management + + StepContext Advance(); + IWorkflowContext Bind(string executorId); + ValueTask EnsureExecutorAsync(string executorId); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs new file mode 100644 index 0000000000..4c99a29cea --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Agents.Workflows.Execution; + +internal readonly struct Identity : IEquatable +{ + public static Identity None { get; } = new Identity(); + + public string? Id { get; init; } + + public bool Equals(Identity other) + { + return this.Id == null + ? other.Id == null + : other.Id != null && StringComparer.OrdinalIgnoreCase.Equals(this.Id, other.Id); + } + + public override bool Equals([NotNullWhen(true)] object? obj) + { + if (this.Id == null) + { + return obj == null; + } + + if (obj == null) + { + return false; + } + + if (obj is Identity id) + { + return id.Equals(this); + } + + if (obj is string idStr) + { + return StringComparer.OrdinalIgnoreCase.Equals(this.Id, idStr); + } + + return false; + } + + public override int GetHashCode() + { + return this.Id == null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(this.Id); + } + + public static implicit operator Identity(string? id) + { + return new Identity { Id = id }; + } + + public static implicit operator string?(Identity identity) + { + return identity.Id; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs new file mode 100644 index 0000000000..18e252c274 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class EdgeMap +{ + private readonly Dictionary _edgeRunners = new(); + private readonly Dictionary _fanInState = new(); + private readonly InputEdgeRuner _inputRunner; + + public EdgeMap(IRunnerContext runContext, Dictionary> workflowEdges, string startExecutorId) + { + foreach (FlowEdge edge in workflowEdges.Values.SelectMany(e => e)) + { + object edgeRunner = edge.EdgeType switch + { + FlowEdge.Type.Direct => new DirectEdgeRunner(runContext, edge.DirectEdgeData!), + FlowEdge.Type.FanOut => new FanOutEdgeRunner(runContext, edge.FanOutEdgeData!), + FlowEdge.Type.FanIn => new FanInEdgeRunner(runContext, edge.FanInEdgeData!), + _ => throw new NotSupportedException($"Unsupported edge type: {edge.EdgeType}") + }; + + this._edgeRunners[edge] = edgeRunner; + } + + this._inputRunner = new InputEdgeRuner(runContext, startExecutorId); + } + + public async ValueTask> InvokeEdgeAsync(FlowEdge edge, string sourceId, object message) + { + if (!this._edgeRunners.TryGetValue(edge, out object? edgeRunner)) + { + throw new InvalidOperationException($"Edge {edge} not found in the edge map."); + } + + IEnumerable edgeResults; + switch (edge.EdgeType) + { + // We know the corresponding EdgeRunner type given the FlowEdge EdgeType, as + // established in the EdgeMap() ctor; this avoid doing an as-cast inside of + // the depths of the message delivery loop for every edges (multiplicity N, + // in FanIn/Out cases) + // TODO: Once we have a fixed interface, if it is reasonably generalizable + // between the Runners, we can normalize it behind an IFace. + case FlowEdge.Type.Direct: + { + DirectEdgeRunner runner = (DirectEdgeRunner)this._edgeRunners[edge]; + edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); + break; + } + + case FlowEdge.Type.FanOut: + { + FanOutEdgeRunner runner = (FanOutEdgeRunner)this._edgeRunners[edge]; + edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); + break; + } + + case FlowEdge.Type.FanIn: + { + FanInEdgeState state = this._fanInState[edge]; + FanInEdgeRunner runner = (FanInEdgeRunner)this._edgeRunners[edge]; + edgeResults = [await runner.ChaseAsync(sourceId, message, state).ConfigureAwait(false)]; + break; + } + + default: + throw new InvalidOperationException("Unknown edge type"); + + } + + return edgeResults; + } + + // TODO: Should we promote Input to a true "FlowEdge" type? + public async ValueTask> InvokeInputAsync(object inputMessage) + { + return [await this._inputRunner.ChaseAsync(inputMessage).ConfigureAwait(false)]; + } + + public ValueTask> InvokeResponseAsync(object externalResponse) + { + throw new NotImplementedException(); + } +} + +internal class LocalRunner +{ + public LocalRunner(Workflow workflow) + { + this.Workflow = workflow ?? throw new ArgumentNullException(nameof(workflow)); + this.RunContext = new LocalRunnerContext(workflow); + + // 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.StartExecutorId); + } + + protected Workflow Workflow { get; init; } + protected LocalRunnerContext RunContext { get; init; } + protected EdgeMap EdgeMap { get; init; } + + // TODO: Better signature? + public event EventHandler? WorkflowEvent; + + private void RaiseWorkflowEvent(WorkflowEvent workflowEvent) + { + this.WorkflowEvent?.Invoke(this, workflowEvent); + } + + private bool IsResponse(object message) + { + return false; + } + + private ValueTask> RouteExternalMessageAsync(object message) + { +#pragma warning disable CS0219 // Variable is assigned but its value is never used + bool isHil = false; +#pragma warning restore CS0219 // Variable is assigned but its value is never used + + return this.IsResponse(message) + ? this.EdgeMap.InvokeResponseAsync(message) + : this.EdgeMap.InvokeInputAsync(message); + } + + public async Task RunAsync(TInput input) + { + await this.RunContext.AddExternalMessageAsync(input).ConfigureAwait(false); + + // Kick everything off by sending the first message to the start executor. + Executor startExecutor = await this.RunContext.EnsureExecutorAsync(this.Workflow.StartExecutorId) + .ConfigureAwait(false); + + for (StepContext currentStep = this.RunContext.Advance(); currentStep.HasMessages; currentStep = this.RunContext.Advance()) + { + // Deliver the messages and queue the next step + List>> edgeTasks = new(); + foreach (Identity sender in currentStep.QueuedMessages.Keys) + { + IEnumerable senderMessages = currentStep.QueuedMessages[sender]; + if (sender.Id is null) + { + edgeTasks.AddRange(senderMessages.Select(message => this.RouteExternalMessageAsync(message).AsTask())); + } + else + { + HashSet outgoingEdges = this.Workflow.Edges[sender.Id!]; // Id is not null when Identity is not .None + foreach (FlowEdge outgoingEdge in outgoingEdges) + { + edgeTasks.AddRange(senderMessages.Select(message => this.EdgeMap.InvokeEdgeAsync(outgoingEdge, sender.Id, message).AsTask())); + } + } + } + + IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); + + // After the message handler invocations, we may have some events to deliver + foreach (WorkflowEvent @event in this.RunContext.QueuedEvents) + { + // TODO + } + } + } +} + +internal class LocalRunner +{ + private readonly Workflow _workflow; + private readonly LocalRunner _innerRunner; + + public LocalRunner(Workflow workflow) + { + this._workflow = Throw.IfNull(workflow); + this._innerRunner = new LocalRunner(workflow); + } + + public async Task RunAsync(TInput input) + { + await this._innerRunner.RunAsync(input).ConfigureAwait(false); + } + + public TResult? RunningOutput => this._workflow.RunningOutput; +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs new file mode 100644 index 0000000000..450ae763e1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class LocalRunnerContext : IRunnerContext +{ + private StepContext _nextStep = new(); + private readonly Dictionary> _executorProviders; + private readonly Dictionary _executors = new(); + + public LocalRunnerContext(Workflow workflow, ILogger? logger = null) + { + this._executorProviders = Throw.IfNull(workflow).ExecutorProviders; + } + + public async ValueTask EnsureExecutorAsync(string executorId) + { + if (!this._executors.TryGetValue(executorId, out var executor)) + { + if (!this._executorProviders.TryGetValue(executorId, out var provider)) + { + throw new InvalidOperationException($"Executor with ID '{executorId}' is not registered."); + } + + this._executors[executorId] = executor = provider(); + + await executor.InitializeAsync(this.Bind(executor.Id)).ConfigureAwait(false); + } + + return executor; + } + + public ValueTask AddExternalMessageAsync([NotNull] TExternalInput message) + { + Throw.IfNull(message); + + this._nextStep.MessagesFor(Identity.None).Add(message); + return CompletedValueTaskSource.Completed; + } + + public StepContext Advance() + { + return Interlocked.Exchange(ref this._nextStep, new StepContext()); + } + + public ValueTask AddEventAsync(string executorId, WorkflowEvent workflowEvent) + { + this.QueuedEvents.Add(workflowEvent); + return CompletedValueTaskSource.Completed; + } + + public ValueTask SendMessageAsync(string executorId, object message) + { + this._nextStep.MessagesFor(message.GetType().Name).Add(message); + return CompletedValueTaskSource.Completed; + } + + public IWorkflowContext Bind(string executorId) + { + return new BoundContext(this, executorId); + } + + public readonly List QueuedEvents = new(); + + private class BoundContext(LocalRunnerContext RunnerContext, string ExecutorId) : IWorkflowContext + { + public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => RunnerContext.AddEventAsync(ExecutorId, workflowEvent); + public ValueTask SendMessageAsync(object message) => RunnerContext.SendMessageAsync(ExecutorId, message); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs new file mode 100644 index 0000000000..ba305a4b46 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class StepContext +{ + public Dictionary> QueuedMessages { get; } = new(); + + public bool HasMessages => this.QueuedMessages.Values.Any(messageList => messageList.Count > 0); + + public List MessagesFor(string? executorId) + { + if (!this.QueuedMessages.TryGetValue(executorId, out var messages)) + { + messages = new List(); + this.QueuedMessages[executorId] = messages; + } + + return messages; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs new file mode 100644 index 0000000000..a73bef38f1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows; + +/// +/// . +/// +public class ExecutionResult +{ +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs new file mode 100644 index 0000000000..f40fbf606f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows; + +internal sealed class ExecutorIsh : + IIdentified, + IEquatable, + IEquatable, + IEquatable +{ + public enum Type + { + Unbound, + Executor, + //Function, + //Agent, + //ProcessStep + } + + public Type ExecutorType { get; init; } + + private readonly string? _idValue; + private readonly Executor? _executorValue; + //private readonly Func? _functionValue; + + public ExecutorIsh(Executor executor) + { + this.ExecutorType = Type.Executor; + this._executorValue = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public ExecutorIsh(string id) + { + this.ExecutorType = Type.Unbound; + this._idValue = id ?? throw new ArgumentNullException(nameof(id)); + } + + public bool IsUnbound => this.ExecutorType == Type.Unbound; + + public string Id => this.ExecutorType switch + { + Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), + Type.Executor => this._executorValue!.Id, + //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), + //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), + //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), + _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + }; + + public ExecutorProvider ExecutorProvider => this.ExecutorType switch + { + Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), + Type.Executor => () => this._executorValue!, + //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), + //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), + //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), + _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + }; + + //public ExecutorIsh(Func function) + //{ + // this.ExecutorType = Type.Function; + // this._functionValue = function ?? throw new ArgumentNullException(nameof(function)); + //} + + // Implicit conversions into ExecutorIsh + public static implicit operator ExecutorIsh(Executor executor) + { + return new ExecutorIsh(executor); + } + + // How do we AoT compile this? + //public static implicit operator ExecutorIsh(Func function) + //{ + // return new ExecutorIsh(function); + //} + + public static implicit operator ExecutorIsh(string id) + { + return new ExecutorIsh(id); + } + + public bool Equals(ExecutorIsh? other) + { + return other is not null && + other.Id == this.Id; + } + + public bool Equals(IIdentified? other) + { + return other is not null && + other.Id == this.Id; + } + + public bool Equals(string? other) + { + return other is not null && + other == this.Id; + } + + public override bool Equals(object? obj) + { + if (obj is null) + { + return false; + } + + if (obj is ExecutorIsh ish) + { + return this.Equals(ish); + } + else if (obj is IIdentified identified) + { + return this.Equals(identified); + } + else if (obj is string str) + { + return this.Equals(str); + } + + return false; + } + + public override int GetHashCode() + { + return this.Id.GetHashCode(); + } + + public override string ToString() + { + return this.ExecutorType switch + { + Type.Unbound => $"'{this.Id}':", + Type.Executor => $"'{this.Id}':{this._executorValue!.GetType()}", + //Type.Function => $"ExecutorIsh for Function with ID '{this.Id}'", + //Type.Agent => $"ExecutorIsh for Agent with ID '{this.Id}'", + //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", + _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj index 8d7a3265b0..df4590d832 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj +++ b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj @@ -18,9 +18,11 @@ Microsoft Agent Workflow Framework Contains the Microsoft Agent Workflow Framework. + Microsoft.Agents.Workflows + @@ -29,4 +31,9 @@ + + + + + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs new file mode 100644 index 0000000000..3417b36161 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows; + +internal class OutputSink : Executor +{ + public TResult? Result { get; protected set; } = default; + + internal OutputSink(string? id = null) : base(id) + { } +} + +internal class OutputCollectorExecutor : OutputSink, IMessageHandler +{ + private readonly StreamingAggregator _aggregator; + public OutputCollectorExecutor(StreamingAggregator aggregator, string? id = null) : base(id) + { + this._aggregator = Throw.IfNull(aggregator); + } + + public ValueTask HandleAsync(TInput message, IWorkflowContext context) + { + this.Result = this._aggregator(message); + return CompletedValueTaskSource.Completed; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs b/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs new file mode 100644 index 0000000000..bd212f1856 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Agents.Workflows; + +internal delegate TResult? StreamingAggregator(TInput input); + +internal static class StreamingAggregators +{ + public static StreamingAggregator First(Func conversion, TResult? defaultValue = default) + { + bool hasRun = false; + TResult? local = defaultValue; + + return Aggregate; + + TResult? Aggregate(TInput input) + { + if (!hasRun) + { + local = conversion(input); + } + + return local; + } + } + + public static StreamingAggregator First(TInput? defaultValue = default) + => First(input => input, defaultValue); + + public static StreamingAggregator Last(Func conversion, TResult? defaultValue = default) + { + TResult? local = defaultValue; + + return Aggregate; + + TResult? Aggregate(TInput input) + { + local = conversion(input); + return local; + } + } + + public static StreamingAggregator Last(TInput? defaultValue = default) + => Last(input => input, defaultValue); + + public static StreamingAggregator> Union(Func conversion) + { + List results = new(); + + return Aggregate; + + IEnumerable Aggregate(TInput input) + { + results.Add(conversion(input)); + return results; + } + } + + public static StreamingAggregator> Union() + => Union(input => input); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index f62008f15f..d9932b0bca 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -6,323 +6,22 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Execution; using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; +using System.Collections.Concurrent; #pragma warning restore IDE0005 // Using directive is unnecessary. -using ConditionalT = System.Func; - namespace Microsoft.Agents.Workflows; internal delegate TExecutor ExecutorProvider() where TExecutor : Executor; -//internal struct EdgeKey : IEquatable -//{ -// public string SourceId { get; init; } -// public string TargetId { get; init; } - -// public EdgeKey(string sourceId, string targetId) -// { -// this.SourceId = sourceId ?? throw new ArgumentNullException(nameof(sourceId)); -// this.TargetId = targetId ?? throw new ArgumentNullException(nameof(targetId)); -// } - -// public bool Equals(EdgeKey other) => this.SourceId == other.SourceId && this.TargetId == other.TargetId; -// public override bool Equals(object? obj) => obj is EdgeKey other && this.Equals(other); -// public override int GetHashCode() => HashCode.Combine(this.SourceId, this.TargetId); -//} - -/// -/// . -/// -public class ExecutionResult -{ -} - -internal sealed class ExecutorIsh : - IIdentified, - IEquatable, - IEquatable, - IEquatable -{ - public enum Type - { - Unbound, - Executor, - //Function, - //Agent, - //ProcessStep - } - - public Type ExecutorType { get; init; } - - private readonly string? _idValue; - private readonly Executor? _executorValue; - //private readonly Func? _functionValue; - - public ExecutorIsh(Executor executor) - { - this.ExecutorType = Type.Executor; - this._executorValue = executor ?? throw new ArgumentNullException(nameof(executor)); - } - - public ExecutorIsh(string id) - { - this.ExecutorType = Type.Unbound; - this._idValue = id ?? throw new ArgumentNullException(nameof(id)); - } - - public bool IsUnbound => this.ExecutorType == Type.Unbound; - - public string Id => this.ExecutorType switch - { - Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), - Type.Executor => this._executorValue!.Id, - //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), - //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), - //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), - _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") - }; - - public ExecutorProvider ExecutorProvider => this.ExecutorType switch - { - Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), - Type.Executor => () => this._executorValue!, - //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), - //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), - //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), - _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") - }; - - //public ExecutorIsh(Func function) - //{ - // this.ExecutorType = Type.Function; - // this._functionValue = function ?? throw new ArgumentNullException(nameof(function)); - //} - - // Implicit conversions into ExecutorIsh - public static implicit operator ExecutorIsh(Executor executor) - { - return new ExecutorIsh(executor); - } - - // How do we AoT compile this? - //public static implicit operator ExecutorIsh(Func function) - //{ - // return new ExecutorIsh(function); - //} - - public static implicit operator ExecutorIsh(string id) - { - return new ExecutorIsh(id); - } - - public bool Equals(ExecutorIsh? other) - { - return other is not null && - other.Id == this.Id; - } - - public bool Equals(IIdentified? other) - { - return other is not null && - other.Id == this.Id; - } - - public bool Equals(string? other) - { - return other is not null && - other == this.Id; - } - - public override bool Equals(object? obj) - { - if (obj is null) - { - return false; - } - - if (obj is ExecutorIsh ish) - { - return this.Equals(ish); - } - else if (obj is IIdentified identified) - { - return this.Equals(identified); - } - else if (obj is string str) - { - return this.Equals(str); - } - - return false; - } - - public override int GetHashCode() - { - return this.Id.GetHashCode(); - } - - public override string ToString() - { - return this.ExecutorType switch - { - Type.Unbound => $"'{this.Id}':", - Type.Executor => $"'{this.Id}':{this._executorValue!.GetType()}", - //Type.Function => $"ExecutorIsh for Function with ID '{this.Id}'", - //Type.Agent => $"ExecutorIsh for Agent with ID '{this.Id}'", - //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", - _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") - }; - } -} - -internal record DirectEdgeData( - ExecutorIsh Source, - ExecutorIsh Sink, - Func? Condition) -{ - public static implicit operator FlowEdgeEx(DirectEdgeData data) - { - return new FlowEdgeEx(data); - } -} - -internal record FanOutEdgeData( - ExecutorIsh Source, - IEnumerable Sinks, - Func>? Partitioner) // TODO: Should this be IList (to imply an ordering?)? -{ - public static implicit operator FlowEdgeEx(FanOutEdgeData data) - { - return new FlowEdgeEx(data); - } -} - -internal enum FanInTrigger -{ - WhenAll, - WhenAny -} - -internal record FanInEdgeData( - IEnumerable Sources, - ExecutorIsh Sink, - FanInTrigger Trigger = FanInTrigger.WhenAll) -{ - public static implicit operator FlowEdgeEx(FanInEdgeData data) - { - return new FlowEdgeEx(data); - } -} - -internal class FlowEdgeEx -{ - public enum Type - { - Direct, - FanOut, - FanIn - } - - public Type EdgeType { get; init; } - public object Data { get; init; } - - public FlowEdgeEx(DirectEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.Direct; - } - - public FlowEdgeEx(FanOutEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.FanOut; - } - - public FlowEdgeEx(FanInEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.FanIn; - } - - public DirectEdgeData? DirectEdgeData => this.Data as DirectEdgeData; - public FanOutEdgeData? FanOutEdgeData => this.Data as FanOutEdgeData; - public FanInEdgeData? FanInEdgeData => this.Data as FanInEdgeData; -} - -internal class FlowEdge(ExecutorIsh source, ExecutorIsh sink, ConditionalT? conditional) : IEquatable -{ - public ExecutorIsh Source { get; init; } = source ?? throw new ArgumentNullException(nameof(source)); - public ExecutorIsh Sink { get; } = sink ?? throw new ArgumentNullException(nameof(sink)); - public Func? Condition { get; } = conditional; - - public bool Equals(FlowEdge? other) - { - return other is null - ? false - : this.Source.Equals(other.Source) && this.Sink.Equals(other.Sink); - } - - public override bool Equals(object? obj) => obj is FlowEdge other && this.Equals(other); - public override int GetHashCode() => HashCode.Combine(this.Source.GetHashCode(), this.Sink.GetHashCode()); -} - -internal class Workflow -{ - public Dictionary> Executors { get; internal init; } = new(); - public Dictionary> Edges { get; internal init; } = new(); - -#if NET9_0_OR_GREATER - required -#endif - public string StartExecutorId - { get; init; } - -#if NET9_0_OR_GREATER - required -#endif - public Type InputType - { get; init; } = typeof(object); - - public Workflow(string startExecutorId, Type type) - { - this.StartExecutorId = startExecutorId ?? throw new ArgumentNullException(nameof(startExecutorId)); - this.InputType = type ?? throw new ArgumentNullException(nameof(type)); - - // TODO: How do we (1) ensure the types are happy, and (2) work under AOT? - } - -#if NET9_0_OR_GREATER - public Workflow() - { } -#endif -} - -// Just a decorator for the purposes of keeping type type where we can -internal class Workflow : Workflow -{ - public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) - { - } - -#if NET9_0_OR_GREATER - public Workflow() - { - this.InputType = typeof(T); - } -#endif -} - internal class WorkflowBuilder { private readonly Dictionary> _executors = new(); - private readonly Dictionary> _edges = new(); + private readonly Dictionary> _edges = new(); private readonly HashSet _unboundExecutors = new(); private readonly string _startExecutorId; @@ -371,13 +70,13 @@ public WorkflowBuilder BindExecutor(Executor executor) return this; } - private HashSet EnsureEdgesFor(string sourceId) + private HashSet EnsureEdgesFor(string sourceId) { // Ensure that there is a set of edges for the given source ID. // If it does not exist, create a new one. - if (!this._edges.TryGetValue(sourceId, out HashSet? edges)) + if (!this._edges.TryGetValue(sourceId, out HashSet? edges)) { - this._edges[sourceId] = edges = new HashSet(); + this._edges[sourceId] = edges = new HashSet(); } return edges; @@ -392,20 +91,22 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func>? partitioner = null, params ExecutorIsh[] targets) + // output int strictly element-of [0, count) + + public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func>? partitioner = null, params ExecutorIsh[] targets) { Throw.IfNull(source); Throw.IfNullOrEmpty(targets); this.EnsureEdgesFor(source.Id) .Add(new FanOutEdgeData( - this.Track(source), - targets.Select(target => this.Track(target)), + this.Track(source).Id, + targets.Select(target => this.Track(target).Id).ToList(), partitioner)); return this; @@ -416,11 +117,15 @@ public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = d Throw.IfNull(target); Throw.IfNullOrEmpty(sources); - this.EnsureEdgesFor(target.Id) - .Add(new FanInEdgeData( - sources.Select(source => this.Track(source)), - this.Track(target), - trigger)); + FanInEdgeData edgeData = new( + sources.Select(source => this.Track(source).Id).ToList(), + this.Track(target).Id, + trigger); + + foreach (string sourceId in edgeData.SourceIds) + { + this.EnsureEdgesFor(sourceId).Add(edgeData); + } return this; } @@ -451,7 +156,7 @@ public Workflow Build() return new Workflow(this._startExecutorId) // Why does it not see the default ctor? { - Executors = this._executors, + ExecutorProviders = this._executors, Edges = this._edges, StartExecutorId = this._startExecutorId, InputType = typeof(T) diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index 1ce2649e2e..188ecb2d14 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; @@ -33,4 +34,39 @@ public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh return builder; } + + public static Workflow BuildWithOutput(this WorkflowBuilder builder, ExecutorIsh outputSource, StreamingAggregator aggregator) + => builder.BuildWithOutput(outputSource, aggregator); + + public static Workflow BuildWithOutput(this WorkflowBuilder builder, ExecutorIsh outputSource, StreamingAggregator aggregator) + { + Throw.IfNull(outputSource); + Throw.IfNull(aggregator); + + OutputCollectorExecutor outputSink = new(aggregator); + + // TODO: Check taht the outputSource has a TResult output? + builder.AddEdge(outputSource, outputSink); + + Workflow workflow = builder.Build(); + return workflow.Promote(outputSink); + } + + //public static WorkflowBuilder AddMapReduce } + +//class T +//{ +// async Task A() +// { +// WorkflowBuilder b; + +// Workflow> wf = +// WorkflowBuilderExtensions.BuildWithOutput>(b, "my_last_node", StreamingAggregators.Union()); + +// LocalRunner> runner = new(wf); + +// await runner.RunAsync(42).ConfigureAwait(false); +// var result = runner.RunningOutput; +// } +//} diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs index d9af8f9f3a..33004f8170 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs @@ -25,7 +25,7 @@ public static ValueTask RunAsync() internal sealed class UppercaseExecutor : Executor, IMessageHandler { - public ValueTask HandleAsync(string message, IExecutionContext context) + public ValueTask HandleAsync(string message, IWorkflowContext context) { return CompletedValueTaskSource.FromResult(message.ToUpperInvariant()); } @@ -33,7 +33,7 @@ public ValueTask HandleAsync(string message, IExecutionContext context) internal sealed class ReverseTextExecutor : Executor, IMessageHandler { - public ValueTask HandleAsync(string message, IExecutionContext context) + public ValueTask HandleAsync(string message, IWorkflowContext context) { char[] charArray = message.ToCharArray(); System.Array.Reverse(charArray); diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs index 42f23a7dd4..cb5d950b6d 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs @@ -38,7 +38,7 @@ public DetectSpamExecutor(params string[] spamKeywords) this.SpamKeywords = spamKeywords; } - public ValueTask HandleAsync(string message, IExecutionContext context) + public ValueTask HandleAsync(string message, IWorkflowContext context) { #if NET5_0_OR_GREATER bool isSpam = this.SpamKeywords.Any(keyword => message.Contains(keyword, StringComparison.OrdinalIgnoreCase)); @@ -52,7 +52,7 @@ public ValueTask HandleAsync(string message, IExecutionContext context) internal sealed class RespondToMessageExecutor : Executor, IMessageHandler { - public async ValueTask HandleAsync(bool message, IExecutionContext context) + public async ValueTask HandleAsync(bool message, IWorkflowContext context) { if (message) { @@ -69,7 +69,7 @@ await context.AddEventAsync(new WorkflowCompletedEvent { Data = "Message process internal sealed class RemoveSpamExecutor : Executor, IMessageHandler { - public async ValueTask HandleAsync(bool message, IExecutionContext context) + public async ValueTask HandleAsync(bool message, IWorkflowContext context) { if (!message) { diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs index c235352725..6ab2b232e5 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs @@ -45,7 +45,7 @@ public GuessNumberExecutor(int lowerBound, int upperBound) private int NextGuess => (this.LowerBound + this.UpperBound) / 2; private int _currGuess = -1; - public async ValueTask HandleAsync(NumberSignal message, IExecutionContext context) + public async ValueTask HandleAsync(NumberSignal message, IWorkflowContext context) { switch (message) { @@ -75,7 +75,7 @@ public JudgeExecutor(int targetNumber) this._targetNumber = targetNumber; } - public ValueTask HandleAsync(int message, IExecutionContext context) + public ValueTask HandleAsync(int message, IWorkflowContext context) { if (message == this._targetNumber) { From f948c512927805b9004975467bb9f417629994d9 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 4 Aug 2025 14:02:48 -0400 Subject: [PATCH 105/232] refactor: Assembly name .Workflow => .Workflows --- dotnet/agent-framework-dotnet.slnx | 2 +- ...Agents.Workflow.csproj => Microsoft.Agents.Workflows.csproj} | 2 -- .../Microsoft.Agents.Workflow.UnitTests.csproj | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) rename dotnet/src/Microsoft.Agents.Workflow/{Microsoft.Agents.Workflow.csproj => Microsoft.Agents.Workflows.csproj} (91%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 91cbf5db1f..e79922db8d 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -116,7 +116,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj similarity index 91% rename from dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj rename to dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj index df4590d832..851213dbe7 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflow.csproj +++ b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj @@ -3,7 +3,6 @@ $(ProjectsTargetFrameworks) $(ProjectsDebugTargetFrameworks) - Microsoft.Agents.Workflow alpha @@ -18,7 +17,6 @@ Microsoft Agent Workflow Framework Contains the Microsoft Agent Workflow Framework. - Microsoft.Agents.Workflows diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj index f4e4b48d84..d384557d8f 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj @@ -6,7 +6,7 @@ - + From 0ecd13d55cf8df1fb06e808e1eb192cb69b7179c Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 12:49:32 -0400 Subject: [PATCH 106/232] feat: Enable Default Message Handling * also lifts Bind in MessageHandlerInfo to better be able to direclty invoke handlers (for AOT, later) --- .../Core/MessageHandler.cs | 17 ++++++ .../Core/MessageRouting.cs | 60 ++++++++++++------- 2 files changed, 57 insertions(+), 20 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs index 1da548d9ba..a607736f40 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs @@ -4,6 +4,23 @@ namespace Microsoft.Agents.Workflows.Core; +/// +/// A default message handler interface for handling messages that do not have a specific handler registered. +/// +public interface IDefaultMessageHandler +{ + /// + /// Handles the incoming message asynchronously. + /// + /// + /// This is used as a fallback handler for messages that do not have a specific handler registered. + /// + /// The message to handle. + /// The execution context. + /// A task that represents the asynchronous operation. + ValueTask HandleAsync(object message, IWorkflowContext context); +} + /// /// A message handler interface for handling messages of type . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs index f7335c99e9..8d899bff94 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs @@ -164,22 +164,17 @@ public MessageHandlerInfo(MethodInfo handlerInfo) } } - public Func> Bind(Executor executor, bool checkType = false) + public static Func> Bind(Func handlerAsync, bool checkType, Type? resultType = null, Func>? unwrapper = null) { - Type? resultType = this.OutType; - MethodInfo handlerMethod = this.HandlerInfo; - Func>? unwrapper = this.Unwrapper; - return InvokeHandlerAsync; - // Create a delegate that binds the handler to the executor. async ValueTask InvokeHandlerAsync(object message, IWorkflowContext workflowContext) { bool expectingVoid = resultType == null || resultType == typeof(void); try { - object? maybeValueTask = handlerMethod.Invoke(executor, new object[] { message, workflowContext }); + object? maybeValueTask = handlerAsync(message, workflowContext); if (expectingVoid) { @@ -190,7 +185,7 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext } throw new InvalidOperationException( - $"Handler method {handlerMethod.Name} is expected to return ValueTask or ValueTask, but returned " + + "Handler method is expected to return ValueTask or ValueTask, but returned " + $"{maybeValueTask?.GetType().Name ?? "null"}."); } @@ -198,13 +193,13 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext if (unwrapper == null) { throw new InvalidOperationException( - $"Handler method {handlerMethod.Name} is expected to return ValueTask<{resultType!.Name}>, but no unwrapper is available."); + $"Handler method is expected to return ValueTask<{resultType!.Name}>, but no unwrapper is available."); } if (maybeValueTask == null) { throw new InvalidOperationException( - $"Handler method {handlerMethod.Name} returned null, but a ValueTask<{resultType!.Name}> was expected."); + $"Handler method returned null, but a ValueTask<{resultType!.Name}> was expected."); } object? result = await unwrapper(maybeValueTask).ConfigureAwait(false); @@ -212,7 +207,7 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext if (checkType && result != null && !resultType.IsInstanceOfType(result)) { throw new InvalidOperationException( - $"Handler method {handlerMethod.Name} returned an incompatible type: expected {resultType.Name}, got {result.GetType().Name}."); + $"Handler method returned an incompatible type: expected {resultType.Name}, got {result.GetType().Name}."); } return CallResult.ReturnResult(result); @@ -224,6 +219,17 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext } } } + + public Func> Bind(Executor executor, bool checkType = false) + { + MethodInfo handlerMethod = this.HandlerInfo; + return MessageHandlerInfo.Bind(InvokeHandler, checkType, this.OutType, this.Unwrapper); + + object? InvokeHandler(object message, IWorkflowContext workflowContext) + { + return handlerMethod.Invoke(executor, new object[] { message, workflowContext }); + } + } } internal class MessageRouter @@ -232,6 +238,7 @@ internal class MessageRouter internal static readonly Dictionary> s_routerFactoryCache = new(); private Dictionary>> BoundHandlers { get; init; } = new(); + private IDefaultMessageHandler? DefaultHandler { get; init; } = null; [SuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + @@ -315,12 +322,13 @@ internal static MessageRouter BindMessageHandlers(Executor executor, bool checkT boundHandlers.Add(inType, boundHandler); // TODO: Turn the error here into something more actionable. } - return new MessageRouter(boundHandlers); + return new MessageRouter(boundHandlers, executor as IDefaultMessageHandler); } - internal MessageRouter(Dictionary>> handlers) + internal MessageRouter(Dictionary>> handlers, IDefaultMessageHandler? defaultHandler = null) { this.BoundHandlers = handlers; + this.DefaultHandler = defaultHandler; } /// @@ -339,12 +347,24 @@ internal MessageRouter(Dictionary>? handler)) { result = await handler(message, context).ConfigureAwait(false); } + else if (this.DefaultHandler != null) + { + try + { + await this.DefaultHandler.HandleAsync(message, context).ConfigureAwait(false); + result = CallResult.ReturnVoid(); + } + catch (Exception e) + { + result = CallResult.RaisedException(wasVoid: true, e); + } + } return result; } @@ -353,14 +373,14 @@ internal MessageRouter(Dictionary IncomingTypes => [.. this.BoundHandlers.Keys]; + public HashSet IncomingTypes + => this.DefaultHandler != null + ? [.. this.BoundHandlers.Keys, typeof(object)] + : [.. this.BoundHandlers.Keys]; } From 4a674571c3537e866079185afe3e3641cbe5a7cd Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 12:55:35 -0400 Subject: [PATCH 107/232] feat: Implement StreamingHandle APIs This allows the user to respond to WorkflowEvents with external messages, enabling HIL. --- .../Execution/ExecutionHandle.cs | 170 ++++++++++++++++++ .../Execution/LocalRunner.cs | 95 +++++++--- .../Execution/LocalRunnerContext.cs | 2 +- .../ExecutionResult.cs | 10 -- .../Microsoft.Agents.Workflows.csproj | 1 - .../WorkflowBuilder.cs | 2 + .../Sample/02_Simple_Workflow_Sequential.cs | 9 +- .../Sample/02a_Simple_Workflow_Condition.cs | 9 +- .../Sample/02b_Simple_Workflow_Loop.cs | 10 +- 9 files changed, 254 insertions(+), 54 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs new file mode 100644 index 0000000000..6bab2c0aea --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Execution; + +internal interface ISuperStepRunner +{ + ValueTask EnqueueMessageAsync(object message); + + event EventHandler? WorkflowEvent; + + ValueTask RunSuperStepAsync(CancellationToken cancellation); +} + +internal interface IRunnerWithResult +{ + ISuperStepRunner StepRunner { get; } + + ValueTask GetResultAsync(CancellationToken cancellation = default); +} + +/// +/// . +/// +public class StreamingExecutionHandle +{ + private readonly ISuperStepRunner _stepRunner; + + internal StreamingExecutionHandle(ISuperStepRunner stepRunner) + { + this._stepRunner = Throw.IfNull(stepRunner); + } + + /// + /// . + /// + /// + /// + /// + public ValueTask SendResponseAsync(object response) + { + return this._stepRunner.EnqueueMessageAsync(response); + } + + /// + /// . + /// + /// + /// + /// + public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancellation] CancellationToken cancellation) + { + List eventSink = new(); + + this._stepRunner.WorkflowEvent += OnWorkflowEvent; + + try + { + while (await this._stepRunner.RunSuperStepAsync(cancellation).ConfigureAwait(false)) + { + List outputEvents = Interlocked.Exchange(ref eventSink, new()); + foreach (WorkflowEvent raisedEvent in outputEvents) + { + yield return raisedEvent; + } + } + } + finally + { + this._stepRunner.WorkflowEvent -= OnWorkflowEvent; + } + + void OnWorkflowEvent(object? sender, WorkflowEvent e) + { + eventSink.Add(e); + } + } +} + +/// +/// . +/// +/// +public class StreamingExecutionHandle : StreamingExecutionHandle +{ + private readonly IRunnerWithResult _resultSource; + + internal StreamingExecutionHandle(IRunnerWithResult runner) + : base(Throw.IfNull(runner.StepRunner)) + { + this._resultSource = runner; + } + + /// + /// . + /// + /// + /// + /// + public ValueTask GetResultAsync(CancellationToken cancellation = default) + { + return this._resultSource.GetResultAsync(cancellation); + } +} + +/// +/// . +/// +public static class ExecutionHandleExtensions +{ + /// + /// Processes all events from the workflow execution stream until completion. + /// + /// This method continuously monitors the workflow execution stream provided by and invokes the for each event. If the callback returns a + /// non- response, the response is sent back to the workflow using the handle. + /// The representing the workflow execution stream to monitor. + /// An optional callback function invoked for each received from the stream. The + /// callback can return a response object to be sent back to the workflow, or if no response + /// is required. + /// A to observe while waiting for events. Defaults to . + /// A that represents the asynchronous operation. The task completes when the workflow + /// execution stream is fully processed. + public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) + { + Throw.IfNull(handle); + + await foreach (WorkflowEvent @event in handle.WatchStreamAsync(cancellation).ConfigureAwait(false)) + { + object? maybeResponse = eventCallback?.Invoke(@event); + if (maybeResponse != null) + { + await handle.SendResponseAsync(maybeResponse).ConfigureAwait(false); + } + } + } + + /// + /// Executes the workflow associated with the specified until it + /// completes and returns the final result. + /// + /// This method ensures that the workflow runs to completion before returning the result. If an + /// is provided, it will be invoked for each event emitted during the workflow's + /// execution, allowing for custom event handling. + /// The type of the result produced by the workflow. + /// The representing the workflow to execute. This parameter cannot + /// be . + /// An optional callback function that is invoked for each emitted during the workflow + /// execution. The callback can process the event and return an object, or if no processing + /// is required. + /// A that can be used to cancel the workflow execution. The default value is . + /// A that represents the asynchronous operation. The task's result is the final + /// result of the workflow execution. + public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) + { + Throw.IfNull(handle); + + await handle.RunToCompletionAsync(eventCallback, cancellation).ConfigureAwait(false); + return await handle.GetResultAsync(cancellation).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index 18e252c274..c0533a1087 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; @@ -91,7 +92,7 @@ public EdgeMap(IRunnerContext runContext, Dictionary> } } -internal class LocalRunner +internal class LocalRunner : ISuperStepRunner where TInput : notnull { public LocalRunner(Workflow workflow) { @@ -103,6 +104,11 @@ public LocalRunner(Workflow workflow) this.EdgeMap = new EdgeMap(this.RunContext, this.Workflow.Edges, this.Workflow.StartExecutorId); } + ValueTask ISuperStepRunner.EnqueueMessageAsync(object message) + { + return this.RunContext.AddExternalMessageAsync(message); + } + protected Workflow Workflow { get; init; } protected LocalRunnerContext RunContext { get; init; } protected EdgeMap EdgeMap { get; init; } @@ -131,47 +137,68 @@ private bool IsResponse(object message) : this.EdgeMap.InvokeInputAsync(message); } - public async Task RunAsync(TInput input) + public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) { await this.RunContext.AddExternalMessageAsync(input).ConfigureAwait(false); - // Kick everything off by sending the first message to the start executor. - Executor startExecutor = await this.RunContext.EnsureExecutorAsync(this.Workflow.StartExecutorId) - .ConfigureAwait(false); + return new StreamingExecutionHandle(this); + } + + private StepContext? _currentStep = null; + public async ValueTask RunSuperStepAsync(CancellationToken cancellation) + { + cancellation.ThrowIfCancellationRequested(); - for (StepContext currentStep = this.RunContext.Advance(); currentStep.HasMessages; currentStep = this.RunContext.Advance()) + if (this._currentStep == null) { - // Deliver the messages and queue the next step - List>> edgeTasks = new(); - foreach (Identity sender in currentStep.QueuedMessages.Keys) + this._currentStep = this.RunContext.Advance(); + } + + if (this._currentStep.HasMessages) + { + await this.RunSuperstepAsync(this._currentStep).ConfigureAwait(false); + return true; + } + + return false; + } + + private async ValueTask RunSuperstepAsync(StepContext currentStep) + { + // Deliver the messages and queue the next step + List>> edgeTasks = new(); + foreach (Identity sender in currentStep.QueuedMessages.Keys) + { + IEnumerable senderMessages = currentStep.QueuedMessages[sender]; + if (sender.Id is null) { - IEnumerable senderMessages = currentStep.QueuedMessages[sender]; - if (sender.Id is null) - { - edgeTasks.AddRange(senderMessages.Select(message => this.RouteExternalMessageAsync(message).AsTask())); - } - else + edgeTasks.AddRange(senderMessages.Select(message => this.RouteExternalMessageAsync(message).AsTask())); + } + else + { + HashSet outgoingEdges = this.Workflow.Edges[sender.Id!]; // Id is not null when Identity is not .None + foreach (FlowEdge outgoingEdge in outgoingEdges) { - HashSet outgoingEdges = this.Workflow.Edges[sender.Id!]; // Id is not null when Identity is not .None - foreach (FlowEdge outgoingEdge in outgoingEdges) - { - edgeTasks.AddRange(senderMessages.Select(message => this.EdgeMap.InvokeEdgeAsync(outgoingEdge, sender.Id, message).AsTask())); - } + edgeTasks.AddRange(senderMessages.Select(message => this.EdgeMap.InvokeEdgeAsync(outgoingEdge, sender.Id, message).AsTask())); } } + } - IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); + // TODO: Should we let the user specify that they want strictly turn-based execution of the edges, vs. concurrent? + // (Simply substitute a strategy that replaces Task.WhenAll with a loop with an await in the middle. Difficulty is + // that we would need to avoid firing the tasks when we call InvokeEdgeAsync, or RouteExternalMessageAsync. + IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); - // After the message handler invocations, we may have some events to deliver - foreach (WorkflowEvent @event in this.RunContext.QueuedEvents) - { - // TODO - } + // TODO: Commit the state updates (so they are visible to the next step) + // After the message handler invocations, we may have some events to deliver + foreach (WorkflowEvent @event in this.RunContext.QueuedEvents) + { + this.RaiseWorkflowEvent(@event); } } } -internal class LocalRunner +internal class LocalRunner : IRunnerWithResult where TInput : notnull { private readonly Workflow _workflow; private readonly LocalRunner _innerRunner; @@ -182,10 +209,20 @@ public LocalRunner(Workflow workflow) this._innerRunner = new LocalRunner(workflow); } - public async Task RunAsync(TInput input) + public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) { - await this._innerRunner.RunAsync(input).ConfigureAwait(false); + await this.StepRunner.EnqueueMessageAsync(input).ConfigureAwait(false); + + return new StreamingExecutionHandle(this._innerRunner); + } + + public ValueTask GetResultAsync(CancellationToken cancellation = default) + { + // TODO: Block on finishing consuming StreamAsync()? + return CompletedValueTaskSource.FromResult(this.RunningOutput!); } public TResult? RunningOutput => this._workflow.RunningOutput; + + public ISuperStepRunner StepRunner => this._innerRunner; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index 450ae763e1..0275b32f30 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -39,7 +39,7 @@ public async ValueTask EnsureExecutorAsync(string executorId) return executor; } - public ValueTask AddExternalMessageAsync([NotNull] TExternalInput message) + public ValueTask AddExternalMessageAsync([NotNull] object message) { Throw.IfNull(message); diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs deleted file mode 100644 index a73bef38f1..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutionResult.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.Workflows; - -/// -/// . -/// -public class ExecutionResult -{ -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj index 851213dbe7..a9023a799c 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj @@ -30,7 +30,6 @@ - diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index d9932b0bca..ada872384e 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -152,6 +152,8 @@ public Workflow Build() { // We have no handlers for the input type T, which means the built workflow will not be able to // process messages of the desired type + throw new InvalidOperationException( + $"Workflow cannot be built because the starting executor {this._startExecutorId} does not contain a handler for the desired input type {typeof(T).Name}"); } return new Workflow(this._startExecutorId) // Why does it not see the default ctor? diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs index 33004f8170..af50478e26 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs @@ -2,12 +2,13 @@ using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; namespace Microsoft.Agents.Workflows.Sample; internal static class Step2EntryPoint { - public static ValueTask RunAsync() + public static async ValueTask RunAsync() { UppercaseExecutor uppercase = new(); ReverseTextExecutor reverse = new(); @@ -16,10 +17,10 @@ public static ValueTask RunAsync() builder.AddEdge(uppercase, reverse); Workflow workflow = builder.Build(); - // async foreach (var event in workflow.RunAsync("hello world")) - // await Console.Out.WriteLineAsync(event); + LocalRunner runner = new(workflow); - return CompletedValueTaskSource.Completed; + var handle = await runner.StreamAsync("Hello, World!").ConfigureAwait(false); + await handle.RunToCompletionAsync().ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs index cb5d950b6d..d782783404 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs @@ -4,12 +4,13 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; namespace Microsoft.Agents.Workflows.Sample; internal static class Step2aEntryPoint { - public static ValueTask RunAsync() + public static async ValueTask RunAsync() { string[] spamKeywords = { "spam", "advertisement", "offer" }; @@ -22,10 +23,10 @@ public static ValueTask RunAsync() .AddEdge(detectSpam, removeSpam, isSpam => isSpam is false) // If spam, remove .Build(); - // async foreach (var event in workflow.RunAsync("This is a spam message.")) - // await Console.Out.WriteLineAsync(event); + LocalRunner runner = new(workflow); - return CompletedValueTaskSource.Completed; + StreamingExecutionHandle handle = await runner.StreamAsync("This is a spam message.").ConfigureAwait(false); + await handle.RunToCompletionAsync().ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs index 6ab2b232e5..3ce581bf8b 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs @@ -2,12 +2,13 @@ using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; namespace Microsoft.Agents.Workflows.Sample; internal static class Step2bEntryPoint { - public static ValueTask RunAsync() + public static async ValueTask RunAsync() { GuessNumberExecutor guessNumber = new(1, 100); JudgeExecutor judge = new(42); // Let's say the target number is 42 @@ -16,10 +17,9 @@ public static ValueTask RunAsync() .AddLoop(guessNumber, judge) .Build(); - // async foreach (var event in workflow.RunAsync(NumberSignal.Init)) - // await Console.Out.WriteLineAsync(event); - - return CompletedValueTaskSource.Completed; + LocalRunner runner = new(workflow); + StreamingExecutionHandle handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); + await handle.RunToCompletionAsync(); } } From f7c36f443e977b5869b3e990cf26f24007775bfc Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 13:10:30 -0400 Subject: [PATCH 108/232] feat: Add checks for duplicate edges and chain cycles --- .../Microsoft.Agents.Workflows.csproj | 10 ++++++---- .../Microsoft.Agents.Workflow/WorkflowBuilder.cs | 14 ++++++++++++++ .../WorkflowBuilderExtensions.cs | 12 ++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj index a9023a799c..478396f484 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj @@ -19,6 +19,12 @@ Contains the Microsoft Agent Workflow Framework. + + + + + + @@ -29,8 +35,4 @@ - - - - \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index ada872384e..28416db074 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -18,11 +18,17 @@ namespace Microsoft.Agents.Workflows; internal delegate TExecutor ExecutorProvider() where TExecutor : Executor; +internal record struct EdgeId(string SourceId, string TargetId) +{ + public override string ToString() => $"{this.SourceId} -> {this.TargetId}"; +} + internal class WorkflowBuilder { private readonly Dictionary> _executors = new(); private readonly Dictionary> _edges = new(); private readonly HashSet _unboundExecutors = new(); + private readonly HashSet _conditionlessEdges = new(); private readonly string _startExecutorId; @@ -90,6 +96,14 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func seenExecutors = new(); + seenExecutors.Add(source.Id); + for (int i = 0; i < executors.Length; i++) { Throw.IfNull(executors[i], nameof(executors) + $"[{i}]"); + + if (seenExecutors.Contains(executors[i].Id)) + { + throw new ArgumentException($"Executor '{executors[i].Id}' is already in the chain.", nameof(executors)); + } + seenExecutors.Add(executors[i].Id); + builder.AddEdge(source, executors[i]); source = executors[i]; } From 96f5bcb664a0040dc49daefbe6e59d2bd08ed1cb Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 13:48:33 -0400 Subject: [PATCH 109/232] feat: Add built-in WorkflowEvents --- .../Microsoft.Agents.Workflow/Core/Events.cs | 38 +++---------------- .../Core/Executor.cs | 4 ++ .../Execution/LocalRunner.cs | 6 +++ .../Execution/LocalRunnerContext.cs | 2 + 4 files changed, 18 insertions(+), 32 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs index 041944ea36..6ce2a05702 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -using System; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; @@ -27,27 +27,15 @@ public record ExecutorEvent : WorkflowEvent /// /// The identifier of the executor that generated this event. /// -#if NET9_0_OR_GREATER - required -#endif - public string ExecutorId - { get; init; } + public string ExecutorId { get; } /// /// . /// public ExecutorEvent(string executorId, object? data = null) : base(data) { - this.ExecutorId = executorId ?? throw new ArgumentNullException(nameof(executorId), "Executor ID cannot be null."); + this.ExecutorId = Throw.IfNull(executorId); } - -#if NET9_0_OR_GREATER - /// - /// . - /// - public ExecutorEvent() - { } -#endif } /// @@ -58,15 +46,9 @@ public record ExecutorInvokeEvent : ExecutorEvent /// /// . /// - public ExecutorInvokeEvent(string executorId, object? data = null) : base(executorId, data) { } - -#if NET9_0_OR_GREATER - /// - /// . - /// - public ExecutorInvokeEvent() - { } -#endif + public ExecutorInvokeEvent(string executorId, object? data = null) : base(executorId, data) + { + } } /// @@ -78,14 +60,6 @@ public record ExecutorCompleteEvent : ExecutorEvent /// . /// public ExecutorCompleteEvent(string executorId, object? data = null) : base(executorId, data) { } - -#if NET9_0_OR_GREATER - /// - /// . - /// - public ExecutorCompleteEvent() - { } -#endif } // TODO: This is a placeholder for streaming chat message content. diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 1767c0cbaa..451be5135b 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -126,9 +126,13 @@ protected Executor(string? id = null, string? name = null) /// public async ValueTask ExecuteAsync(object message, IWorkflowContext context) { + await context.AddEventAsync(new ExecutorInvokeEvent(this.Id)).ConfigureAwait(false); + CallResult? result = await this.MessageRouter.RouteMessageAsync(message, context, requireRoute: true) .ConfigureAwait(false); + await context.AddEventAsync(new ExecutorCompleteEvent(this.Id)).ConfigureAwait(false); + if (result == null) { throw new NotSupportedException( diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index c0533a1087..1605c77c5a 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -109,6 +109,7 @@ ValueTask ISuperStepRunner.EnqueueMessageAsync(object message) return this.RunContext.AddExternalMessageAsync(message); } + protected Dictionary PendingCalls { get; } = new(); protected Workflow Workflow { get; init; } protected LocalRunnerContext RunContext { get; init; } protected EdgeMap EdgeMap { get; init; } @@ -151,12 +152,16 @@ public async ValueTask RunSuperStepAsync(CancellationToken cancellation) if (this._currentStep == null) { + // TODO: Python-side does not raise this event. + // await this.RunContext.AddEventAsync(this.Workflow.StartExecutorId, new WorkflowStartedEvent()).ConfigureAwait(false); this._currentStep = this.RunContext.Advance(); } if (this._currentStep.HasMessages) { await this.RunSuperstepAsync(this._currentStep).ConfigureAwait(false); + this._currentStep = this.RunContext.Advance(); + return true; } @@ -190,6 +195,7 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); // TODO: Commit the state updates (so they are visible to the next step) + // After the message handler invocations, we may have some events to deliver foreach (WorkflowEvent @event in this.RunContext.QueuedEvents) { diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index 0275b32f30..c58a891e7f 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -47,6 +47,8 @@ public ValueTask AddExternalMessageAsync([NotNull] object message) return CompletedValueTaskSource.Completed; } + public bool NextStepHasActions => this._nextStep.HasMessages; + public StepContext Advance() { return Interlocked.Exchange(ref this._nextStep, new StepContext()); From d603926c1c9f750ed9654c2285043c04ad7e6530 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 13:55:59 -0400 Subject: [PATCH 110/232] refactor: Pull classes into own files --- .../Core/CallResult.cs | 78 ++++ .../Core/Executor.cs | 74 ---- .../Core/ExecutorCapabilities.cs | 69 ++++ .../Core/IDefaultMessageHandler.cs | 22 + .../Core/IIdentified.cs | 14 + .../{MessageHandler.cs => IMessageHandler.cs} | 17 - .../Core/MessageHandlerInfo.cs | 131 ++++++ .../Core/MessageRouter.cs | 169 ++++++++ .../Core/MessageRouting.cs | 386 ------------------ .../Core/StreamsMessageAttribute.cs | 26 ++ ...TypeErasure.cs => ValueTaskTypeErasure.cs} | 0 .../Execution/EdgeMap.cs | 91 +++++ .../{Identity.cs => ExecutorIdentity.cs} | 14 +- .../Execution/IRunnerWithResult.cs | 13 + .../Execution/ISuperStepRunner.cs | 17 + .../Execution/LocalRunner.cs | 84 +--- .../Execution/LocalRunnerContext.cs | 2 +- .../Execution/StepContext.cs | 2 +- ...nHandle.cs => StreamingExecutionHandle.cs} | 16 - 19 files changed, 640 insertions(+), 585 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/IIdentified.cs rename dotnet/src/Microsoft.Agents.Workflow/Core/{MessageHandler.cs => IMessageHandler.cs} (68%) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandlerInfo.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs rename dotnet/src/Microsoft.Agents.Workflow/Core/{TypeErasure.cs => ValueTaskTypeErasure.cs} (100%) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs rename dotnet/src/Microsoft.Agents.Workflow/Execution/{Identity.cs => ExecutorIdentity.cs} (70%) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs rename dotnet/src/Microsoft.Agents.Workflow/Execution/{ExecutionHandle.cs => StreamingExecutionHandle.cs} (94%) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs new file mode 100644 index 0000000000..9b484610b0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// This class represents the result of a call to a +/// or . +/// +public sealed class CallResult +{ + /// + /// Indicates whether the call was void (i.e., no result expected). This only applies to + /// calls to handlers. + /// + public bool IsVoid { get; init; } + + /// + /// If the call was successful, this property contains the result of the call. For calls to + /// void handlers, this will be null. + /// + public object? Result { get; init; } = null; + + /// + /// If the call failed, this property contains the exception that was raised during the call. + /// + public Exception? Exception { get; init; } = null; + + /// + /// Indicates whether the call was successful. A call is considered successful if it returned + /// without throwing an exception. + /// + public bool IsSuccess => this.Exception == null; + + private CallResult(bool isVoid = false) + { + // Private constructor to enforce use of static methods. + this.IsVoid = isVoid; + } + + /// + /// Create a indicating a successful that returned a result (non-void). + /// + /// The result to return. + /// A indicating the result of the call. + public static CallResult ReturnResult(object? result = null) + { + return new() { Result = result }; + } + + /// + /// Create a indicating a successful call that returned no result (void). + /// + /// A indicating the result of the call. + public static CallResult ReturnVoid() + { + return new(isVoid: true); + } + + /// + /// Create a indicating that an exception was raised during the call. + /// + /// A boolean specifying whether the call was void (was not expected to return + /// a value). + /// The exception that was raised during the call. + /// A indicating the result of the call. + /// Thrown when is null. + public static CallResult RaisedException(bool wasVoid, Exception exception) + { + if (exception == null) + { + throw new ArgumentNullException(nameof(exception), "Exception cannot be null."); + } + + return new(wasVoid) { Exception = exception }; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 451be5135b..1f82cb0b5d 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -10,80 +10,6 @@ namespace Microsoft.Agents.Workflows.Core; -/// -/// A tag interface for objects that have a unique identifier within an appropriate namespace. -/// -public interface IIdentified -{ - /// - /// The unique identifier. - /// - string Id { get; } -} - -/// -/// . -/// -public record ExecutorCapabilities -{ - /// - /// . - /// - public string Id { get; init; } - /// - /// . - /// - public string Name { get; init; } - /// - /// . - /// - public Type ExecutorType { get; init; } - /// - /// . - /// - public ISet HandledMessageTypes { get; init; } - /// - /// . - /// - public bool IsInitialized { get; init; } - /// - /// . - /// - public ISet StateKeys { get; init; } - - /// - /// . - /// - public ExecutorCapabilities() - { - this.Id = string.Empty; - this.Name = string.Empty; - this.ExecutorType = typeof(Executor); - this.HandledMessageTypes = new HashSet(); - this.IsInitialized = false; - this.StateKeys = new HashSet(); - } - - /// - /// . - /// - /// - /// - /// - /// - /// - /// - public ExecutorCapabilities(string id, string name, Type executorType, ISet handledMessageTypes, bool isInitialized, ISet stateKeys) - { - this.Id = id; - this.Name = name; - this.ExecutorType = executorType; - this.HandledMessageTypes = handledMessageTypes; - this.IsInitialized = isInitialized; - this.StateKeys = stateKeys; - } -} - /// /// . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs new file mode 100644 index 0000000000..7f6ab5aebd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +public record ExecutorCapabilities +{ + /// + /// . + /// + public string Id { get; init; } + /// + /// . + /// + public string Name { get; init; } + /// + /// . + /// + public Type ExecutorType { get; init; } + /// + /// . + /// + public ISet HandledMessageTypes { get; init; } + /// + /// . + /// + public bool IsInitialized { get; init; } + /// + /// . + /// + public ISet StateKeys { get; init; } + + /// + /// . + /// + public ExecutorCapabilities() + { + this.Id = string.Empty; + this.Name = string.Empty; + this.ExecutorType = typeof(Executor); + this.HandledMessageTypes = new HashSet(); + this.IsInitialized = false; + this.StateKeys = new HashSet(); + } + + /// + /// . + /// + /// + /// + /// + /// + /// + /// + public ExecutorCapabilities(string id, string name, Type executorType, ISet handledMessageTypes, bool isInitialized, ISet stateKeys) + { + this.Id = id; + this.Name = name; + this.ExecutorType = executorType; + this.HandledMessageTypes = handledMessageTypes; + this.IsInitialized = isInitialized; + this.StateKeys = stateKeys; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs new file mode 100644 index 0000000000..bd8de4e48b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// A default message handler interface for handling messages that do not have a specific handler registered. +/// +public interface IDefaultMessageHandler +{ + /// + /// Handles the incoming message asynchronously. + /// + /// + /// This is used as a fallback handler for messages that do not have a specific handler registered. + /// + /// The message to handle. + /// The execution context. + /// A task that represents the asynchronous operation. + ValueTask HandleAsync(object message, IWorkflowContext context); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IIdentified.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IIdentified.cs new file mode 100644 index 0000000000..3b58e89665 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/IIdentified.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// A tag interface for objects that have a unique identifier within an appropriate namespace. +/// +public interface IIdentified +{ + /// + /// The unique identifier. + /// + string Id { get; } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageHandler.cs similarity index 68% rename from dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/IMessageHandler.cs index a607736f40..1da548d9ba 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandler.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageHandler.cs @@ -4,23 +4,6 @@ namespace Microsoft.Agents.Workflows.Core; -/// -/// A default message handler interface for handling messages that do not have a specific handler registered. -/// -public interface IDefaultMessageHandler -{ - /// - /// Handles the incoming message asynchronously. - /// - /// - /// This is used as a fallback handler for messages that do not have a specific handler registered. - /// - /// The message to handle. - /// The execution context. - /// A task that represents the asynchronous operation. - ValueTask HandleAsync(object message, IWorkflowContext context); -} - /// /// A message handler interface for handling messages of type . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandlerInfo.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandlerInfo.cs new file mode 100644 index 0000000000..da55f649c2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandlerInfo.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows.Core; + +internal struct MessageHandlerInfo +{ + public Type InType { get; init; } + public Type? OutType { get; init; } = null; + + public MethodInfo HandlerInfo { get; init; } + public Func>? Unwrapper { get; init; } = null; + + [SuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality " + + "when AOT compiling.", Justification = "")] + public MessageHandlerInfo(MethodInfo handlerInfo) + { + // The method is one of the following: + // - ValueTask HandleAsync(TMessage message, IExecutionContext context) + // - ValueTask HandleAsync(TMessage message, IExecutionContext context) + this.HandlerInfo = handlerInfo; + + ParameterInfo[] parameters = handlerInfo.GetParameters(); + if (parameters.Length != 2) + { + throw new ArgumentException("Handler method must have exactly two parameters: TMessage and IExecutionContext.", nameof(handlerInfo)); + } + + if (parameters[1].ParameterType != typeof(IWorkflowContext)) + { + throw new ArgumentException("Handler method's second parameter must be of type IExecutionContext.", nameof(handlerInfo)); + } + + this.InType = parameters[0].ParameterType; + + Type decoratedReturnType = handlerInfo.ReturnType; + if (decoratedReturnType.IsGenericType && decoratedReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) + { + // If the return type is ValueTask, extract TResult. + Type[] returnRawTypes = decoratedReturnType.GetGenericArguments(); + Debug.Assert( + returnRawTypes.Length == 1, + "ValueTask should have exactly one generic argument."); + + this.OutType = returnRawTypes.Single(); + this.Unwrapper = ValueTaskTypeErasure.UnwrapperFor(this.OutType); + } + else if (decoratedReturnType == typeof(ValueTask)) + { + // If the return type is ValueTask, there is no output type. + this.OutType = null; + } + else + { + throw new ArgumentException("Handler method must return ValueTask or ValueTask.", nameof(handlerInfo)); + } + } + + public static Func> Bind(Func handlerAsync, bool checkType, Type? resultType = null, Func>? unwrapper = null) + { + return InvokeHandlerAsync; + + async ValueTask InvokeHandlerAsync(object message, IWorkflowContext workflowContext) + { + bool expectingVoid = resultType == null || resultType == typeof(void); + + try + { + object? maybeValueTask = handlerAsync(message, workflowContext); + + if (expectingVoid) + { + if (maybeValueTask is ValueTask vt) + { + await vt.ConfigureAwait(false); + return CallResult.ReturnVoid(); + } + + throw new InvalidOperationException( + "Handler method is expected to return ValueTask or ValueTask, but returned " + + $"{maybeValueTask?.GetType().Name ?? "null"}."); + } + + Debug.Assert(resultType != null, "Expected resultType to be non-null when not expecting void."); + if (unwrapper == null) + { + throw new InvalidOperationException( + $"Handler method is expected to return ValueTask<{resultType!.Name}>, but no unwrapper is available."); + } + + if (maybeValueTask == null) + { + throw new InvalidOperationException( + $"Handler method returned null, but a ValueTask<{resultType!.Name}> was expected."); + } + + object? result = await unwrapper(maybeValueTask).ConfigureAwait(false); + + if (checkType && result != null && !resultType.IsInstanceOfType(result)) + { + throw new InvalidOperationException( + $"Handler method returned an incompatible type: expected {resultType.Name}, got {result.GetType().Name}."); + } + + return CallResult.ReturnResult(result); + } + catch (Exception ex) + { + // If the handler throws an exception, return it in the CallResult. + return CallResult.RaisedException(wasVoid: expectingVoid, exception: ex); + } + } + } + + public Func> Bind(Executor executor, bool checkType = false) + { + MethodInfo handlerMethod = this.HandlerInfo; + return MessageHandlerInfo.Bind(InvokeHandler, checkType, this.OutType, this.Unwrapper); + + object? InvokeHandler(object message, IWorkflowContext workflowContext) + { + return handlerMethod.Invoke(executor, new object[] { message, workflowContext }); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs new file mode 100644 index 0000000000..43e4ef4d74 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +using HandlerInfosT = + System.Collections.Generic.Dictionary< + System.Type, + Microsoft.Agents.Workflows.Core.MessageHandlerInfo + >; + +namespace Microsoft.Agents.Workflows.Core; + +internal class MessageRouter +{ + // TODO: The goal of the cache is to allow SourceGenerators to do the reflection to bind the handlers in the router. + internal static readonly Dictionary> s_routerFactoryCache = new(); + + private Dictionary>> BoundHandlers { get; init; } = new(); + private IDefaultMessageHandler? DefaultHandler { get; init; } = null; + + [SuppressMessage("Trimming", + "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + + "of the source method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + [SuppressMessage("Trimming", + "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of " + + "method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + private static HandlerInfosT ReflectHandlers(Type executorType) + { + // This method reflects over the methods of the executor type to find message handlers. + HandlerInfosT handlers = new(); + + // Get all implementations of IMessageHandler or IMessageHandler + // and create a MessageHandlerInfo for each. + if (!typeof(Executor).IsAssignableFrom(executorType)) + { + throw new ArgumentException($"Type {executorType.FullName} is not a valid Executor type.", nameof(executorType)); + } + + if (executorType.IsAbstract || executorType.IsInterface) + { + throw new ArgumentException($"Type {executorType.FullName} cannot be abstract or an interface.", nameof(executorType)); + } + + // Iterate all interfaces implemented by the executor type. + foreach (Type interfaceType in executorType.GetInterfaces()) + { + // Check if the interface is a message handler. + if (!interfaceType.IsGenericType) + { + continue; + } + + if (interfaceType.IsGenericType && (interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || + interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>))) + { + // Get the generic arguments of the interface. + Type[] genericArguments = interfaceType.GetGenericArguments(); + if (genericArguments.Length < 1 || genericArguments.Length > 2) + { + continue; // Invalid handler signature. + } + Type inType = genericArguments[0]; + Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; + MethodInfo? method = interfaceType.GetMethod("HandleAsync"); + if (method != null) + { + MessageHandlerInfo info = new(method) { InType = inType, OutType = outType }; + handlers[inType] = info; + } + } + } + + return handlers; + } + + [SuppressMessage("Trimming", + "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + + "of the source method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + internal static HandlerInfosT ReflectHandlers() where TExecutor : Executor + => ReflectHandlers(typeof(TExecutor)); + + internal static MessageRouter BindMessageHandlers(Executor executor, bool checkType) + { + if (s_routerFactoryCache.TryGetValue(executor.GetType(), out var factory)) + { + return factory(); + } + + // If no factory is found, reflect over the handlers + HandlerInfosT handlers = ReflectHandlers(executor.GetType()); + + Dictionary>> boundHandlers = new(); + foreach (Type inType in handlers.Keys) + { + MessageHandlerInfo handlerInfo = handlers[inType]; + Func> boundHandler = handlerInfo.Bind(executor, checkType); + boundHandlers.Add(inType, boundHandler); // TODO: Turn the error here into something more actionable. + } + + return new MessageRouter(boundHandlers, executor as IDefaultMessageHandler); + } + + internal MessageRouter(Dictionary>> handlers, IDefaultMessageHandler? defaultHandler = null) + { + this.BoundHandlers = handlers; + this.DefaultHandler = defaultHandler; + } + + /// + /// . + /// + /// + /// + /// + /// + /// + /// + public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message), "Message cannot be null."); + } + + // TODO: Implement base type delegation? + CallResult? result = null; + if (this.BoundHandlers.TryGetValue(message.GetType(), out Func>? handler)) + { + result = await handler(message, context).ConfigureAwait(false); + } + else if (this.DefaultHandler != null) + { + try + { + await this.DefaultHandler.HandleAsync(message, context).ConfigureAwait(false); + result = CallResult.ReturnVoid(); + } + catch (Exception e) + { + result = CallResult.RaisedException(wasVoid: true, e); + } + } + + return result; + } + + public bool CanHandle(object message) => this.CanHandle(Throw.IfNull(message).GetType()); + + public bool CanHandle(Type candidateType) + { + Throw.IfNull(candidateType); + + // Check if the router can handle the candidate type. + return this.DefaultHandler != null || this.BoundHandlers.ContainsKey(candidateType); + } + + public HashSet IncomingTypes + => this.DefaultHandler != null + ? [.. this.BoundHandlers.Keys, typeof(object)] + : [.. this.BoundHandlers.Keys]; +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs deleted file mode 100644 index 8d899bff94..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouting.cs +++ /dev/null @@ -1,386 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Reflection; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -using HandlerInfosT = - System.Collections.Generic.Dictionary< - System.Type, - Microsoft.Agents.Workflows.Core.MessageHandlerInfo - >; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// This attribute indicates that a message handler streams messages during its execution. -/// -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] -public sealed class StreamsMessageAttribute : Attribute -{ - /// - /// The type of the message that the handler yields. - /// - public Type Type { get; } - - /// - /// Indicates that the message handler yields streaming messages during the course of execution. - /// - public StreamsMessageAttribute(Type type) - { - // This attribute is used to mark executors that yield messages. - this.Type = type ?? throw new ArgumentNullException(nameof(type), "Type cannot be null."); - } -} - -/// -/// This class represents the result of a call to a -/// or . -/// -public sealed class CallResult -{ - /// - /// Indicates whether the call was void (i.e., no result expected). This only applies to - /// calls to handlers. - /// - public bool IsVoid { get; init; } - - /// - /// If the call was successful, this property contains the result of the call. For calls to - /// void handlers, this will be null. - /// - public object? Result { get; init; } = null; - - /// - /// If the call failed, this property contains the exception that was raised during the call. - /// - public Exception? Exception { get; init; } = null; - - /// - /// Indicates whether the call was successful. A call is considered successful if it returned - /// without throwing an exception. - /// - public bool IsSuccess => this.Exception == null; - - private CallResult(bool isVoid = false) - { - // Private constructor to enforce use of static methods. - this.IsVoid = isVoid; - } - - /// - /// Create a indicating a successful that returned a result (non-void). - /// - /// The result to return. - /// A indicating the result of the call. - public static CallResult ReturnResult(object? result = null) - { - return new() { Result = result }; - } - - /// - /// Create a indicating a successful call that returned no result (void). - /// - /// A indicating the result of the call. - public static CallResult ReturnVoid() - { - return new(isVoid: true); - } - - /// - /// Create a indicating that an exception was raised during the call. - /// - /// A boolean specifying whether the call was void (was not expected to return - /// a value). - /// The exception that was raised during the call. - /// A indicating the result of the call. - /// Thrown when is null. - public static CallResult RaisedException(bool wasVoid, Exception exception) - { - if (exception == null) - { - throw new ArgumentNullException(nameof(exception), "Exception cannot be null."); - } - - return new(wasVoid) { Exception = exception }; - } -} - -internal struct MessageHandlerInfo -{ - public Type InType { get; init; } - public Type? OutType { get; init; } = null; - - public MethodInfo HandlerInfo { get; init; } - public Func>? Unwrapper { get; init; } = null; - - [SuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality " + - "when AOT compiling.", Justification = "")] - public MessageHandlerInfo(MethodInfo handlerInfo) - { - // The method is one of the following: - // - ValueTask HandleAsync(TMessage message, IExecutionContext context) - // - ValueTask HandleAsync(TMessage message, IExecutionContext context) - this.HandlerInfo = handlerInfo; - - ParameterInfo[] parameters = handlerInfo.GetParameters(); - if (parameters.Length != 2) - { - throw new ArgumentException("Handler method must have exactly two parameters: TMessage and IExecutionContext.", nameof(handlerInfo)); - } - - if (parameters[1].ParameterType != typeof(IWorkflowContext)) - { - throw new ArgumentException("Handler method's second parameter must be of type IExecutionContext.", nameof(handlerInfo)); - } - - this.InType = parameters[0].ParameterType; - - Type decoratedReturnType = handlerInfo.ReturnType; - if (decoratedReturnType.IsGenericType && decoratedReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) - { - // If the return type is ValueTask, extract TResult. - Type[] returnRawTypes = decoratedReturnType.GetGenericArguments(); - Debug.Assert( - returnRawTypes.Length == 1, - "ValueTask should have exactly one generic argument."); - - this.OutType = returnRawTypes.Single(); - this.Unwrapper = ValueTaskTypeErasure.UnwrapperFor(this.OutType); - } - else if (decoratedReturnType == typeof(ValueTask)) - { - // If the return type is ValueTask, there is no output type. - this.OutType = null; - } - else - { - throw new ArgumentException("Handler method must return ValueTask or ValueTask.", nameof(handlerInfo)); - } - } - - public static Func> Bind(Func handlerAsync, bool checkType, Type? resultType = null, Func>? unwrapper = null) - { - return InvokeHandlerAsync; - - async ValueTask InvokeHandlerAsync(object message, IWorkflowContext workflowContext) - { - bool expectingVoid = resultType == null || resultType == typeof(void); - - try - { - object? maybeValueTask = handlerAsync(message, workflowContext); - - if (expectingVoid) - { - if (maybeValueTask is ValueTask vt) - { - await vt.ConfigureAwait(false); - return CallResult.ReturnVoid(); - } - - throw new InvalidOperationException( - "Handler method is expected to return ValueTask or ValueTask, but returned " + - $"{maybeValueTask?.GetType().Name ?? "null"}."); - } - - Debug.Assert(resultType != null, "Expected resultType to be non-null when not expecting void."); - if (unwrapper == null) - { - throw new InvalidOperationException( - $"Handler method is expected to return ValueTask<{resultType!.Name}>, but no unwrapper is available."); - } - - if (maybeValueTask == null) - { - throw new InvalidOperationException( - $"Handler method returned null, but a ValueTask<{resultType!.Name}> was expected."); - } - - object? result = await unwrapper(maybeValueTask).ConfigureAwait(false); - - if (checkType && result != null && !resultType.IsInstanceOfType(result)) - { - throw new InvalidOperationException( - $"Handler method returned an incompatible type: expected {resultType.Name}, got {result.GetType().Name}."); - } - - return CallResult.ReturnResult(result); - } - catch (Exception ex) - { - // If the handler throws an exception, return it in the CallResult. - return CallResult.RaisedException(wasVoid: expectingVoid, exception: ex); - } - } - } - - public Func> Bind(Executor executor, bool checkType = false) - { - MethodInfo handlerMethod = this.HandlerInfo; - return MessageHandlerInfo.Bind(InvokeHandler, checkType, this.OutType, this.Unwrapper); - - object? InvokeHandler(object message, IWorkflowContext workflowContext) - { - return handlerMethod.Invoke(executor, new object[] { message, workflowContext }); - } - } -} - -internal class MessageRouter -{ - // TODO: The goal of the cache is to allow SourceGenerators to do the reflection to bind the handlers in the router. - internal static readonly Dictionary> s_routerFactoryCache = new(); - - private Dictionary>> BoundHandlers { get; init; } = new(); - private IDefaultMessageHandler? DefaultHandler { get; init; } = null; - - [SuppressMessage("Trimming", - "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + - "of the source method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - [SuppressMessage("Trimming", - "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of " + - "method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - private static HandlerInfosT ReflectHandlers(Type executorType) - { - // This method reflects over the methods of the executor type to find message handlers. - HandlerInfosT handlers = new(); - - // Get all implementations of IMessageHandler or IMessageHandler - // and create a MessageHandlerInfo for each. - if (!typeof(Executor).IsAssignableFrom(executorType)) - { - throw new ArgumentException($"Type {executorType.FullName} is not a valid Executor type.", nameof(executorType)); - } - - if (executorType.IsAbstract || executorType.IsInterface) - { - throw new ArgumentException($"Type {executorType.FullName} cannot be abstract or an interface.", nameof(executorType)); - } - - // Iterate all interfaces implemented by the executor type. - foreach (Type interfaceType in executorType.GetInterfaces()) - { - // Check if the interface is a message handler. - if (!interfaceType.IsGenericType) - { - continue; - } - - if (interfaceType.IsGenericType && (interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || - interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>))) - { - // Get the generic arguments of the interface. - Type[] genericArguments = interfaceType.GetGenericArguments(); - if (genericArguments.Length < 1 || genericArguments.Length > 2) - { - continue; // Invalid handler signature. - } - Type inType = genericArguments[0]; - Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; - MethodInfo? method = interfaceType.GetMethod("HandleAsync"); - if (method != null) - { - MessageHandlerInfo info = new(method) { InType = inType, OutType = outType }; - handlers[inType] = info; - } - } - } - - return handlers; - } - - [SuppressMessage("Trimming", - "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + - "of the source method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - internal static HandlerInfosT ReflectHandlers() where TExecutor : Executor - => ReflectHandlers(typeof(TExecutor)); - - internal static MessageRouter BindMessageHandlers(Executor executor, bool checkType) - { - if (s_routerFactoryCache.TryGetValue(executor.GetType(), out var factory)) - { - return factory(); - } - - // If no factory is found, reflect over the handlers - HandlerInfosT handlers = ReflectHandlers(executor.GetType()); - - Dictionary>> boundHandlers = new(); - foreach (Type inType in handlers.Keys) - { - MessageHandlerInfo handlerInfo = handlers[inType]; - Func> boundHandler = handlerInfo.Bind(executor, checkType); - boundHandlers.Add(inType, boundHandler); // TODO: Turn the error here into something more actionable. - } - - return new MessageRouter(boundHandlers, executor as IDefaultMessageHandler); - } - - internal MessageRouter(Dictionary>> handlers, IDefaultMessageHandler? defaultHandler = null) - { - this.BoundHandlers = handlers; - this.DefaultHandler = defaultHandler; - } - - /// - /// . - /// - /// - /// - /// - /// - /// - /// - public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) - { - if (message == null) - { - throw new ArgumentNullException(nameof(message), "Message cannot be null."); - } - - // TODO: Implement base type delegation? - CallResult? result = null; - if (this.BoundHandlers.TryGetValue(message.GetType(), out Func>? handler)) - { - result = await handler(message, context).ConfigureAwait(false); - } - else if (this.DefaultHandler != null) - { - try - { - await this.DefaultHandler.HandleAsync(message, context).ConfigureAwait(false); - result = CallResult.ReturnVoid(); - } - catch (Exception e) - { - result = CallResult.RaisedException(wasVoid: true, e); - } - } - - return result; - } - - public bool CanHandle(object message) => this.CanHandle(Throw.IfNull(message).GetType()); - - public bool CanHandle(Type candidateType) - { - Throw.IfNull(candidateType); - - // Check if the router can handle the candidate type. - return this.DefaultHandler != null || this.BoundHandlers.ContainsKey(candidateType); - } - - public HashSet IncomingTypes - => this.DefaultHandler != null - ? [.. this.BoundHandlers.Keys, typeof(object)] - : [.. this.BoundHandlers.Keys]; -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs new file mode 100644 index 0000000000..52e1afb457 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// This attribute indicates that a message handler streams messages during its execution. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public sealed class StreamsMessageAttribute : Attribute +{ + /// + /// The type of the message that the handler yields. + /// + public Type Type { get; } + + /// + /// Indicates that the message handler yields streaming messages during the course of execution. + /// + public StreamsMessageAttribute(Type type) + { + // This attribute is used to mark executors that yield messages. + this.Type = type ?? throw new ArgumentNullException(nameof(type), "Type cannot be null."); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/TypeErasure.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ValueTaskTypeErasure.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/TypeErasure.cs rename to dotnet/src/Microsoft.Agents.Workflow/Core/ValueTaskTypeErasure.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs new file mode 100644 index 0000000000..0b84cba03a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class EdgeMap +{ + private readonly Dictionary _edgeRunners = new(); + private readonly Dictionary _fanInState = new(); + private readonly InputEdgeRuner _inputRunner; + + public EdgeMap(IRunnerContext runContext, Dictionary> workflowEdges, string startExecutorId) + { + foreach (FlowEdge edge in workflowEdges.Values.SelectMany(e => e)) + { + object edgeRunner = edge.EdgeType switch + { + FlowEdge.Type.Direct => new DirectEdgeRunner(runContext, edge.DirectEdgeData!), + FlowEdge.Type.FanOut => new FanOutEdgeRunner(runContext, edge.FanOutEdgeData!), + FlowEdge.Type.FanIn => new FanInEdgeRunner(runContext, edge.FanInEdgeData!), + _ => throw new NotSupportedException($"Unsupported edge type: {edge.EdgeType}") + }; + + this._edgeRunners[edge] = edgeRunner; + } + + this._inputRunner = new InputEdgeRuner(runContext, startExecutorId); + } + + public async ValueTask> InvokeEdgeAsync(FlowEdge edge, string sourceId, object message) + { + if (!this._edgeRunners.TryGetValue(edge, out object? edgeRunner)) + { + throw new InvalidOperationException($"Edge {edge} not found in the edge map."); + } + + IEnumerable edgeResults; + switch (edge.EdgeType) + { + // We know the corresponding EdgeRunner type given the FlowEdge EdgeType, as + // established in the EdgeMap() ctor; this avoid doing an as-cast inside of + // the depths of the message delivery loop for every edges (multiplicity N, + // in FanIn/Out cases) + // TODO: Once we have a fixed interface, if it is reasonably generalizable + // between the Runners, we can normalize it behind an IFace. + case FlowEdge.Type.Direct: + { + DirectEdgeRunner runner = (DirectEdgeRunner)this._edgeRunners[edge]; + edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); + break; + } + + case FlowEdge.Type.FanOut: + { + FanOutEdgeRunner runner = (FanOutEdgeRunner)this._edgeRunners[edge]; + edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); + break; + } + + case FlowEdge.Type.FanIn: + { + FanInEdgeState state = this._fanInState[edge]; + FanInEdgeRunner runner = (FanInEdgeRunner)this._edgeRunners[edge]; + edgeResults = [await runner.ChaseAsync(sourceId, message, state).ConfigureAwait(false)]; + break; + } + + default: + throw new InvalidOperationException("Unknown edge type"); + + } + + return edgeResults; + } + + // TODO: Should we promote Input to a true "FlowEdge" type? + public async ValueTask> InvokeInputAsync(object inputMessage) + { + return [await this._inputRunner.ChaseAsync(inputMessage).ConfigureAwait(false)]; + } + + public ValueTask> InvokeResponseAsync(object externalResponse) + { + throw new NotImplementedException(); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutorIdentity.cs similarity index 70% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs rename to dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutorIdentity.cs index 4c99a29cea..b612a735bb 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/Identity.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutorIdentity.cs @@ -5,13 +5,13 @@ namespace Microsoft.Agents.Workflows.Execution; -internal readonly struct Identity : IEquatable +internal readonly struct ExecutorIdentity : IEquatable { - public static Identity None { get; } = new Identity(); + public static ExecutorIdentity None { get; } = new ExecutorIdentity(); public string? Id { get; init; } - public bool Equals(Identity other) + public bool Equals(ExecutorIdentity other) { return this.Id == null ? other.Id == null @@ -30,7 +30,7 @@ public override bool Equals([NotNullWhen(true)] object? obj) return false; } - if (obj is Identity id) + if (obj is ExecutorIdentity id) { return id.Equals(this); } @@ -48,12 +48,12 @@ public override int GetHashCode() return this.Id == null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(this.Id); } - public static implicit operator Identity(string? id) + public static implicit operator ExecutorIdentity(string? id) { - return new Identity { Id = id }; + return new ExecutorIdentity { Id = id }; } - public static implicit operator string?(Identity identity) + public static implicit operator string?(ExecutorIdentity identity) { return identity.Id; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs new file mode 100644 index 0000000000..0d8a8ff422 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows.Execution; + +internal interface IRunnerWithResult +{ + ISuperStepRunner StepRunner { get; } + + ValueTask GetResultAsync(CancellationToken cancellation = default); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs new file mode 100644 index 0000000000..f2c6b5f929 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal interface ISuperStepRunner +{ + ValueTask EnqueueMessageAsync(object message); + + event EventHandler? WorkflowEvent; + + ValueTask RunSuperStepAsync(CancellationToken cancellation); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index 1605c77c5a..89e18037cf 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -10,88 +10,6 @@ namespace Microsoft.Agents.Workflows.Execution; -internal class EdgeMap -{ - private readonly Dictionary _edgeRunners = new(); - private readonly Dictionary _fanInState = new(); - private readonly InputEdgeRuner _inputRunner; - - public EdgeMap(IRunnerContext runContext, Dictionary> workflowEdges, string startExecutorId) - { - foreach (FlowEdge edge in workflowEdges.Values.SelectMany(e => e)) - { - object edgeRunner = edge.EdgeType switch - { - FlowEdge.Type.Direct => new DirectEdgeRunner(runContext, edge.DirectEdgeData!), - FlowEdge.Type.FanOut => new FanOutEdgeRunner(runContext, edge.FanOutEdgeData!), - FlowEdge.Type.FanIn => new FanInEdgeRunner(runContext, edge.FanInEdgeData!), - _ => throw new NotSupportedException($"Unsupported edge type: {edge.EdgeType}") - }; - - this._edgeRunners[edge] = edgeRunner; - } - - this._inputRunner = new InputEdgeRuner(runContext, startExecutorId); - } - - public async ValueTask> InvokeEdgeAsync(FlowEdge edge, string sourceId, object message) - { - if (!this._edgeRunners.TryGetValue(edge, out object? edgeRunner)) - { - throw new InvalidOperationException($"Edge {edge} not found in the edge map."); - } - - IEnumerable edgeResults; - switch (edge.EdgeType) - { - // We know the corresponding EdgeRunner type given the FlowEdge EdgeType, as - // established in the EdgeMap() ctor; this avoid doing an as-cast inside of - // the depths of the message delivery loop for every edges (multiplicity N, - // in FanIn/Out cases) - // TODO: Once we have a fixed interface, if it is reasonably generalizable - // between the Runners, we can normalize it behind an IFace. - case FlowEdge.Type.Direct: - { - DirectEdgeRunner runner = (DirectEdgeRunner)this._edgeRunners[edge]; - edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); - break; - } - - case FlowEdge.Type.FanOut: - { - FanOutEdgeRunner runner = (FanOutEdgeRunner)this._edgeRunners[edge]; - edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); - break; - } - - case FlowEdge.Type.FanIn: - { - FanInEdgeState state = this._fanInState[edge]; - FanInEdgeRunner runner = (FanInEdgeRunner)this._edgeRunners[edge]; - edgeResults = [await runner.ChaseAsync(sourceId, message, state).ConfigureAwait(false)]; - break; - } - - default: - throw new InvalidOperationException("Unknown edge type"); - - } - - return edgeResults; - } - - // TODO: Should we promote Input to a true "FlowEdge" type? - public async ValueTask> InvokeInputAsync(object inputMessage) - { - return [await this._inputRunner.ChaseAsync(inputMessage).ConfigureAwait(false)]; - } - - public ValueTask> InvokeResponseAsync(object externalResponse) - { - throw new NotImplementedException(); - } -} - internal class LocalRunner : ISuperStepRunner where TInput : notnull { public LocalRunner(Workflow workflow) @@ -172,7 +90,7 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) { // Deliver the messages and queue the next step List>> edgeTasks = new(); - foreach (Identity sender in currentStep.QueuedMessages.Keys) + foreach (ExecutorIdentity sender in currentStep.QueuedMessages.Keys) { IEnumerable senderMessages = currentStep.QueuedMessages[sender]; if (sender.Id is null) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index c58a891e7f..e915d16157 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -43,7 +43,7 @@ public ValueTask AddExternalMessageAsync([NotNull] object message) { Throw.IfNull(message); - this._nextStep.MessagesFor(Identity.None).Add(message); + this._nextStep.MessagesFor(ExecutorIdentity.None).Add(message); return CompletedValueTaskSource.Completed; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs index ba305a4b46..07d30267ed 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.Workflows.Execution; internal class StepContext { - public Dictionary> QueuedMessages { get; } = new(); + public Dictionary> QueuedMessages { get; } = new(); public bool HasMessages => this.QueuedMessages.Values.Any(messageList => messageList.Count > 0); diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs similarity index 94% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs rename to dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs index 6bab2c0aea..ca915e24e7 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs @@ -10,22 +10,6 @@ namespace Microsoft.Agents.Workflows.Execution; -internal interface ISuperStepRunner -{ - ValueTask EnqueueMessageAsync(object message); - - event EventHandler? WorkflowEvent; - - ValueTask RunSuperStepAsync(CancellationToken cancellation); -} - -internal interface IRunnerWithResult -{ - ISuperStepRunner StepRunner { get; } - - ValueTask GetResultAsync(CancellationToken cancellation = default); -} - /// /// . /// From 50a0023cb9f40914ac9e5fb527d63b03de56edef Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 13:59:16 -0400 Subject: [PATCH 111/232] refactor: Simplify Disposal pattern in Executor --- .../Core/DisposableObject.cs | 59 ------------------- .../Core/Executor.cs | 13 ++-- 2 files changed, 9 insertions(+), 63 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs deleted file mode 100644 index a754870660..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/DisposableObject.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Diagnostics; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// Provides a base class implementing the interface using -/// the virtual Dispose pattern. -/// -public class DisposableObject : IAsyncDisposable -{ - /// - /// Implements invocation of the DisposeAsync method when the object is finalized to - /// dispose unmanaged resources properly. - /// - ~DisposableObject() - { - // Finalizer calls DisposeAsync to ensure resources are released. - // This is a safety net in case DisposeAsync was not called. -#pragma warning disable CA2012 // Use ValueTasks correctly: Uses OnCompleted to properly handle the ValueTask return. - ValueTask disposeTask = this.DisposeAsync(false); -#pragma warning restore CA2012 // Use ValueTasks correctly - - if (!disposeTask.IsCompleted) - { - using (ManualResetEvent barrier = new(false)) - { - disposeTask.GetAwaiter().OnCompleted(() => barrier.Set()); - - // Wait for the DisposeAsync to complete. - barrier.WaitOne(); // TODO: Timeout? - } - } - - Debug.Assert( - disposeTask.IsCompleted, - "DisposeAsync should have completed in order to pass to this line."); -#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits - disposeTask.GetAwaiter().GetResult(); -#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits - } - - /// - protected virtual ValueTask DisposeAsync(bool disposing) - { - return CompletedValueTaskSource.Completed; - } - - /// - public async ValueTask DisposeAsync() - { - await this.DisposeAsync(true).ConfigureAwait(false); - GC.SuppressFinalize(this); - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 1f82cb0b5d..1a95663f57 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -14,7 +14,7 @@ namespace Microsoft.Agents.Workflows.Core; /// . /// [DebuggerDisplay("{GetType().Name}{Id}({Name})")] -public abstract class Executor : DisposableObject, IIdentified +public abstract class Executor : IIdentified, IAsyncDisposable { /// /// . @@ -192,14 +192,19 @@ private async ValueTask FlushReduceRemainingAsync() /// /// . /// - /// /// - protected override async ValueTask DisposeAsync(bool disposing = false) + protected virtual async ValueTask DisposeAsync() { this._initialized = false; await this.FlushReduceRemainingAsync().ConfigureAwait(false); + } + + ValueTask IAsyncDisposable.DisposeAsync() + { + GC.SuppressFinalize(this); // Should we be suppressing the finalizer here? CodeAnalysis seems to want it (CA1816) - await base.DisposeAsync(disposing).ConfigureAwait(false); + // Chain to the virtual call to DisposeAsync. + return this.DisposeAsync(); } } From ea1476136be9408cfd6c2b8e510badabc738e33f Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 14:01:06 -0400 Subject: [PATCH 112/232] refactor: Break EdgeRunner file into per-type files --- .../Execution/DirectEdgeRunner.cs | 38 +++++ .../Execution/EdgeRunner.cs | 159 ------------------ .../Execution/FanInEdgeRunner.cs | 36 ++++ .../Execution/FanInEdgeState.cs | 38 +++++ .../Execution/FanOutEdgeRunner.cs | 43 +++++ .../Execution/InputEdgeRuner.cs | 34 ++++ 6 files changed, 189 insertions(+), 159 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeState.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs new file mode 100644 index 0000000000..29b1b2dc83 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData edgeData) : + EdgeRunner(runContext, edgeData) +{ + public IWorkflowContext WorkflowContext { get; } = runContext.Bind(edgeData.SinkId); + + private async ValueTask FindRouterAsync() + { + Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); + + return sink.MessageRouter; + } + + public async ValueTask> ChaseAsync(object message) + { + if (this.EdgeData.Condition != null && !this.EdgeData.Condition(message)) + { + return []; + } + + MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); + if (router.CanHandle(message)) + { + return [await router.RouteMessageAsync(message, this.WorkflowContext) + .ConfigureAwait(false)]; + } + + return []; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs index 05f6f6f887..8871f9d8bb 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs @@ -1,9 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Execution; @@ -14,158 +10,3 @@ internal abstract class EdgeRunner( protected IRunnerContext RunContext { get; } = Throw.IfNull(runContext); protected TEdgeData EdgeData { get; } = Throw.IfNull(edgeData); } - -internal class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData edgeData) : - EdgeRunner(runContext, edgeData) -{ - public IWorkflowContext WorkflowContext { get; } = runContext.Bind(edgeData.SinkId); - - private async ValueTask FindRouterAsync() - { - Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) - .ConfigureAwait(false); - - return sink.MessageRouter; - } - - public async ValueTask> ChaseAsync(object message) - { - if (this.EdgeData.Condition != null && !this.EdgeData.Condition(message)) - { - return []; - } - - MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); - if (router.CanHandle(message)) - { - return [await router.RouteMessageAsync(message, this.WorkflowContext) - .ConfigureAwait(false)]; - } - - return []; - } -} - -internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeData) : - EdgeRunner(runContext, edgeData) -{ - private Dictionary BoundContexts { get; } - = edgeData.SinkIds.ToDictionary( - sinkId => sinkId, - sinkId => runContext.Bind(sinkId)); - - public async ValueTask> ChaseAsync(object message) - { - List targets = - this.EdgeData.Partitioner == null - ? this.EdgeData.SinkIds - : this.EdgeData.Partitioner(message, this.BoundContexts.Count).Select(i => this.EdgeData.SinkIds[i]).ToList(); - - CallResult?[] result = await Task.WhenAll(targets.Select(ProcessTargetAsync)).ConfigureAwait(false); - return result.Where(r => r is not null); - - async Task ProcessTargetAsync(string targetId) - { - Executor executor = await this.RunContext.EnsureExecutorAsync(targetId) - .ConfigureAwait(false); - - MessageRouter router = executor.MessageRouter; - if (router.CanHandle(message)) - { - return await router.RouteMessageAsync(message, this.BoundContexts[targetId]) - .ConfigureAwait(false); - } - - return null; - } - } -} - -internal record FanInEdgeState(FanInEdgeData EdgeData) -{ - private List? _pendingMessages - = EdgeData.Trigger == FanInTrigger.WhenAll ? [] : null; - - private HashSet? _unseen - = EdgeData.Trigger == FanInTrigger.WhenAll ? new(EdgeData.SourceIds) : null; - - public IEnumerable? ProcessMessage(string sourceId, object message) - { - if (this.EdgeData.Trigger == FanInTrigger.WhenAll) - { - this._pendingMessages!.Add(message); - this._unseen!.Remove(sourceId); - - if (this._unseen.Count == 0) - { - List result = this._pendingMessages; - - this._pendingMessages = []; - this._unseen = new(this.EdgeData.SourceIds); - - return result; - } - - return null; - } - - return [message]; - } -} - -internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData) : - EdgeRunner(runContext, edgeData) -{ - private IWorkflowContext BoundContext { get; } = runContext.Bind(edgeData.SinkId); - - public FanInEdgeState CreateState() => new(this.EdgeData); - - public async ValueTask ChaseAsync(string sourceId, object message, FanInEdgeState state) - { - IEnumerable? releasedMessages = state.ProcessMessage(sourceId, message); - if (releasedMessages is null) - { - // Not ready to process yet. - return null; - } - - Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) - .ConfigureAwait(false); - - MessageRouter router = sink.MessageRouter; - if (router.CanHandle(message)) - { - return await router.RouteMessageAsync(message, this.BoundContext) - .ConfigureAwait(false); - } - return null; - } -} - -internal class InputEdgeRuner(IRunnerContext runContext, string sinkId) - : EdgeRunner(runContext, sinkId) -{ - public IWorkflowContext WorkflowContext { get; } = runContext.Bind(sinkId); - - private async ValueTask FindRouterAsync() - { - Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData) - .ConfigureAwait(false); - - return sink.MessageRouter; - } - - public async ValueTask ChaseAsync(object message) - { - MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); - if (router.CanHandle(message)) - { - return await router.RouteMessageAsync(message, this.WorkflowContext) - .ConfigureAwait(false); - } - - // TODO: Throw instead? - - return null; - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs new file mode 100644 index 0000000000..585f7e1833 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData) : + EdgeRunner(runContext, edgeData) +{ + private IWorkflowContext BoundContext { get; } = runContext.Bind(edgeData.SinkId); + + public FanInEdgeState CreateState() => new(this.EdgeData); + + public async ValueTask ChaseAsync(string sourceId, object message, FanInEdgeState state) + { + IEnumerable? releasedMessages = state.ProcessMessage(sourceId, message); + if (releasedMessages is null) + { + // Not ready to process yet. + return null; + } + + Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); + + MessageRouter router = sink.MessageRouter; + if (router.CanHandle(message)) + { + return await router.RouteMessageAsync(message, this.BoundContext) + .ConfigureAwait(false); + } + return null; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeState.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeState.cs new file mode 100644 index 0000000000..2747969d91 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeState.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal record FanInEdgeState(FanInEdgeData EdgeData) +{ + private List? _pendingMessages + = EdgeData.Trigger == FanInTrigger.WhenAll ? [] : null; + + private HashSet? _unseen + = EdgeData.Trigger == FanInTrigger.WhenAll ? new(EdgeData.SourceIds) : null; + + public IEnumerable? ProcessMessage(string sourceId, object message) + { + if (this.EdgeData.Trigger == FanInTrigger.WhenAll) + { + this._pendingMessages!.Add(message); + this._unseen!.Remove(sourceId); + + if (this._unseen.Count == 0) + { + List result = this._pendingMessages; + + this._pendingMessages = []; + this._unseen = new(this.EdgeData.SourceIds); + + return result; + } + + return null; + } + + return [message]; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs new file mode 100644 index 0000000000..7ff3e9c171 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeData) : + EdgeRunner(runContext, edgeData) +{ + private Dictionary BoundContexts { get; } + = edgeData.SinkIds.ToDictionary( + sinkId => sinkId, + sinkId => runContext.Bind(sinkId)); + + public async ValueTask> ChaseAsync(object message) + { + List targets = + this.EdgeData.Partitioner == null + ? this.EdgeData.SinkIds + : this.EdgeData.Partitioner(message, this.BoundContexts.Count).Select(i => this.EdgeData.SinkIds[i]).ToList(); + + CallResult?[] result = await Task.WhenAll(targets.Select(ProcessTargetAsync)).ConfigureAwait(false); + return result.Where(r => r is not null); + + async Task ProcessTargetAsync(string targetId) + { + Executor executor = await this.RunContext.EnsureExecutorAsync(targetId) + .ConfigureAwait(false); + + MessageRouter router = executor.MessageRouter; + if (router.CanHandle(message)) + { + return await router.RouteMessageAsync(message, this.BoundContexts[targetId]) + .ConfigureAwait(false); + } + + return null; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs new file mode 100644 index 0000000000..2ba64f1ee8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class InputEdgeRuner(IRunnerContext runContext, string sinkId) + : EdgeRunner(runContext, sinkId) +{ + public IWorkflowContext WorkflowContext { get; } = runContext.Bind(sinkId); + + private async ValueTask FindRouterAsync() + { + Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData) + .ConfigureAwait(false); + + return sink.MessageRouter; + } + + public async ValueTask ChaseAsync(object message) + { + MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); + if (router.CanHandle(message)) + { + return await router.RouteMessageAsync(message, this.WorkflowContext) + .ConfigureAwait(false); + } + + // TODO: Throw instead? + + return null; + } +} From 6f7c0924a375e865a0930885eeb49b6714e0c2de Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 14:06:47 -0400 Subject: [PATCH 113/232] refactor: Use Throw.IfNull() --- dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs | 6 ++---- dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs | 6 ++---- dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs | 6 +++--- dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs | 5 +---- .../Core/StreamsMessageAttribute.cs | 3 ++- dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs | 4 ++-- .../src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs | 2 +- dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs | 7 ++++--- .../Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs | 1 - 9 files changed, 17 insertions(+), 23 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs index 9b484610b0..934c03d43c 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; @@ -68,10 +69,7 @@ public static CallResult ReturnVoid() /// Thrown when is null. public static CallResult RaisedException(bool wasVoid, Exception exception) { - if (exception == null) - { - throw new ArgumentNullException(nameof(exception), "Exception cannot be null."); - } + Throw.IfNull(exception); return new(wasVoid) { Exception = exception }; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 1a95663f57..c617bb52e6 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -7,6 +7,7 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; @@ -142,10 +143,7 @@ public ExecutorCapabilities Capabilities /// public void RestoreState(IDictionary state) { - if (state == null) - { - throw new ArgumentNullException(nameof(state), "State cannot be null."); - } + Throw.IfNull(state); this.State.Clear(); diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs index c3c6bd2bbe..e94fad7848 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; - +using Microsoft.Shared.Diagnostics; using ExecutorId = string; // TODO: Unclear whether this should be forcibly a serializable type. using MetadataValueT = object; @@ -93,8 +93,8 @@ public record Message /// public Message(TContent content, MessageMetadata metadata) { - this.Content = content ?? throw new ArgumentNullException(nameof(content)); - this.Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); + this.Content = Throw.IfNull(content); + this.Metadata = Throw.IfNull(metadata); } /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs index 43e4ef4d74..0e7fce5082 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs @@ -125,10 +125,7 @@ internal MessageRouter(Dictionary public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) { - if (message == null) - { - throw new ArgumentNullException(nameof(message), "Message cannot be null."); - } + Throw.IfNull(message); // TODO: Implement base type delegation? CallResult? result = null; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs index 52e1afb457..c79d8fb8ab 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; @@ -21,6 +22,6 @@ public sealed class StreamsMessageAttribute : Attribute public StreamsMessageAttribute(Type type) { // This attribute is used to mark executors that yield messages. - this.Type = type ?? throw new ArgumentNullException(nameof(type), "Type cannot be null."); + this.Type = Throw.IfNull(type); } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs index bb92518d7b..e246635e89 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs @@ -25,8 +25,8 @@ public Type InputType public Workflow(string startExecutorId, Type type) { - this.StartExecutorId = startExecutorId ?? throw new ArgumentNullException(nameof(startExecutorId)); - this.InputType = type ?? throw new ArgumentNullException(nameof(type)); + this.StartExecutorId = Throw.IfNull(startExecutorId); + this.InputType = Throw.IfNull(type); // TODO: How do we (1) ensure the types are happy, and (2) work under AOT? } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index 89e18037cf..0646a25b49 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -14,7 +14,7 @@ internal class LocalRunner : ISuperStepRunner where TInput : notnull { public LocalRunner(Workflow workflow) { - this.Workflow = workflow ?? throw new ArgumentNullException(nameof(workflow)); + this.Workflow = Throw.IfNull(workflow); this.RunContext = new LocalRunnerContext(workflow); // Initialize the runners for each of the edges, along with the state for edges that diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs index f40fbf606f..a1fa16dd58 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -2,6 +2,7 @@ using System; using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; @@ -29,13 +30,13 @@ public enum Type public ExecutorIsh(Executor executor) { this.ExecutorType = Type.Executor; - this._executorValue = executor ?? throw new ArgumentNullException(nameof(executor)); + this._executorValue = Throw.IfNull(executor); } public ExecutorIsh(string id) { this.ExecutorType = Type.Unbound; - this._idValue = id ?? throw new ArgumentNullException(nameof(id)); + this._idValue = Throw.IfNull(id); } public bool IsUnbound => this.ExecutorType == Type.Unbound; @@ -63,7 +64,7 @@ public ExecutorIsh(string id) //public ExecutorIsh(Func function) //{ // this.ExecutorType = Type.Function; - // this._functionValue = function ?? throw new ArgumentNullException(nameof(function)); + // this._functionValue = Throw.IfNull(function); //} // Implicit conversions into ExecutorIsh diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index e3dae65cf3..3a0f3a2fbd 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.Linq; using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; From bf8526afcd586f162f068068d801b63b4eaef8c8 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 14:17:14 -0400 Subject: [PATCH 114/232] refactor: Remove AddLoop() Per https://github.com/microsoft/agent-framework/pull/272#discussion_r2241739079 we decided this was not very useful. --- .../WorkflowBuilderExtensions.cs | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index 3a0f3a2fbd..7b2836952b 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -9,18 +9,6 @@ namespace Microsoft.Agents.Workflows; internal static class WorkflowBuilderExtensions { - public static WorkflowBuilder AddLoop(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh loopBody, Func? condition = null) - { - Throw.IfNull(builder); - Throw.IfNull(source); - Throw.IfNull(loopBody); - - builder.AddEdge(source, loopBody, condition); - builder.AddEdge(loopBody, source); - - return builder; - } - public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors) { Throw.IfNull(builder); @@ -62,22 +50,4 @@ public static Workflow BuildWithOutput workflow = builder.Build(); return workflow.Promote(outputSink); } - - //public static WorkflowBuilder AddMapReduce } - -//class T -//{ -// async Task A() -// { -// WorkflowBuilder b; - -// Workflow> wf = -// WorkflowBuilderExtensions.BuildWithOutput>(b, "my_last_node", StreamingAggregators.Union()); - -// LocalRunner> runner = new(wf); - -// await runner.RunAsync(42).ConfigureAwait(false); -// var result = runner.RunningOutput; -// } -//} From cc016131f677b3e56f663338b587e09d6d9835fe Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 5 Aug 2025 14:20:25 -0400 Subject: [PATCH 115/232] refactor: Normalize use of ValueTask --- .../Core/CompletedValueTaskSource.cs | 23 ------------------- .../Core/Executor.cs | 6 ++--- .../Execution/LocalRunner.cs | 2 +- .../Execution/LocalRunnerContext.cs | 6 ++--- .../OutputCollectorExecutor.cs | 2 +- .../Sample/02_Simple_Workflow_Sequential.cs | 4 ++-- .../Sample/02a_Simple_Workflow_Condition.cs | 2 +- .../Sample/02b_Simple_Workflow_Loop.cs | 6 ++--- 8 files changed, 14 insertions(+), 37 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs deleted file mode 100644 index 2c8cec1e81..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/CompletedValueTaskSource.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// Helper class to work around lack of proper ValueTask support in .NET Framework. -/// -internal static class CompletedValueTaskSource -{ - internal static ValueTask Completed => -#if NET5_0_OR_GREATER - ValueTask.CompletedTask; -#else - new(Task.CompletedTask); -#endif - - internal static ValueTask FromResult(T result) - { - return new ValueTask(result); - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index c617bb52e6..6ba34b4457 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -159,7 +159,7 @@ public void RestoreState(IDictionary state) /// protected virtual ValueTask PrepareForCheckpointAsync() { - return CompletedValueTaskSource.Completed; + return default; } /// @@ -168,7 +168,7 @@ protected virtual ValueTask PrepareForCheckpointAsync() /// protected virtual ValueTask AfterCheckpointRestoreAsync() { - return CompletedValueTaskSource.Completed; + return default; } /// @@ -179,7 +179,7 @@ protected virtual ValueTask AfterCheckpointRestoreAsync() protected virtual ValueTask InitializeOverride(IWorkflowContext context) { // Default implementation does nothing. - return CompletedValueTaskSource.Completed; + return default; } private async ValueTask FlushReduceRemainingAsync() diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index 0646a25b49..a155dfe2c2 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -143,7 +143,7 @@ public async ValueTask StreamAsync(TInput input, Cance public ValueTask GetResultAsync(CancellationToken cancellation = default) { // TODO: Block on finishing consuming StreamAsync()? - return CompletedValueTaskSource.FromResult(this.RunningOutput!); + return new ValueTask(this.RunningOutput!); } public TResult? RunningOutput => this._workflow.RunningOutput; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index e915d16157..2a4edcbcf4 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -44,7 +44,7 @@ public ValueTask AddExternalMessageAsync([NotNull] object message) Throw.IfNull(message); this._nextStep.MessagesFor(ExecutorIdentity.None).Add(message); - return CompletedValueTaskSource.Completed; + return default; } public bool NextStepHasActions => this._nextStep.HasMessages; @@ -57,13 +57,13 @@ public StepContext Advance() public ValueTask AddEventAsync(string executorId, WorkflowEvent workflowEvent) { this.QueuedEvents.Add(workflowEvent); - return CompletedValueTaskSource.Completed; + return default; } public ValueTask SendMessageAsync(string executorId, object message) { this._nextStep.MessagesFor(message.GetType().Name).Add(message); - return CompletedValueTaskSource.Completed; + return default; } public IWorkflowContext Bind(string executorId) diff --git a/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs index 3417b36161..3380293cfa 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs @@ -25,6 +25,6 @@ public OutputCollectorExecutor(StreamingAggregator aggregator, public ValueTask HandleAsync(TInput message, IWorkflowContext context) { this.Result = this._aggregator(message); - return CompletedValueTaskSource.Completed; + return default; } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs index af50478e26..c731a2c3f1 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs @@ -28,7 +28,7 @@ internal sealed class UppercaseExecutor : Executor, IMessageHandler HandleAsync(string message, IWorkflowContext context) { - return CompletedValueTaskSource.FromResult(message.ToUpperInvariant()); + return new ValueTask(message.ToUpperInvariant()); } } @@ -38,6 +38,6 @@ public ValueTask HandleAsync(string message, IWorkflowContext context) { char[] charArray = message.ToCharArray(); System.Array.Reverse(charArray); - return CompletedValueTaskSource.FromResult(new string(charArray)); + return new ValueTask(new string(charArray)); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs index d782783404..4040452540 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs @@ -47,7 +47,7 @@ public ValueTask HandleAsync(string message, IWorkflowContext context) bool isSpam = this.SpamKeywords.Any(keyword => message.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0); #endif - return CompletedValueTaskSource.FromResult(isSpam); + return new ValueTask(isSpam); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs index 3ce581bf8b..9b09b23306 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs @@ -79,15 +79,15 @@ public ValueTask HandleAsync(int message, IWorkflowContext context { if (message == this._targetNumber) { - return CompletedValueTaskSource.FromResult(NumberSignal.Matched); + return new ValueTask(NumberSignal.Matched); } else if (message < this._targetNumber) { - return CompletedValueTaskSource.FromResult(NumberSignal.Below); + return new ValueTask(NumberSignal.Below); } else { - return CompletedValueTaskSource.FromResult(NumberSignal.Above); + return new ValueTask(NumberSignal.Above); } } } From 85f3ab806e4390094bac50a5dfb434441bb8d892 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 10:06:46 -0400 Subject: [PATCH 116/232] fix: Build Break from removing .AddLoop --- .../Sample/02b_Simple_Workflow_Loop.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs index 9b09b23306..df69c47167 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs @@ -14,7 +14,8 @@ public static async ValueTask RunAsync() JudgeExecutor judge = new(42); // Let's say the target number is 42 Workflow workflow = new WorkflowBuilder(guessNumber) - .AddLoop(guessNumber, judge) + .AddEdge(guessNumber, judge) + .AddEdge(judge, guessNumber) .Build(); LocalRunner runner = new(workflow); From e4c5547eef9081d2cef8a277c3f98333eef58ddf Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 10:08:37 -0400 Subject: [PATCH 117/232] refactor: Explicit routing and RouteBuilder Split out reflection from MessageRouter implemention into build phase, enabling AOT compilation to drive RouteBuilding without reflection. --- .../Core/Executor.cs | 71 +++++--- .../Core/IMessageRouter.cs | 16 ++ .../Core/MessageRouter.cs | 156 +++--------------- .../Core/RouteBuilder.cs | 92 +++++++++++ .../Core/RouteBuilderExtensions.cs | 78 +++++++++ .../Execution/DirectEdgeRunner.cs | 2 +- .../Execution/FanInEdgeRunner.cs | 2 +- .../Execution/FanOutEdgeRunner.cs | 2 +- .../Execution/InputEdgeRuner.cs | 2 +- 9 files changed, 260 insertions(+), 161 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/IMessageRouter.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 6ba34b4457..ebaea05391 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -27,7 +27,6 @@ public abstract class Executor : IIdentified, IAsyncDisposable /// public string Name { get; } - internal MessageRouter MessageRouter { get; init; } private Dictionary State { get; } = new(); /// @@ -39,8 +38,32 @@ protected Executor(string? id = null, string? name = null) { this.Name = name ?? this.GetType().Name; this.Id = id ?? $"{this.Name}{Guid.NewGuid():N}"; + } + + /// + /// Override this method to register handlers for the executor. The deafult implementation uses reflection to + /// look for implementations of and . + /// + /// + /// + protected virtual RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + return routeBuilder.ReflectHandlers(this); + } - this.MessageRouter = MessageRouter.BindMessageHandlers(this, checkType: true); + private MessageRouter? _router = null; + internal MessageRouter Router + { + get + { + if (this._router == null) + { + RouteBuilder routeBuilder = this.ConfigureRoutes(new RouteBuilder()); + this._router = routeBuilder.Build(); + } + + return this._router; + } } /// @@ -55,7 +78,7 @@ protected Executor(string? id = null, string? name = null) { await context.AddEventAsync(new ExecutorInvokeEvent(this.Id)).ConfigureAwait(false); - CallResult? result = await this.MessageRouter.RouteMessageAsync(message, context, requireRoute: true) + CallResult? result = await this.Router.RouteMessageAsync(message, context, requireRoute: true) .ConfigureAwait(false); await context.AddEventAsync(new ExecutorCompleteEvent(this.Id)).ConfigureAwait(false); @@ -81,10 +104,25 @@ protected Executor(string? id = null, string? name = null) private bool _initialized = false; + /// + /// Ensures that the executor has been initialized before performing operations. + /// + /// This method checks the internal state of the executor and throws an exception if it has not + /// been initialized. Call InitializeAsync before invoking any operations that require + /// initialization. + /// Thrown if the executor has not been initialized by calling InitializeAsync. + protected void CheckInitialized() + { + if (!this._initialized) + { + throw new InvalidOperationException($"Executor {this.GetType().Name} is not initialized. Call InitializeAsync first."); + } + } + /// /// . /// - public ISet InputTypes => this.MessageRouter.IncomingTypes; + public ISet InputTypes => this.Router.IncomingTypes; /// /// . @@ -97,7 +135,7 @@ protected Executor(string? id = null, string? name = null) /// /// /// - public bool CanHandle(Type messageType) => this.MessageRouter.CanHandle(messageType); + public bool CanHandle(Type messageType) => this.Router.CanHandle(messageType); /// /// . @@ -157,35 +195,20 @@ public void RestoreState(IDictionary state) /// . /// /// - protected virtual ValueTask PrepareForCheckpointAsync() - { - return default; - } + protected virtual ValueTask PrepareForCheckpointAsync() => default; /// /// . /// /// - protected virtual ValueTask AfterCheckpointRestoreAsync() - { - return default; - } + protected virtual ValueTask AfterCheckpointRestoreAsync() => default; /// /// . /// /// /// - protected virtual ValueTask InitializeOverride(IWorkflowContext context) - { - // Default implementation does nothing. - return default; - } - - private async ValueTask FlushReduceRemainingAsync() - { - return; - } + protected virtual ValueTask InitializeOverride(IWorkflowContext context) => default; /// /// . @@ -194,8 +217,6 @@ private async ValueTask FlushReduceRemainingAsync() protected virtual async ValueTask DisposeAsync() { this._initialized = false; - - await this.FlushReduceRemainingAsync().ConfigureAwait(false); } ValueTask IAsyncDisposable.DisposeAsync() diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageRouter.cs new file mode 100644 index 0000000000..8876220a8e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageRouter.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows.Core; + +internal interface IMessageRouter +{ + HashSet IncomingTypes { get; } + + bool CanHandle(object message); + bool CanHandle(Type candidateType); + ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs index 0e7fce5082..29281c63bc 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs @@ -2,165 +2,57 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -using HandlerInfosT = - System.Collections.Generic.Dictionary< - System.Type, - Microsoft.Agents.Workflows.Core.MessageHandlerInfo +using MessageHandlerF = + System.Func< + object, // message + Microsoft.Agents.Workflows.Core.IWorkflowContext, // context + System.Threading.Tasks.ValueTask >; namespace Microsoft.Agents.Workflows.Core; -internal class MessageRouter +internal class MessageRouter : IMessageRouter { - // TODO: The goal of the cache is to allow SourceGenerators to do the reflection to bind the handlers in the router. - internal static readonly Dictionary> s_routerFactoryCache = new(); + private readonly Dictionary _typedHandlers; + private readonly bool _hasCatchall; - private Dictionary>> BoundHandlers { get; init; } = new(); - private IDefaultMessageHandler? DefaultHandler { get; init; } = null; - - [SuppressMessage("Trimming", - "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + - "of the source method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - [SuppressMessage("Trimming", - "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of " + - "method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - private static HandlerInfosT ReflectHandlers(Type executorType) + internal MessageRouter(Dictionary handlers) { - // This method reflects over the methods of the executor type to find message handlers. - HandlerInfosT handlers = new(); - - // Get all implementations of IMessageHandler or IMessageHandler - // and create a MessageHandlerInfo for each. - if (!typeof(Executor).IsAssignableFrom(executorType)) - { - throw new ArgumentException($"Type {executorType.FullName} is not a valid Executor type.", nameof(executorType)); - } - - if (executorType.IsAbstract || executorType.IsInterface) - { - throw new ArgumentException($"Type {executorType.FullName} cannot be abstract or an interface.", nameof(executorType)); - } - - // Iterate all interfaces implemented by the executor type. - foreach (Type interfaceType in executorType.GetInterfaces()) - { - // Check if the interface is a message handler. - if (!interfaceType.IsGenericType) - { - continue; - } - - if (interfaceType.IsGenericType && (interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || - interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>))) - { - // Get the generic arguments of the interface. - Type[] genericArguments = interfaceType.GetGenericArguments(); - if (genericArguments.Length < 1 || genericArguments.Length > 2) - { - continue; // Invalid handler signature. - } - Type inType = genericArguments[0]; - Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; - MethodInfo? method = interfaceType.GetMethod("HandleAsync"); - if (method != null) - { - MessageHandlerInfo info = new(method) { InType = inType, OutType = outType }; - handlers[inType] = info; - } - } - } - - return handlers; + this._typedHandlers = Throw.IfNull(handlers); + this._hasCatchall = this._typedHandlers.ContainsKey(typeof(object)); } - [SuppressMessage("Trimming", - "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + - "of the source method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - internal static HandlerInfosT ReflectHandlers() where TExecutor : Executor - => ReflectHandlers(typeof(TExecutor)); - - internal static MessageRouter BindMessageHandlers(Executor executor, bool checkType) - { - if (s_routerFactoryCache.TryGetValue(executor.GetType(), out var factory)) - { - return factory(); - } - - // If no factory is found, reflect over the handlers - HandlerInfosT handlers = ReflectHandlers(executor.GetType()); + public HashSet IncomingTypes => [.. this._typedHandlers.Keys]; - Dictionary>> boundHandlers = new(); - foreach (Type inType in handlers.Keys) - { - MessageHandlerInfo handlerInfo = handlers[inType]; - Func> boundHandler = handlerInfo.Bind(executor, checkType); - boundHandlers.Add(inType, boundHandler); // TODO: Turn the error here into something more actionable. - } - - return new MessageRouter(boundHandlers, executor as IDefaultMessageHandler); - } + public bool CanHandle(object message) => this.CanHandle(Throw.IfNull(message).GetType()); - internal MessageRouter(Dictionary>> handlers, IDefaultMessageHandler? defaultHandler = null) + public bool CanHandle(Type candidateType) { - this.BoundHandlers = handlers; - this.DefaultHandler = defaultHandler; + // For now we only support routing to handlers registered on the exact type (no base type delegation). + return this._hasCatchall || this._typedHandlers.ContainsKey(candidateType); } - /// - /// . - /// - /// - /// - /// - /// - /// - /// public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) { Throw.IfNull(message); - // TODO: Implement base type delegation? CallResult? result = null; - if (this.BoundHandlers.TryGetValue(message.GetType(), out Func>? handler)) - { - result = await handler(message, context).ConfigureAwait(false); - } - else if (this.DefaultHandler != null) + + try { - try - { - await this.DefaultHandler.HandleAsync(message, context).ConfigureAwait(false); - result = CallResult.ReturnVoid(); - } - catch (Exception e) + if (this._typedHandlers.TryGetValue(message.GetType(), out MessageHandlerF? handler)) { - result = CallResult.RaisedException(wasVoid: true, e); + result = await handler(message, context).ConfigureAwait(false); } } + catch (Exception e) + { + result = CallResult.RaisedException(wasVoid: true, e); + } return result; } - - public bool CanHandle(object message) => this.CanHandle(Throw.IfNull(message).GetType()); - - public bool CanHandle(Type candidateType) - { - Throw.IfNull(candidateType); - - // Check if the router can handle the candidate type. - return this.DefaultHandler != null || this.BoundHandlers.ContainsKey(candidateType); - } - - public HashSet IncomingTypes - => this.DefaultHandler != null - ? [.. this.BoundHandlers.Keys, typeof(object)] - : [.. this.BoundHandlers.Keys]; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs new file mode 100644 index 0000000000..e480aeb97d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +using MessageHandlerF = + System.Func< + object, // message + Microsoft.Agents.Workflows.Core.IWorkflowContext, // context + System.Threading.Tasks.ValueTask + >; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +public class RouteBuilder +{ + private readonly Dictionary _typedHandlers = new(); + + internal RouteBuilder AddHandler(Type messageType, MessageHandlerF handler, bool overwrite = false) + { + Throw.IfNull(messageType); + Throw.IfNull(handler); + + // Overwrite must be false if the type is not registered. Overwrite must be true if the type is registered. + if (this._typedHandlers.ContainsKey(messageType) == overwrite) + { + this._typedHandlers[messageType] = handler; + } + else if (overwrite) + { + // overwrite is true, but the type is not registered. + throw new ArgumentException($"A handler for message type {messageType.FullName} has not yet been registered (overwrite = true)."); + } + else if (!overwrite) + { + throw new ArgumentException($"A handler for message type {messageType.FullName} is already registered (overwrite = false)."); + } + + return this; + } + + /// + /// . + /// + /// + /// + /// + /// + public RouteBuilder AddHandler(Func handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddHandler(typeof(TInput), WrappedHandlerAsync, overwrite); + + async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) + { + await handler.Invoke((TInput)msg, ctx).ConfigureAwait(false); + return CallResult.ReturnVoid(); + } + } + + /// + /// . + /// + /// + /// + /// + /// + /// + public RouteBuilder AddHandler(Func> handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddHandler(typeof(TInput), WrappedHandlerAsync, overwrite); + + async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) + { + TResult result = await handler.Invoke((TInput)msg, ctx).ConfigureAwait(false); + return CallResult.ReturnResult(result); + } + } + + internal MessageRouter Build() + { + return new MessageRouter(this._typedHandlers); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs new file mode 100644 index 0000000000..7d7aa7a7a3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Core; + +internal static class RouteBuilderExtensions +{ + [SuppressMessage("Trimming", + "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + + "of the source method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + [SuppressMessage("Trimming", + "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of " + + "method does not have matching annotations.", + Justification = "Trimming attributes are inaccessible in 472")] + private static IEnumerable GetHandlerInfos(this Type executorType) + { + // Handlers are defined by implementations of IMessageHandler or IMessageHandler + Debug.Assert(typeof(Executor).IsAssignableFrom(executorType), "executorType must be an Executor type."); + + foreach (Type interfaceType in executorType.GetInterfaces()) + { + // Check if the interface is a message handler. + if (!interfaceType.IsGenericType) + { + continue; + } + + if (interfaceType.IsGenericType && (interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || + interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>))) + { + // Get the generic arguments of the interface. + Type[] genericArguments = interfaceType.GetGenericArguments(); + if (genericArguments.Length < 1 || genericArguments.Length > 2) + { + continue; // Invalid handler signature. + } + Type inType = genericArguments[0]; + Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; + MethodInfo? method = interfaceType.GetMethod("HandleAsync"); + + if (method != null) + { + yield return new MessageHandlerInfo(method) { InType = inType, OutType = outType }; + } + } + } + } + + public static RouteBuilder ReflectHandlers(this RouteBuilder builder, Type executorType, Executor executor) + { + Throw.IfNull(builder); + Throw.IfNull(executorType); + + Debug.Assert(typeof(Executor).IsAssignableFrom(executorType), "executorType must be an Executor type."); + + foreach (MessageHandlerInfo handlerInfo in executorType.GetHandlerInfos()) + { + builder = builder.AddHandler(handlerInfo.InType, handlerInfo.Bind(executor, checkType: true)); + } + + if (executor is IDefaultMessageHandler defaultHandler) + { + builder = builder.AddHandler(defaultHandler.HandleAsync); + } + + return builder; + } + + public static RouteBuilder ReflectHandlers(this RouteBuilder builder, TExecutor executor) where TExecutor : Executor + => builder.ReflectHandlers(typeof(TExecutor), executor); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs index 29b1b2dc83..8908a6e3e6 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs @@ -16,7 +16,7 @@ private async ValueTask FindRouterAsync() Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) .ConfigureAwait(false); - return sink.MessageRouter; + return sink.Router; } public async ValueTask> ChaseAsync(object message) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs index 585f7e1833..3d8db74eca 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs @@ -25,7 +25,7 @@ internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) .ConfigureAwait(false); - MessageRouter router = sink.MessageRouter; + MessageRouter router = sink.Router; if (router.CanHandle(message)) { return await router.RouteMessageAsync(message, this.BoundContext) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs index 7ff3e9c171..59afcd1ced 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs @@ -30,7 +30,7 @@ internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeDa Executor executor = await this.RunContext.EnsureExecutorAsync(targetId) .ConfigureAwait(false); - MessageRouter router = executor.MessageRouter; + MessageRouter router = executor.Router; if (router.CanHandle(message)) { return await router.RouteMessageAsync(message, this.BoundContexts[targetId]) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs index 2ba64f1ee8..24c5622b80 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs @@ -15,7 +15,7 @@ private async ValueTask FindRouterAsync() Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData) .ConfigureAwait(false); - return sink.MessageRouter; + return sink.Router; } public async ValueTask ChaseAsync(object message) From 1187bb3e059a96965af110cc338bfbfe9f62aed1 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 10:43:31 -0400 Subject: [PATCH 118/232] test: Add Reflection/Invocation tests --- .../Core/RouteBuilderExtensions.cs | 2 +- .../ReflectionSmokeTest.cs | 136 ++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs index 7d7aa7a7a3..601e5de199 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs @@ -74,5 +74,5 @@ public static RouteBuilder ReflectHandlers(this RouteBuilder builder, Type execu } public static RouteBuilder ReflectHandlers(this RouteBuilder builder, TExecutor executor) where TExecutor : Executor - => builder.ReflectHandlers(typeof(TExecutor), executor); + => builder.ReflectHandlers(executor.GetType(), executor); } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs new file mode 100644 index 0000000000..2a3734f7b4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Moq; + +namespace Microsoft.Agents.Orchestration.UnitTest; + +public class BaseTestExecutor : Executor +{ + protected void OnInvokedHandler() + { + this.InvokedHandler = true; + } + + public bool InvokedHandler + { + get; + private set; + } = false; +} + +public class DefaultHandler : BaseTestExecutor, IDefaultMessageHandler +{ + public ValueTask HandleAsync(object message, IWorkflowContext context) + { + this.OnInvokedHandler(); + return this.Handler(message, context); + } + + public Func Handler + { + get; + set; + } = (message, context) => default; +} + +public class TypedHandler : BaseTestExecutor, IMessageHandler +{ + public ValueTask HandleAsync(TInput message, IWorkflowContext context) + { + this.OnInvokedHandler(); + return this.Handler(message, context); + } + + public Func Handler + { + get; + set; + } = (message, context) => default; +} + +public class TypedHandlerWithOutput : BaseTestExecutor, IMessageHandler +{ + public ValueTask HandleAsync(TInput message, IWorkflowContext context) + { + this.OnInvokedHandler(); + return this.Handler(message, context); + } + public Func> Handler + { + get; + set; + } = (message, context) => default; +} + +public class RoutingReflectionTests +{ + private async ValueTask RunTestReflectAndRouteMessageAsync(BaseTestExecutor executor, TInput? input = default) where TInput : new() + { + MessageRouter router = executor.Router; + + Assert.NotNull(router); + input ??= new(); + Assert.True(router.CanHandle(input.GetType())); + Assert.True(router.CanHandle(input)); + + CallResult? result = await router.RouteMessageAsync(input, Mock.Of()); + + Assert.True(executor.InvokedHandler); + + return result; + } + + [Fact] + public async Task Test_ReflectAndExecute_DefaultHandlerAsync() + { + DefaultHandler executor = new(); + + CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor); + + Assert.NotNull(result); + Assert.True(result.IsSuccess); + Assert.True(result.IsVoid); + + await ((IAsyncDisposable)executor).DisposeAsync(); + } + + [Fact] + public async Task Test_ReflectAndExecute_HandlerReturnsVoidAsync() + { + TypedHandler executor = new(); + + CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor, 3); + + Assert.NotNull(result); + Assert.True(result.IsSuccess); + Assert.True(result.IsVoid); + + await ((IAsyncDisposable)executor).DisposeAsync(); + } + + [Fact] + public async Task Test_ReflectAndExecute_HandlerReturnsValueAsync() + { + TypedHandlerWithOutput executor = new() + { + Handler = (message, context) => + { + return new ValueTask($"{message}"); + } + }; + + const string Expected = "3"; + CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor, int.Parse(Expected)); + + Assert.NotNull(result); + Assert.True(result.IsSuccess); + Assert.False(result.IsVoid); + + Assert.Equal(Expected, result.Result); + + await ((IAsyncDisposable)executor).DisposeAsync(); + } +} From a6eeb05164541a2c4e685ff3ce1ab2d2da37de7f Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 10:06:32 -0400 Subject: [PATCH 119/232] fix: Terminate on Completion event --- .../Execution/StreamingExecutionHandle.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs index ca915e24e7..048b6fa5ad 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs @@ -49,10 +49,23 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell { while (await this._stepRunner.RunSuperStepAsync(cancellation).ConfigureAwait(false)) { + bool hadCompletionEvent = false; List outputEvents = Interlocked.Exchange(ref eventSink, new()); foreach (WorkflowEvent raisedEvent in outputEvents) { yield return raisedEvent; + + // TODO: Do we actually want to interpret this as a termination request? + if (raisedEvent is WorkflowCompletedEvent) + { + hadCompletionEvent = true; + } + } + + if (hadCompletionEvent) + { + // If we had a completion event, we are done. + yield break; } } } From 638e8ac9ddcf089edbe9dc0e27dfb7c22b7f20e4 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 11:15:25 -0400 Subject: [PATCH 120/232] refactor: Update public API surface --- .../Core/CallResult.cs | 2 +- .../Microsoft.Agents.Workflow/Core/Edge.cs | 150 ++++++++++++++++++ .../Microsoft.Agents.Workflow/Core/Edges.cs | 89 ----------- .../Core/Executor.cs | 4 +- .../Core/IDefaultMessageHandler.cs | 22 --- .../Core/IWorkflowContext.cs | 13 +- .../Core/RouteBuilderExtensions.cs | 5 - .../Core/Workflow.cs | 68 ++++---- .../Execution/EdgeMap.cs | 22 +-- .../Execution/LocalRunner.cs | 69 ++++++-- .../Microsoft.Agents.Workflow/ExecutorIsh.cs | 52 +++++- .../StreamingAggregators.cs | 56 ++++++- .../WorkflowBuilder.cs | 72 +++++++-- .../WorkflowBuilderExtensions.cs | 32 +++- .../ReflectionSmokeTest.cs | 2 +- 15 files changed, 453 insertions(+), 205 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs index 934c03d43c..482a228e1a 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.Workflows.Core; /// This class represents the result of a call to a /// or . /// -public sealed class CallResult +internal sealed class CallResult { /// /// Indicates whether the call was void (i.e., no result expected). This only applies to diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs new file mode 100644 index 0000000000..de2dc0ed44 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +using PredicateT = System.Func; +using PartitionerT = System.Func>; +using System; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +/// +/// +/// +public record DirectEdgeData( + string SourceId, + string SinkId, + PredicateT? Condition = null) +{ + /// + /// . + /// + /// + public static implicit operator Edge(DirectEdgeData data) + { + return new Edge(data); + } +} + +/// +/// . +/// +/// +/// +/// +public record FanOutEdgeData( + string SourceId, + List SinkIds, + PartitionerT? Partitioner = null) +{ + /// + /// . + /// + /// + public static implicit operator Edge(FanOutEdgeData data) + { + return new Edge(data); + } +} + +/// +/// . +/// +public enum FanInTrigger +{ + /// + /// . + /// + WhenAll, + /// + /// . + /// + WhenAny +} + +/// +/// . +/// +/// +/// +/// +public record FanInEdgeData( + IEnumerable SourceIds, + string SinkId, + FanInTrigger Trigger = FanInTrigger.WhenAll) +{ + internal Guid UniqueKey { get; } = Guid.NewGuid(); + + /// + /// . + /// + /// + public static implicit operator Edge(FanInEdgeData data) + { + return new Edge(data); + } +} + +/// +/// . +/// +public class Edge +{ + /// + /// . + /// + public enum Type + { + /// + /// . + /// + Direct, + /// + /// . + /// + FanOut, + /// + /// . + /// + FanIn + } + + /// + /// . + /// + public Type EdgeType { get; init; } + + /// + /// . + /// + public object Data { get; init; } + + internal Edge(DirectEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.Direct; + } + + internal Edge(FanOutEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.FanOut; + } + + internal Edge(FanInEdgeData data) + { + this.Data = Throw.IfNull(data); + + this.EdgeType = Type.FanIn; + } + + internal DirectEdgeData? DirectEdgeData => this.Data as DirectEdgeData; + internal FanOutEdgeData? FanOutEdgeData => this.Data as FanOutEdgeData; + internal FanInEdgeData? FanInEdgeData => this.Data as FanInEdgeData; +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs deleted file mode 100644 index b17d8ebb54..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Edges.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using Microsoft.Shared.Diagnostics; - -using PredicateT = System.Func; -using PartitionerT = System.Func>; -using System; - -namespace Microsoft.Agents.Workflows.Core; - -internal record DirectEdgeData( - string SourceId, - string SinkId, - PredicateT? Condition = null) -{ - public static implicit operator FlowEdge(DirectEdgeData data) - { - return new FlowEdge(data); - } -} - -internal record FanOutEdgeData( - string SourceId, - List SinkIds, - PartitionerT? Partitioner = null) -{ - public static implicit operator FlowEdge(FanOutEdgeData data) - { - return new FlowEdge(data); - } -} - -internal enum FanInTrigger -{ - WhenAll, - WhenAny -} - -internal record FanInEdgeData( - IEnumerable SourceIds, - string SinkId, - FanInTrigger Trigger = FanInTrigger.WhenAll) -{ - internal Guid UniqueKey { get; } = Guid.NewGuid(); - - public static implicit operator FlowEdge(FanInEdgeData data) - { - return new FlowEdge(data); - } -} - -internal class FlowEdge -{ - public enum Type - { - Direct, - FanOut, - FanIn - } - - public Type EdgeType { get; init; } - public object Data { get; init; } - - public FlowEdge(DirectEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.Direct; - } - - public FlowEdge(FanOutEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.FanOut; - } - - public FlowEdge(FanInEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.FanIn; - } - - public DirectEdgeData? DirectEdgeData => this.Data as DirectEdgeData; - public FanOutEdgeData? FanOutEdgeData => this.Data as FanOutEdgeData; - public FanInEdgeData? FanInEdgeData => this.Data as FanInEdgeData; -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index ebaea05391..5e43a5c98b 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -127,8 +126,7 @@ protected void CheckInitialized() /// /// . /// - [SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "")] - public ISet OutputTypes => throw new NotImplementedException(); + public virtual ISet OutputTypes => new HashSet(); /// /// . diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs deleted file mode 100644 index bd8de4e48b..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/IDefaultMessageHandler.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// A default message handler interface for handling messages that do not have a specific handler registered. -/// -public interface IDefaultMessageHandler -{ - /// - /// Handles the incoming message asynchronously. - /// - /// - /// This is used as a fallback handler for messages that do not have a specific handler registered. - /// - /// The message to handle. - /// The execution context. - /// A task that represents the asynchronous operation. - ValueTask HandleAsync(object message, IWorkflowContext context); -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs index decf8ce8d4..bf5528db9c 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs @@ -10,17 +10,18 @@ namespace Microsoft.Agents.Workflows.Core; public interface IWorkflowContext { /// - /// . + /// Adds an event to the workflow's output queue. These events will be raised to the caller of the workflow at the + /// end of the current SuperStep. /// - /// - /// + /// The event to be raised. + /// A representing the asynchronous operation. ValueTask AddEventAsync(WorkflowEvent workflowEvent); /// - /// . + /// Queues a message to be sent to connected executors. The message will be sent during the next SuperStep. /// - /// - /// + /// The message to be sent. + /// A representing the asynchronous operation. ValueTask SendMessageAsync(object message); // TODO: State management diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs index 601e5de199..2beadc3ec5 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs @@ -65,11 +65,6 @@ public static RouteBuilder ReflectHandlers(this RouteBuilder builder, Type execu builder = builder.AddHandler(handlerInfo.InType, handlerInfo.Bind(executor, checkType: true)); } - if (executor is IDefaultMessageHandler defaultHandler) - { - builder = builder.AddHandler(defaultHandler.HandleAsync); - } - return builder; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs index e246635e89..c49afff737 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs @@ -6,65 +6,72 @@ namespace Microsoft.Agents.Workflows.Core; -internal class Workflow +/// +/// . +/// +public class Workflow { + /// + /// . + /// public Dictionary> ExecutorProviders { get; internal init; } = new(); - public Dictionary> Edges { get; internal init; } = new(); -#if NET9_0_OR_GREATER - required -#endif - public string StartExecutorId - { get; init; } + /// + /// . + /// + public Dictionary> Edges { get; internal init; } = new(); -#if NET9_0_OR_GREATER - required -#endif - public Type InputType - { get; init; } = typeof(object); + /// + /// . + /// + public string StartExecutorId { get; } - public Workflow(string startExecutorId, Type type) + /// + /// . + /// + public Type InputType { get; } + + internal Workflow(string startExecutorId, Type type) { this.StartExecutorId = Throw.IfNull(startExecutorId); this.InputType = Throw.IfNull(type); // TODO: How do we (1) ensure the types are happy, and (2) work under AOT? } - -#if NET9_0_OR_GREATER - public Workflow() - { } -#endif } -internal class Workflow : Workflow +/// +/// . +/// +/// +public class Workflow : Workflow { + /// + /// . + /// + /// public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) { } -#if NET9_0_OR_GREATER - public Workflow() - { - this.InputType = typeof(T); - } -#endif - internal Workflow Promote(OutputSink outputSource) { Throw.IfNull(outputSource); return new Workflow(this.StartExecutorId, outputSource) { - StartExecutorId = this.StartExecutorId, ExecutorProviders = this.ExecutorProviders, Edges = this.Edges, - InputType = this.InputType, }; } } -internal class Workflow : Workflow +/// +/// . +/// +/// +/// +public class Workflow : Workflow { private readonly OutputSink _output; @@ -74,5 +81,8 @@ internal Workflow(string startExecutorId, OutputSink outputSource) this._output = Throw.IfNull(outputSource); } + /// + /// . + /// public TResult? RunningOutput => this._output.Result; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs index 0b84cba03a..373b443ec2 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs @@ -10,19 +10,19 @@ namespace Microsoft.Agents.Workflows.Execution; internal class EdgeMap { - private readonly Dictionary _edgeRunners = new(); - private readonly Dictionary _fanInState = new(); + private readonly Dictionary _edgeRunners = new(); + private readonly Dictionary _fanInState = new(); private readonly InputEdgeRuner _inputRunner; - public EdgeMap(IRunnerContext runContext, Dictionary> workflowEdges, string startExecutorId) + public EdgeMap(IRunnerContext runContext, Dictionary> workflowEdges, string startExecutorId) { - foreach (FlowEdge edge in workflowEdges.Values.SelectMany(e => e)) + foreach (Edge edge in workflowEdges.Values.SelectMany(e => e)) { object edgeRunner = edge.EdgeType switch { - FlowEdge.Type.Direct => new DirectEdgeRunner(runContext, edge.DirectEdgeData!), - FlowEdge.Type.FanOut => new FanOutEdgeRunner(runContext, edge.FanOutEdgeData!), - FlowEdge.Type.FanIn => new FanInEdgeRunner(runContext, edge.FanInEdgeData!), + Edge.Type.Direct => new DirectEdgeRunner(runContext, edge.DirectEdgeData!), + Edge.Type.FanOut => new FanOutEdgeRunner(runContext, edge.FanOutEdgeData!), + Edge.Type.FanIn => new FanInEdgeRunner(runContext, edge.FanInEdgeData!), _ => throw new NotSupportedException($"Unsupported edge type: {edge.EdgeType}") }; @@ -32,7 +32,7 @@ public EdgeMap(IRunnerContext runContext, Dictionary> this._inputRunner = new InputEdgeRuner(runContext, startExecutorId); } - public async ValueTask> InvokeEdgeAsync(FlowEdge edge, string sourceId, object message) + public async ValueTask> InvokeEdgeAsync(Edge edge, string sourceId, object message) { if (!this._edgeRunners.TryGetValue(edge, out object? edgeRunner)) { @@ -48,21 +48,21 @@ public EdgeMap(IRunnerContext runContext, Dictionary> // in FanIn/Out cases) // TODO: Once we have a fixed interface, if it is reasonably generalizable // between the Runners, we can normalize it behind an IFace. - case FlowEdge.Type.Direct: + case Edge.Type.Direct: { DirectEdgeRunner runner = (DirectEdgeRunner)this._edgeRunners[edge]; edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); break; } - case FlowEdge.Type.FanOut: + case Edge.Type.FanOut: { FanOutEdgeRunner runner = (FanOutEdgeRunner)this._edgeRunners[edge]; edgeResults = await runner.ChaseAsync(message).ConfigureAwait(false); break; } - case FlowEdge.Type.FanIn: + case Edge.Type.FanIn: { FanInEdgeState state = this._fanInState[edge]; FanInEdgeRunner runner = (FanInEdgeRunner)this._edgeRunners[edge]; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index a155dfe2c2..e5bc824348 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -10,8 +10,16 @@ namespace Microsoft.Agents.Workflows.Execution; -internal class LocalRunner : ISuperStepRunner where TInput : notnull +/// +/// . +/// +/// +public class LocalRunner : ISuperStepRunner where TInput : notnull { + /// + /// . + /// + /// public LocalRunner(Workflow workflow) { this.Workflow = Throw.IfNull(workflow); @@ -27,13 +35,19 @@ ValueTask ISuperStepRunner.EnqueueMessageAsync(object message) return this.RunContext.AddExternalMessageAsync(message); } - protected Dictionary PendingCalls { get; } = new(); - protected Workflow Workflow { get; init; } - protected LocalRunnerContext RunContext { get; init; } - protected EdgeMap EdgeMap { get; init; } + private Dictionary PendingCalls { get; } = new(); + private Workflow Workflow { get; init; } + private LocalRunnerContext RunContext { get; init; } + private EdgeMap EdgeMap { get; init; } // TODO: Better signature? - public event EventHandler? WorkflowEvent; + event EventHandler? ISuperStepRunner.WorkflowEvent + { + add => this.WorkflowEvent += value; + remove => this.WorkflowEvent -= value; + } + + private event EventHandler? WorkflowEvent; private void RaiseWorkflowEvent(WorkflowEvent workflowEvent) { @@ -56,6 +70,12 @@ private bool IsResponse(object message) : this.EdgeMap.InvokeInputAsync(message); } + /// + /// . + /// + /// + /// + /// public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) { await this.RunContext.AddExternalMessageAsync(input).ConfigureAwait(false); @@ -64,7 +84,7 @@ public async ValueTask StreamAsync(TInput input, Cance } private StepContext? _currentStep = null; - public async ValueTask RunSuperStepAsync(CancellationToken cancellation) + async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cancellation) { cancellation.ThrowIfCancellationRequested(); @@ -99,8 +119,8 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) } else { - HashSet outgoingEdges = this.Workflow.Edges[sender.Id!]; // Id is not null when Identity is not .None - foreach (FlowEdge outgoingEdge in outgoingEdges) + HashSet outgoingEdges = this.Workflow.Edges[sender.Id!]; // Id is not null when Identity is not .None + foreach (Edge outgoingEdge in outgoingEdges) { edgeTasks.AddRange(senderMessages.Select(message => this.EdgeMap.InvokeEdgeAsync(outgoingEdge, sender.Id, message).AsTask())); } @@ -122,31 +142,54 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) } } -internal class LocalRunner : IRunnerWithResult where TInput : notnull +/// +/// . +/// +/// +/// +public class LocalRunner : IRunnerWithResult where TInput : notnull { private readonly Workflow _workflow; - private readonly LocalRunner _innerRunner; + private readonly ISuperStepRunner _innerRunner; + /// + /// . + /// + /// public LocalRunner(Workflow workflow) { this._workflow = Throw.IfNull(workflow); this._innerRunner = new LocalRunner(workflow); } + /// + /// . + /// + /// + /// + /// public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) { - await this.StepRunner.EnqueueMessageAsync(input).ConfigureAwait(false); + await this._innerRunner.EnqueueMessageAsync(input).ConfigureAwait(false); return new StreamingExecutionHandle(this._innerRunner); } + /// + /// . + /// + /// + /// public ValueTask GetResultAsync(CancellationToken cancellation = default) { // TODO: Block on finishing consuming StreamAsync()? return new ValueTask(this.RunningOutput!); } + /// + /// . + /// public TResult? RunningOutput => this._workflow.RunningOutput; - public ISuperStepRunner StepRunner => this._innerRunner; + ISuperStepRunner IRunnerWithResult.StepRunner => this._innerRunner; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs index a1fa16dd58..6200dc5d2e 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -6,41 +6,65 @@ namespace Microsoft.Agents.Workflows; -internal sealed class ExecutorIsh : +/// +/// . +/// +public sealed class ExecutorIsh : IIdentified, IEquatable, IEquatable, IEquatable { + /// + /// . + /// public enum Type { + /// + /// . + /// Unbound, + /// + /// . + /// Executor, //Function, //Agent, //ProcessStep } + /// + /// . + /// public Type ExecutorType { get; init; } private readonly string? _idValue; private readonly Executor? _executorValue; //private readonly Func? _functionValue; + /// + /// . + /// + /// public ExecutorIsh(Executor executor) { this.ExecutorType = Type.Executor; this._executorValue = Throw.IfNull(executor); } + /// + /// . + /// + /// public ExecutorIsh(string id) { this.ExecutorType = Type.Unbound; this._idValue = Throw.IfNull(id); } - public bool IsUnbound => this.ExecutorType == Type.Unbound; + internal bool IsUnbound => this.ExecutorType == Type.Unbound; + /// public string Id => this.ExecutorType switch { Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), @@ -48,9 +72,12 @@ public ExecutorIsh(string id) //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), - _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") }; + /// + /// . + /// public ExecutorProvider ExecutorProvider => this.ExecutorType switch { Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), @@ -58,7 +85,7 @@ public ExecutorIsh(string id) //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), - _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") }; //public ExecutorIsh(Func function) @@ -67,7 +94,10 @@ public ExecutorIsh(string id) // this._functionValue = Throw.IfNull(function); //} - // Implicit conversions into ExecutorIsh + /// + /// . + /// + /// public static implicit operator ExecutorIsh(Executor executor) { return new ExecutorIsh(executor); @@ -79,29 +109,37 @@ public static implicit operator ExecutorIsh(Executor executor) // return new ExecutorIsh(function); //} + /// + /// . + /// + /// public static implicit operator ExecutorIsh(string id) { return new ExecutorIsh(id); } + /// public bool Equals(ExecutorIsh? other) { return other is not null && other.Id == this.Id; } + /// public bool Equals(IIdentified? other) { return other is not null && other.Id == this.Id; } + /// public bool Equals(string? other) { return other is not null && other == this.Id; } + /// public override bool Equals(object? obj) { if (obj is null) @@ -125,11 +163,13 @@ public override bool Equals(object? obj) return false; } + /// public override int GetHashCode() { return this.Id.GetHashCode(); } + /// public override string ToString() { return this.ExecutorType switch @@ -139,7 +179,7 @@ public override string ToString() //Type.Function => $"ExecutorIsh for Function with ID '{this.Id}'", //Type.Agent => $"ExecutorIsh for Agent with ID '{this.Id}'", //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", - _ => throw new ArgumentOutOfRangeException(nameof(this.ExecutorType), "Unknown ExecutorIsh type.") + _ => $"'{this.Id}':(TInput input); - -internal static class StreamingAggregators +/// +/// . +/// +/// +/// +/// +/// +public delegate TResult? StreamingAggregator(TInput input); + +/// +/// . +/// +public static class StreamingAggregators { + /// + /// . + /// + /// + /// + /// + /// + /// public static StreamingAggregator First(Func conversion, TResult? defaultValue = default) { bool hasRun = false; @@ -27,9 +45,23 @@ public static StreamingAggregator First(Func + /// . + /// + /// + /// + /// public static StreamingAggregator First(TInput? defaultValue = default) => First(input => input, defaultValue); + /// + /// . + /// + /// + /// + /// + /// + /// public static StreamingAggregator Last(Func conversion, TResult? defaultValue = default) { TResult? local = defaultValue; @@ -43,9 +75,22 @@ public static StreamingAggregator Last(Func + /// . + /// + /// + /// + /// public static StreamingAggregator Last(TInput? defaultValue = default) => Last(input => input, defaultValue); + /// + /// . + /// + /// + /// + /// + /// public static StreamingAggregator> Union(Func conversion) { List results = new(); @@ -59,6 +104,11 @@ IEnumerable Aggregate(TInput input) } } + /// + /// . + /// + /// + /// public static StreamingAggregator> Union() => Union(input => input); } diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index 28416db074..d92bef1c8a 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -15,23 +15,35 @@ namespace Microsoft.Agents.Workflows; -internal delegate TExecutor ExecutorProvider() +/// +/// A factory method that produces an executor instance. +/// +/// The executor type. +/// A new instance. +public delegate TExecutor ExecutorProvider() where TExecutor : Executor; -internal record struct EdgeId(string SourceId, string TargetId) +/// +/// . +/// +public class WorkflowBuilder { - public override string ToString() => $"{this.SourceId} -> {this.TargetId}"; -} + private record struct EdgeId(string SourceId, string TargetId) + { + public override string ToString() => $"{this.SourceId} -> {this.TargetId}"; + } -internal class WorkflowBuilder -{ private readonly Dictionary> _executors = new(); - private readonly Dictionary> _edges = new(); + private readonly Dictionary> _edges = new(); private readonly HashSet _unboundExecutors = new(); private readonly HashSet _conditionlessEdges = new(); private readonly string _startExecutorId; + /// + /// . + /// + /// public WorkflowBuilder(ExecutorIsh start) { this._startExecutorId = this.Track(start).Id; @@ -63,6 +75,12 @@ private void UpdateExecutor(string id, ExecutorProvider provider) this._executors[id] = provider; } + /// + /// . + /// + /// + /// + /// public WorkflowBuilder BindExecutor(Executor executor) { if (!this._unboundExecutors.Contains(executor.Id)) @@ -76,18 +94,26 @@ public WorkflowBuilder BindExecutor(Executor executor) return this; } - private HashSet EnsureEdgesFor(string sourceId) + private HashSet EnsureEdgesFor(string sourceId) { // Ensure that there is a set of edges for the given source ID. // If it does not exist, create a new one. - if (!this._edges.TryGetValue(sourceId, out HashSet? edges)) + if (!this._edges.TryGetValue(sourceId, out HashSet? edges)) { - this._edges[sourceId] = edges = new HashSet(); + this._edges[sourceId] = edges = new HashSet(); } return edges; } + /// + /// . + /// + /// + /// + /// + /// + /// public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func? condition = null) { // Add an edge from source to target with an optional condition. @@ -110,8 +136,13 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func + /// . + /// + /// + /// + /// + /// public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func>? partitioner = null, params ExecutorIsh[] targets) { Throw.IfNull(source); @@ -126,6 +157,13 @@ public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func + /// . + /// + /// + /// + /// + /// public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = default, params ExecutorIsh[] sources) { Throw.IfNull(target); @@ -144,6 +182,12 @@ public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = d return this; } + /// + /// . + /// + /// + /// + /// public Workflow Build() { if (this._unboundExecutors.Count > 0) @@ -173,9 +217,7 @@ public Workflow Build() return new Workflow(this._startExecutorId) // Why does it not see the default ctor? { ExecutorProviders = this._executors, - Edges = this._edges, - StartExecutorId = this._startExecutorId, - InputType = typeof(T) + Edges = this._edges }; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index 7b2836952b..aa8fe8c8ce 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -7,8 +7,19 @@ namespace Microsoft.Agents.Workflows; -internal static class WorkflowBuilderExtensions +/// +/// . +/// +public static class WorkflowBuilderExtensions { + /// + /// . + /// + /// + /// + /// + /// + /// public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors) { Throw.IfNull(builder); @@ -34,9 +45,28 @@ public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh return builder; } + /// + /// . + /// + /// + /// + /// + /// + /// + /// public static Workflow BuildWithOutput(this WorkflowBuilder builder, ExecutorIsh outputSource, StreamingAggregator aggregator) => builder.BuildWithOutput(outputSource, aggregator); + /// + /// . + /// + /// + /// + /// + /// + /// + /// + /// public static Workflow BuildWithOutput(this WorkflowBuilder builder, ExecutorIsh outputSource, StreamingAggregator aggregator) { Throw.IfNull(outputSource); diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs index 2a3734f7b4..ae6cb303a1 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs @@ -21,7 +21,7 @@ public bool InvokedHandler } = false; } -public class DefaultHandler : BaseTestExecutor, IDefaultMessageHandler +public class DefaultHandler : BaseTestExecutor, IMessageHandler { public ValueTask HandleAsync(object message, IWorkflowContext context) { From a6a5785a7495ae000ab104b89830eb336241bb57 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 18:23:48 -0400 Subject: [PATCH 121/232] feat: Add support for external requests --- .../Microsoft.Agents.Workflow/Core/Events.cs | 5 ++ .../Core/ExternalRequest.cs | 73 +++++++++++++++++++ .../Core/ExternalResponse.cs | 16 ++++ .../Core/InputPort.cs | 14 ++++ .../Core/Workflow.cs | 6 ++ .../Execution/EdgeMap.cs | 24 ++++-- .../Execution/IExternalRequestSink.cs | 11 +++ .../Execution/IRunnerContext.cs | 4 +- .../{InputEdgeRuner.cs => InputEdgeRunner.cs} | 13 +++- .../Execution/LocalRunner.cs | 18 ++++- .../Execution/LocalRunnerContext.cs | 20 ++++- .../Execution/StreamingExecutionHandle.cs | 10 +-- .../Microsoft.Agents.Workflow/ExecutorIsh.cs | 42 ++++++++--- .../Microsoft.Agents.Workflows.csproj | 8 +- .../OutputCollectorExecutor.cs | 2 +- .../Specialized/RequestInputExecutor.cs | 46 ++++++++++++ .../WorkflowBuilderExtensions.cs | 21 ++++++ 17 files changed, 295 insertions(+), 38 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/IExternalRequestSink.cs rename dotnet/src/Microsoft.Agents.Workflow/Execution/{InputEdgeRuner.cs => InputEdgeRunner.cs} (67%) rename dotnet/src/Microsoft.Agents.Workflow/{ => Specialized}/OutputCollectorExecutor.cs (94%) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs index 6ce2a05702..0a06cf1bb0 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs @@ -19,6 +19,11 @@ public record WorkflowStartedEvent : WorkflowEvent; /// public record WorkflowCompletedEvent : WorkflowEvent; +/// +/// . +/// +public record RequestInputEvent(ExternalRequest request) : WorkflowEvent; + /// /// . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs new file mode 100644 index 0000000000..c7ab3b2d5e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +/// +/// +/// +public record ExternalRequest(InputPort Port, string RequestId, object Data) +{ + /// + /// . + /// + /// + /// + /// + /// + /// + public static ExternalRequest Create(InputPort port, [NotNull] object data, string? requestId = null) + { + if (!port.Request.IsAssignableFrom(Throw.IfNull(data).GetType())) + { + throw new InvalidOperationException( + $"Message type {data.GetType().Name} is not assignable to the request type {port.Request.Name} of input port {port.Id}."); + } + + requestId ??= Guid.NewGuid().ToString("N"); + + return new ExternalRequest(port, requestId, data); + } + + /// + /// . + /// + /// + /// + /// + /// + /// + public static ExternalRequest Create(InputPort port, T data, string? requestId = null) => Create(port, (object)Throw.IfNull(data), requestId); + + /// + /// . + /// + /// + /// + /// + public ExternalResponse CreateResponse(object data) + { + if (!Throw.IfNull(this.Port).Response.IsAssignableFrom(Throw.IfNull(data).GetType())) + { + throw new InvalidOperationException( + $"Message type {data.GetType().Name} is not assignable to the response type {this.Port.Response.Name} of input port {this.Port.Id}."); + } + + return new ExternalResponse(this.Port, this.RequestId, data); + } + + /// + /// . + /// + /// + /// + /// + /// + public ExternalResponse CreateResponse(InputPort port, T data) => this.CreateResponse(port, (object)Throw.IfNull(data)); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs new file mode 100644 index 0000000000..2c6bd22782 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + + +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +/// +/// +/// +public record ExternalResponse(InputPort Port, string RequestId, object Data) +{ +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs new file mode 100644 index 0000000000..cfa4e27f8d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +/// +/// +/// +public record InputPort(string Id, Type Request, Type Response) +{ }; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs index c49afff737..b12ff25113 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using Microsoft.Agents.Workflows.Specialized; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; @@ -21,6 +22,11 @@ public class Workflow /// public Dictionary> Edges { get; internal init; } = new(); + /// + /// . + /// + public Dictionary Ports { get; } = new(); + /// /// . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs index 373b443ec2..f9ba08af00 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs @@ -12,9 +12,13 @@ internal class EdgeMap { private readonly Dictionary _edgeRunners = new(); private readonly Dictionary _fanInState = new(); - private readonly InputEdgeRuner _inputRunner; + private readonly Dictionary _portEdgeRunners; + private readonly InputEdgeRunner _inputRunner; - public EdgeMap(IRunnerContext runContext, Dictionary> workflowEdges, string startExecutorId) + public EdgeMap(IRunnerContext runContext, + Dictionary> workflowEdges, + IEnumerable workflowPorts, + string startExecutorId) { foreach (Edge edge in workflowEdges.Values.SelectMany(e => e)) { @@ -29,7 +33,12 @@ public EdgeMap(IRunnerContext runContext, Dictionary> work this._edgeRunners[edge] = edgeRunner; } - this._inputRunner = new InputEdgeRuner(runContext, startExecutorId); + this._portEdgeRunners = workflowPorts.ToDictionary( + port => port.Id, + port => InputEdgeRunner.ForPort(runContext, port) + ); + + this._inputRunner = new InputEdgeRunner(runContext, startExecutorId); } public async ValueTask> InvokeEdgeAsync(Edge edge, string sourceId, object message) @@ -84,8 +93,13 @@ public EdgeMap(IRunnerContext runContext, Dictionary> work return [await this._inputRunner.ChaseAsync(inputMessage).ConfigureAwait(false)]; } - public ValueTask> InvokeResponseAsync(object externalResponse) + public async ValueTask> InvokeResponseAsync(ExternalResponse response) { - throw new NotImplementedException(); + if (!this._portEdgeRunners.TryGetValue(response.Port.Id, out InputEdgeRunner? portRunner)) + { + throw new InvalidOperationException($"Port {response.Port.Id} not found in the edge map."); + } + + return [await portRunner.ChaseAsync(response.Data).ConfigureAwait(false)]; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IExternalRequestSink.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/IExternalRequestSink.cs new file mode 100644 index 0000000000..76301b2785 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/IExternalRequestSink.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +internal interface IExternalRequestSink +{ + ValueTask PostAsync(ExternalRequest request); +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs index 78770036a9..692abdf3af 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs @@ -5,9 +5,9 @@ namespace Microsoft.Agents.Workflows.Execution; -internal interface IRunnerContext +internal interface IRunnerContext : IExternalRequestSink { - ValueTask AddEventAsync(string executorId, WorkflowEvent workflowEvent); + ValueTask AddEventAsync(WorkflowEvent workflowEvent); ValueTask SendMessageAsync(string executorId, object message); // TODO: State Management diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs similarity index 67% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs rename to dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs index 24c5622b80..b0c45ba0f4 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRuner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs @@ -2,14 +2,23 @@ using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Execution; -internal class InputEdgeRuner(IRunnerContext runContext, string sinkId) +internal class InputEdgeRunner(IRunnerContext runContext, string sinkId) : EdgeRunner(runContext, sinkId) { public IWorkflowContext WorkflowContext { get; } = runContext.Bind(sinkId); + public static InputEdgeRunner ForPort(IRunnerContext runContext, InputPort port) + { + Throw.IfNull(port); + + // The port is an input port, so we can use the port's ID as the sink ID. + return new InputEdgeRunner(runContext, port.Id); + } + private async ValueTask FindRouterAsync() { Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData) @@ -24,7 +33,7 @@ private async ValueTask FindRouterAsync() if (router.CanHandle(message)) { return await router.RouteMessageAsync(message, this.WorkflowContext) - .ConfigureAwait(false); + .ConfigureAwait(false); } // TODO: Throw instead? diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index e5bc824348..ed37852ccb 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -27,7 +27,7 @@ public LocalRunner(Workflow workflow) // 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.StartExecutorId); + this.EdgeMap = new EdgeMap(this.RunContext, this.Workflow.Edges, this.Workflow.Ports.Values, this.Workflow.StartExecutorId); } ValueTask ISuperStepRunner.EnqueueMessageAsync(object message) @@ -56,7 +56,7 @@ private void RaiseWorkflowEvent(WorkflowEvent workflowEvent) private bool IsResponse(object message) { - return false; + return message is ExternalResponse; } private ValueTask> RouteExternalMessageAsync(object message) @@ -65,11 +65,21 @@ private bool IsResponse(object message) bool isHil = false; #pragma warning restore CS0219 // Variable is assigned but its value is never used - return this.IsResponse(message) - ? this.EdgeMap.InvokeResponseAsync(message) + return message is ExternalResponse response + ? this.CompleteExternalResponseAsync(response) : this.EdgeMap.InvokeInputAsync(message); } + private ValueTask> CompleteExternalResponseAsync(ExternalResponse response) + { + if (!this.RunContext.CompleteRequest(response.RequestId)) + { + throw new InvalidOperationException($"No pending request with ID {response.RequestId} found in the workflow context."); + } + + return this.EdgeMap.InvokeResponseAsync(response); + } + /// /// . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index 2a4edcbcf4..fb14752d2d 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Specialized; using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; @@ -16,6 +17,7 @@ internal class LocalRunnerContext : IRunnerContext private StepContext _nextStep = new(); private readonly Dictionary> _executorProviders; private readonly Dictionary _executors = new(); + private readonly Dictionary _externalRequests = new(); public LocalRunnerContext(Workflow workflow, ILogger? logger = null) { @@ -34,6 +36,11 @@ public async ValueTask EnsureExecutorAsync(string executorId) this._executors[executorId] = executor = provider(); await executor.InitializeAsync(this.Bind(executor.Id)).ConfigureAwait(false); + + if (executor is RequestInputExecutor requestInputExecutor) + { + requestInputExecutor.AttachRequestSink(this); + } } return executor; @@ -48,13 +55,14 @@ public ValueTask AddExternalMessageAsync([NotNull] object message) } public bool NextStepHasActions => this._nextStep.HasMessages; + public bool HasUnservicedRequests => this._externalRequests.Count > 0; public StepContext Advance() { return Interlocked.Exchange(ref this._nextStep, new StepContext()); } - public ValueTask AddEventAsync(string executorId, WorkflowEvent workflowEvent) + public ValueTask AddEventAsync(WorkflowEvent workflowEvent) { this.QueuedEvents.Add(workflowEvent); return default; @@ -71,11 +79,19 @@ public IWorkflowContext Bind(string executorId) return new BoundContext(this, executorId); } + public ValueTask PostAsync(ExternalRequest request) + { + this._externalRequests.Add(request.RequestId, request); + return this.AddEventAsync(new RequestInputEvent(request)); + } + + public bool CompleteRequest(string requestId) => this._externalRequests.Remove(requestId); + public readonly List QueuedEvents = new(); private class BoundContext(LocalRunnerContext RunnerContext, string ExecutorId) : IWorkflowContext { - public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => RunnerContext.AddEventAsync(ExecutorId, workflowEvent); + public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => RunnerContext.AddEventAsync(workflowEvent); public ValueTask SendMessageAsync(object message) => RunnerContext.SendMessageAsync(ExecutorId, message); } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs index 048b6fa5ad..eba2f960a1 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs @@ -28,7 +28,7 @@ internal StreamingExecutionHandle(ISuperStepRunner stepRunner) /// /// /// - public ValueTask SendResponseAsync(object response) + public ValueTask SendResponseAsync(ExternalResponse response) { return this._stepRunner.EnqueueMessageAsync(response); } @@ -119,20 +119,20 @@ public static class ExecutionHandleExtensions /// name="handle"/> and invokes the for each event. If the callback returns a /// non- response, the response is sent back to the workflow using the handle. /// The representing the workflow execution stream to monitor. - /// An optional callback function invoked for each received from the stream. The - /// callback can return a response object to be sent back to the workflow, or if no response + /// An optional callback function invoked for each received from the stream. + /// The /// callback can return a response object to be sent back to the workflow, or if no response /// is required. /// A to observe while waiting for events. Defaults to . /// A that represents the asynchronous operation. The task completes when the workflow /// execution stream is fully processed. - public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) + public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) { Throw.IfNull(handle); await foreach (WorkflowEvent @event in handle.WatchStreamAsync(cancellation).ConfigureAwait(false)) { - object? maybeResponse = eventCallback?.Invoke(@event); + ExternalResponse? maybeResponse = eventCallback?.Invoke(@event); if (maybeResponse != null) { await handle.SendResponseAsync(maybeResponse).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs index 6200dc5d2e..30367ac709 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -2,6 +2,7 @@ using System; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Specialized; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; @@ -28,6 +29,10 @@ public enum Type /// . /// Executor, + /// + /// . + /// + InputPort, //Function, //Agent, //ProcessStep @@ -40,8 +45,19 @@ public enum Type private readonly string? _idValue; private readonly Executor? _executorValue; + private readonly InputPort? _inputPortValue; //private readonly Func? _functionValue; + /// + /// . + /// + /// + public ExecutorIsh(string id) + { + this.ExecutorType = Type.Unbound; + this._idValue = Throw.IfNull(id); + } + /// /// . /// @@ -55,11 +71,11 @@ public ExecutorIsh(Executor executor) /// /// . /// - /// - public ExecutorIsh(string id) + /// + public ExecutorIsh(InputPort port) { - this.ExecutorType = Type.Unbound; - this._idValue = Throw.IfNull(id); + this.ExecutorType = Type.InputPort; + this._inputPortValue = Throw.IfNull(port); } internal bool IsUnbound => this.ExecutorType == Type.Unbound; @@ -69,6 +85,7 @@ public ExecutorIsh(string id) { Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), Type.Executor => this._executorValue!.Id, + Type.InputPort => this._inputPortValue!.Id, //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), @@ -82,6 +99,7 @@ public ExecutorIsh(string id) { Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), Type.Executor => () => this._executorValue!, + Type.InputPort => () => new RequestInputExecutor(this._inputPortValue!), //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), @@ -98,10 +116,13 @@ public ExecutorIsh(string id) /// . /// /// - public static implicit operator ExecutorIsh(Executor executor) - { - return new ExecutorIsh(executor); - } + public static implicit operator ExecutorIsh(Executor executor) => new(executor); + + /// + /// . + /// + /// + public static implicit operator ExecutorIsh(InputPort inputPort) => new(inputPort); // How do we AoT compile this? //public static implicit operator ExecutorIsh(Func function) @@ -175,11 +196,12 @@ public override string ToString() return this.ExecutorType switch { Type.Unbound => $"'{this.Id}':", - Type.Executor => $"'{this.Id}':{this._executorValue!.GetType()}", + Type.Executor => $"'{this.Id}':{this._executorValue!.GetType().Name}", + Type.InputPort => $"'{this.Id}':Input({this._inputPortValue!.Request.Name}->{this._inputPortValue!.Response.Name})", //Type.Function => $"ExecutorIsh for Function with ID '{this.Id}'", //Type.Agent => $"ExecutorIsh for Agent with ID '{this.Id}'", //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", - _ => $"'{this.Id}': $"'{this.Id}':" }; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj index 478396f484..65f8a0dfab 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj @@ -20,14 +20,8 @@ - - - - - - + - diff --git a/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs similarity index 94% rename from dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs index 3380293cfa..f3658fb479 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/OutputCollectorExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs @@ -4,7 +4,7 @@ using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.Workflows; +namespace Microsoft.Agents.Workflows.Specialized; internal class OutputSink : Executor { diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs new file mode 100644 index 0000000000..be460d2955 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Specialized; + +internal class RequestInputExecutor : Executor, IMessageHandler, IMessageHandler +{ + private InputPort Port { get; } + private IExternalRequestSink? RequestSink { get; set; } + + public RequestInputExecutor(InputPort port) : base(port.Id) + { + this.Port = port; + } + + internal void AttachRequestSink(IExternalRequestSink requestSink) + { + this.RequestSink = Throw.IfNull(requestSink); + } + + public ValueTask HandleAsync(object message, IWorkflowContext context) + { + Throw.IfNull(message); + + return this.RequestSink!.PostAsync(ExternalRequest.Create(this.Port, message)); + } + + public ValueTask HandleAsync(ExternalResponse message, IWorkflowContext context) + { + Throw.IfNull(message); + Throw.IfNull(message.Data); + + if (!this.Port.Response.IsAssignableFrom(message.Data.GetType())) + { + throw new InvalidOperationException( + $"Message type {message.Data.GetType().Name} is not assignable to the response type {this.Port.Response.Name} of input port {this.Port.Id}."); + } + + return context.SendMessageAsync(message.Data); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index aa8fe8c8ce..7643d05078 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Specialized; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; @@ -45,6 +46,26 @@ public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh return builder; } + /// + /// . + /// + /// + /// + /// + /// + /// + /// + public static WorkflowBuilder AddExternalCall(this WorkflowBuilder builder, ExecutorIsh source, string portId) + { + Throw.IfNull(builder); + Throw.IfNull(source); + Throw.IfNull(portId); + + InputPort port = new(portId, typeof(TRequest), typeof(TResponse)); + return builder.AddEdge(source, port) + .AddEdge(port, source); + } + /// /// . /// From e171c152716b56b941ca0dda852b9f76834a4063 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 18:59:24 -0400 Subject: [PATCH 122/232] feat: Support hosting AIAgent instances in Workflows --- .../Microsoft.Agents.Workflow/ExecutorIsh.cs | 44 ++++++++++--------- .../Specialized/AIAgentHostExecutor.cs | 31 +++++++++++++ 2 files changed, 55 insertions(+), 20 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Specialized/AIAgentHostExecutor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs index 30367ac709..28384a2cf8 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -3,6 +3,7 @@ using System; using Microsoft.Agents.Workflows.Core; using Microsoft.Agents.Workflows.Specialized; +using Microsoft.Extensions.AI.Agents; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; @@ -33,8 +34,10 @@ public enum Type /// . /// InputPort, - //Function, - //Agent, + /// + /// . + /// + Agent, //ProcessStep } @@ -46,7 +49,7 @@ public enum Type private readonly string? _idValue; private readonly Executor? _executorValue; private readonly InputPort? _inputPortValue; - //private readonly Func? _functionValue; + private readonly AIAgent? _aiAgentValue; /// /// . @@ -78,6 +81,16 @@ public ExecutorIsh(InputPort port) this._inputPortValue = Throw.IfNull(port); } + /// + /// . + /// + /// + public ExecutorIsh(AIAgent aiAgent) + { + this.ExecutorType = Type.Agent; + this._aiAgentValue = Throw.IfNull(aiAgent); + } + internal bool IsUnbound => this.ExecutorType == Type.Unbound; /// @@ -86,8 +99,7 @@ public ExecutorIsh(InputPort port) Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."), Type.Executor => this._executorValue!.Id, Type.InputPort => this._inputPortValue!.Id, - //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), - //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), + Type.Agent => this._aiAgentValue!.Id, //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") }; @@ -100,18 +112,11 @@ public ExecutorIsh(InputPort port) Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), Type.Executor => () => this._executorValue!, Type.InputPort => () => new RequestInputExecutor(this._inputPortValue!), - //Type.Function => throw new NotImplementedException("Function type is not yet implemented."), - //Type.Agent => throw new NotImplementedException("Agent type is not yet implemented."), + Type.Agent => () => new AIAgentHostExecutor(this._aiAgentValue!), //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") }; - //public ExecutorIsh(Func function) - //{ - // this.ExecutorType = Type.Function; - // this._functionValue = Throw.IfNull(function); - //} - /// /// . /// @@ -124,11 +129,11 @@ public ExecutorIsh(InputPort port) /// public static implicit operator ExecutorIsh(InputPort inputPort) => new(inputPort); - // How do we AoT compile this? - //public static implicit operator ExecutorIsh(Func function) - //{ - // return new ExecutorIsh(function); - //} + /// + /// . + /// + /// + public static implicit operator ExecutorIsh(AIAgent aiAgent) => new(aiAgent); /// /// . @@ -198,8 +203,7 @@ public override string ToString() Type.Unbound => $"'{this.Id}':", Type.Executor => $"'{this.Id}':{this._executorValue!.GetType().Name}", Type.InputPort => $"'{this.Id}':Input({this._inputPortValue!.Request.Name}->{this._inputPortValue!.Response.Name})", - //Type.Function => $"ExecutorIsh for Function with ID '{this.Id}'", - //Type.Agent => $"ExecutorIsh for Agent with ID '{this.Id}'", + Type.Agent => $"{this.Id}':AIAgent(@{this._aiAgentValue!.GetType().Name})", //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", _ => $"'{this.Id}':" }; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/Specialized/AIAgentHostExecutor.cs new file mode 100644 index 0000000000..2f156d9191 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Specialized/AIAgentHostExecutor.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; + +namespace Microsoft.Agents.Workflows.Specialized; + +internal class AIAgentHostExecutor : Executor, IMessageHandler> +{ + private AIAgent Agent { get; set; } + + public AIAgentHostExecutor(AIAgent agent) + { + this.Agent = agent; + } + + public async ValueTask HandleAsync(IList message, IWorkflowContext context) + { + IReadOnlyCollection messageList = (message as List ?? message.ToList()).AsReadOnly(); + + // TODO: Ideally we want to be able to split the Run across multiple super-steps so that we can stream out + // incremental updates from the chat model. + AgentRunResponse runResponse = await this.Agent.RunAsync(messageList).ConfigureAwait(false); + + await context.SendMessageAsync(runResponse).ConfigureAwait(false); + } +} From 9dfc5aab9168342dddc99867efbf391eb8e9143f Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 20:28:33 -0400 Subject: [PATCH 123/232] fix: Fix routing to go through Executor.ExecuteAsync --- .../Core/Executor.cs | 7 ++- .../Core/ExternalResponse.cs | 3 - .../Execution/DirectEdgeRunner.cs | 17 +++--- .../Execution/EdgeMap.cs | 8 +-- .../Execution/FanInEdgeRunner.cs | 13 ++--- .../Execution/FanOutEdgeRunner.cs | 13 ++--- .../Execution/InputEdgeRunner.cs | 15 ++--- .../Execution/LocalRunner.cs | 17 +++--- .../Execution/LocalRunnerContext.cs | 2 +- .../Execution/StreamingExecutionHandle.cs | 2 +- .../Sample/01_Simple_Workflow_Sequential.cs | 57 +++++++++++++++++++ .../Sample/02_Simple_Workflow_Sequential.cs | 43 -------------- .../SampleSmokeTest.cs | 30 ++++++++++ 13 files changed, 131 insertions(+), 96 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/01_Simple_Workflow_Sequential.cs delete mode 100644 dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 5e43a5c98b..e3f5af19d9 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -80,7 +80,12 @@ internal MessageRouter Router CallResult? result = await this.Router.RouteMessageAsync(message, context, requireRoute: true) .ConfigureAwait(false); - await context.AddEventAsync(new ExecutorCompleteEvent(this.Id)).ConfigureAwait(false); + ExecutorCompleteEvent completeEvent = new(this.Id) + { + Data = result == null ? null : result.IsSuccess ? result.Result : result.Exception + }; + + await context.AddEventAsync(completeEvent).ConfigureAwait(false); if (result == null) { diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs index 2c6bd22782..00dde3bcbd 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs @@ -1,8 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. - -// Copyright (c) Microsoft. All rights reserved. - namespace Microsoft.Agents.Workflows.Core; /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs index 8908a6e3e6..df70b2b620 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs @@ -11,26 +11,23 @@ internal class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData edgeDa { public IWorkflowContext WorkflowContext { get; } = runContext.Bind(edgeData.SinkId); - private async ValueTask FindRouterAsync() + private async ValueTask FindRouterAsync() { - Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) - .ConfigureAwait(false); - - return sink.Router; + return await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); } - public async ValueTask> ChaseAsync(object message) + public async ValueTask> ChaseAsync(object message) { if (this.EdgeData.Condition != null && !this.EdgeData.Condition(message)) { return []; } - MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); - if (router.CanHandle(message)) + Executor target = await this.FindRouterAsync().ConfigureAwait(false); + if (target.CanHandle(message.GetType())) { - return [await router.RouteMessageAsync(message, this.WorkflowContext) - .ConfigureAwait(false)]; + return [await target.ExecuteAsync(message, this.WorkflowContext).ConfigureAwait(false)]; } return []; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs index f9ba08af00..34e13c898d 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs @@ -41,14 +41,14 @@ public EdgeMap(IRunnerContext runContext, this._inputRunner = new InputEdgeRunner(runContext, startExecutorId); } - public async ValueTask> InvokeEdgeAsync(Edge edge, string sourceId, object message) + public async ValueTask> InvokeEdgeAsync(Edge edge, string sourceId, object message) { if (!this._edgeRunners.TryGetValue(edge, out object? edgeRunner)) { throw new InvalidOperationException($"Edge {edge} not found in the edge map."); } - IEnumerable edgeResults; + IEnumerable edgeResults; switch (edge.EdgeType) { // We know the corresponding EdgeRunner type given the FlowEdge EdgeType, as @@ -88,12 +88,12 @@ public EdgeMap(IRunnerContext runContext, } // TODO: Should we promote Input to a true "FlowEdge" type? - public async ValueTask> InvokeInputAsync(object inputMessage) + public async ValueTask> InvokeInputAsync(object inputMessage) { return [await this._inputRunner.ChaseAsync(inputMessage).ConfigureAwait(false)]; } - public async ValueTask> InvokeResponseAsync(ExternalResponse response) + public async ValueTask> InvokeResponseAsync(ExternalResponse response) { if (!this._portEdgeRunners.TryGetValue(response.Port.Id, out InputEdgeRunner? portRunner)) { diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs index 3d8db74eca..9b790bd0e6 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs @@ -13,7 +13,7 @@ internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData public FanInEdgeState CreateState() => new(this.EdgeData); - public async ValueTask ChaseAsync(string sourceId, object message, FanInEdgeState state) + public async ValueTask ChaseAsync(string sourceId, object message, FanInEdgeState state) { IEnumerable? releasedMessages = state.ProcessMessage(sourceId, message); if (releasedMessages is null) @@ -22,14 +22,13 @@ internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData return null; } - Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) - .ConfigureAwait(false); + Executor target = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); - MessageRouter router = sink.Router; - if (router.CanHandle(message)) + if (target.CanHandle(message.GetType())) { - return await router.RouteMessageAsync(message, this.BoundContext) - .ConfigureAwait(false); + return await target.ExecuteAsync(message, this.BoundContext) + .ConfigureAwait(false); } return null; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs index 59afcd1ced..7a21accf8b 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs @@ -15,26 +15,25 @@ internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeDa sinkId => sinkId, sinkId => runContext.Bind(sinkId)); - public async ValueTask> ChaseAsync(object message) + public async ValueTask> ChaseAsync(object message) { List targets = this.EdgeData.Partitioner == null ? this.EdgeData.SinkIds : this.EdgeData.Partitioner(message, this.BoundContexts.Count).Select(i => this.EdgeData.SinkIds[i]).ToList(); - CallResult?[] result = await Task.WhenAll(targets.Select(ProcessTargetAsync)).ConfigureAwait(false); + object?[] result = await Task.WhenAll(targets.Select(ProcessTargetAsync)).ConfigureAwait(false); return result.Where(r => r is not null); - async Task ProcessTargetAsync(string targetId) + async Task ProcessTargetAsync(string targetId) { Executor executor = await this.RunContext.EnsureExecutorAsync(targetId) .ConfigureAwait(false); - MessageRouter router = executor.Router; - if (router.CanHandle(message)) + if (executor.CanHandle(message.GetType())) { - return await router.RouteMessageAsync(message, this.BoundContexts[targetId]) - .ConfigureAwait(false); + return await executor.ExecuteAsync(message, this.BoundContexts[targetId]) + .ConfigureAwait(false); } return null; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs index b0c45ba0f4..bfe002b9bd 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs @@ -19,20 +19,17 @@ public static InputEdgeRunner ForPort(IRunnerContext runContext, InputPort port) return new InputEdgeRunner(runContext, port.Id); } - private async ValueTask FindRouterAsync() + private async ValueTask FindExecutorAsync() { - Executor sink = await this.RunContext.EnsureExecutorAsync(this.EdgeData) - .ConfigureAwait(false); - - return sink.Router; + return await this.RunContext.EnsureExecutorAsync(this.EdgeData).ConfigureAwait(false); } - public async ValueTask ChaseAsync(object message) + public async ValueTask ChaseAsync(object message) { - MessageRouter router = await this.FindRouterAsync().ConfigureAwait(false); - if (router.CanHandle(message)) + Executor target = await this.FindExecutorAsync().ConfigureAwait(false); + if (target.CanHandle(message.GetType())) { - return await router.RouteMessageAsync(message, this.WorkflowContext) + return await target.ExecuteAsync(message, this.WorkflowContext) .ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index ed37852ccb..0a089619e6 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -59,18 +59,14 @@ private bool IsResponse(object message) return message is ExternalResponse; } - private ValueTask> RouteExternalMessageAsync(object message) + private ValueTask> RouteExternalMessageAsync(object message) { -#pragma warning disable CS0219 // Variable is assigned but its value is never used - bool isHil = false; -#pragma warning restore CS0219 // Variable is assigned but its value is never used - return message is ExternalResponse response ? this.CompleteExternalResponseAsync(response) : this.EdgeMap.InvokeInputAsync(message); } - private ValueTask> CompleteExternalResponseAsync(ExternalResponse response) + private ValueTask> CompleteExternalResponseAsync(ExternalResponse response) { if (!this.RunContext.CompleteRequest(response.RequestId)) { @@ -119,7 +115,7 @@ async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cance private async ValueTask RunSuperstepAsync(StepContext currentStep) { // Deliver the messages and queue the next step - List>> edgeTasks = new(); + List>> edgeTasks = new(); foreach (ExecutorIdentity sender in currentStep.QueuedMessages.Keys) { IEnumerable senderMessages = currentStep.QueuedMessages[sender]; @@ -127,9 +123,8 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) { edgeTasks.AddRange(senderMessages.Select(message => this.RouteExternalMessageAsync(message).AsTask())); } - else + else if (this.Workflow.Edges.TryGetValue(sender.Id!, out HashSet? outgoingEdges)) { - HashSet outgoingEdges = this.Workflow.Edges[sender.Id!]; // Id is not null when Identity is not .None foreach (Edge outgoingEdge in outgoingEdges) { edgeTasks.AddRange(senderMessages.Select(message => this.EdgeMap.InvokeEdgeAsync(outgoingEdge, sender.Id, message).AsTask())); @@ -140,7 +135,7 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) // TODO: Should we let the user specify that they want strictly turn-based execution of the edges, vs. concurrent? // (Simply substitute a strategy that replaces Task.WhenAll with a loop with an await in the middle. Difficulty is // that we would need to avoid firing the tasks when we call InvokeEdgeAsync, or RouteExternalMessageAsync. - IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); + IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); // TODO: Commit the state updates (so they are visible to the next step) @@ -149,6 +144,8 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) { this.RaiseWorkflowEvent(@event); } + + this.RunContext.QueuedEvents.Clear(); } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index fb14752d2d..ad6fafe741 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -70,7 +70,7 @@ public ValueTask AddEventAsync(WorkflowEvent workflowEvent) public ValueTask SendMessageAsync(string executorId, object message) { - this._nextStep.MessagesFor(message.GetType().Name).Add(message); + this._nextStep.MessagesFor(executorId).Add(message); return default; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs index eba2f960a1..1fee2c7695 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs @@ -39,7 +39,7 @@ public ValueTask SendResponseAsync(ExternalResponse response) /// /// /// - public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancellation] CancellationToken cancellation) + public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancellation] CancellationToken cancellation = default) { List eventSink = new(); diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/01_Simple_Workflow_Sequential.cs new file mode 100644 index 0000000000..5af83b752a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/01_Simple_Workflow_Sequential.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.IO; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; + +namespace Microsoft.Agents.Workflows.Sample; + +internal static class Step1EntryPoint +{ + public static async ValueTask RunAsync(TextWriter writer) + { + UppercaseExecutor uppercase = new(); + ReverseTextExecutor reverse = new(); + + WorkflowBuilder builder = new(uppercase); + builder.AddEdge(uppercase, reverse); + + Workflow workflow = builder.Build(); + LocalRunner runner = new(workflow); + + var handle = await runner.StreamAsync("Hello, World!").ConfigureAwait(false); + + await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + { + if (evt is ExecutorCompleteEvent executorComplete) + { + writer.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}"); + } + } + } +} + +internal sealed class UppercaseExecutor() : Executor("UppercaseExecutor"), IMessageHandler +{ + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + string result = message.ToUpperInvariant(); + + await context.SendMessageAsync(result).ConfigureAwait(false); + return result; + } +} + +internal sealed class ReverseTextExecutor() : Executor("ReverseTextExecutor"), IMessageHandler +{ + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + char[] charArray = message.ToCharArray(); + System.Array.Reverse(charArray); + string result = new(charArray); + + await context.SendMessageAsync(result).ConfigureAwait(false); + return result; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs deleted file mode 100644 index c731a2c3f1..0000000000 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Sequential.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Core; -using Microsoft.Agents.Workflows.Execution; - -namespace Microsoft.Agents.Workflows.Sample; - -internal static class Step2EntryPoint -{ - public static async ValueTask RunAsync() - { - UppercaseExecutor uppercase = new(); - ReverseTextExecutor reverse = new(); - - WorkflowBuilder builder = new(uppercase); - builder.AddEdge(uppercase, reverse); - - Workflow workflow = builder.Build(); - LocalRunner runner = new(workflow); - - var handle = await runner.StreamAsync("Hello, World!").ConfigureAwait(false); - await handle.RunToCompletionAsync().ConfigureAwait(false); - } -} - -internal sealed class UppercaseExecutor : Executor, IMessageHandler -{ - public ValueTask HandleAsync(string message, IWorkflowContext context) - { - return new ValueTask(message.ToUpperInvariant()); - } -} - -internal sealed class ReverseTextExecutor : Executor, IMessageHandler -{ - public ValueTask HandleAsync(string message, IWorkflowContext context) - { - char[] charArray = message.ToCharArray(); - System.Array.Reverse(charArray); - return new ValueTask(new string(charArray)); - } -} diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs new file mode 100644 index 0000000000..7fdee9601c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Sample; + +namespace Microsoft.Agents.Workflow.UnitTests; + +public class SampleSmokeTest +{ + [Fact] + public async Task Test_RunSample_Step1Async() + { + using StringWriter writer = new(); + + await Step1EntryPoint.RunAsync(writer); + + string result = writer.ToString(); + string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); + + const string INPUT = "Hello, World!"; + + Assert.Collection(lines, + line => Assert.Contains($"UppercaseExecutor: {INPUT.ToUpperInvariant()}", line), + line => Assert.Contains($"ReverseTextExecutor: {new string(INPUT.ToUpperInvariant().Reverse().ToArray())}", line) + ); + } +} From 95aac2a39ee8561a900e14deb1663298c112e2e5 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 6 Aug 2025 21:31:25 -0400 Subject: [PATCH 124/232] test: Update samples for "must SendMessage" semantics * Add invoking samples to unit tests to avoid future breaks --- ...ion.cs => 02_Simple_Workflow_Condition.cs} | 41 ++++++++++++++----- ...low_Loop.cs => 03_Simple_Workflow_Loop.cs} | 40 ++++++++++++++---- .../SampleSmokeTest.cs | 24 +++++++++++ 3 files changed, 87 insertions(+), 18 deletions(-) rename dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/{02a_Simple_Workflow_Condition.cs => 02_Simple_Workflow_Condition.cs} (63%) rename dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/{02b_Simple_Workflow_Loop.cs => 03_Simple_Workflow_Loop.cs} (61%) diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Condition.cs similarity index 63% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs rename to dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Condition.cs index 4040452540..c6f52d7166 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02a_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Condition.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.IO; using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; @@ -8,9 +9,9 @@ namespace Microsoft.Agents.Workflows.Sample; -internal static class Step2aEntryPoint +internal static class Step2EntryPoint { - public static async ValueTask RunAsync() + public static async ValueTask RunAsync(TextWriter writer, string input = "This is a spam message.") { string[] spamKeywords = { "spam", "advertisement", "offer" }; @@ -19,14 +20,29 @@ public static async ValueTask RunAsync() RemoveSpamExecutor removeSpam = new(); Workflow workflow = new WorkflowBuilder(detectSpam) - .AddEdge(detectSpam, respondToMessage, isSpam => isSpam is true) // If not spam, respond - .AddEdge(detectSpam, removeSpam, isSpam => isSpam is false) // If spam, remove + .AddEdge(detectSpam, respondToMessage, isSpam => isSpam is false) // If not spam, respond + .AddEdge(detectSpam, removeSpam, isSpam => isSpam is true) // If spam, remove .Build(); LocalRunner runner = new(workflow); - StreamingExecutionHandle handle = await runner.StreamAsync("This is a spam message.").ConfigureAwait(false); - await handle.RunToCompletionAsync().ConfigureAwait(false); + StreamingExecutionHandle handle = await runner.StreamAsync(input).ConfigureAwait(false); + await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + { + switch (evt) + { + case WorkflowCompletedEvent workflowCompleteEvt: + // The workflow has completed successfully, return the result + string workflowResult = workflowCompleteEvt.Data!.ToString()!; + writer.WriteLine($"Result: {workflowResult}"); + return workflowResult; + case ExecutorCompleteEvent executorCompleteEvt: + writer.WriteLine($"'{executorCompleteEvt.ExecutorId}: {executorCompleteEvt.Data}"); + break; + } + } + + throw new InvalidOperationException("Workflow failed to yield the completion event."); } } @@ -39,7 +55,7 @@ public DetectSpamExecutor(params string[] spamKeywords) this.SpamKeywords = spamKeywords; } - public ValueTask HandleAsync(string message, IWorkflowContext context) + public async ValueTask HandleAsync(string message, IWorkflowContext context) { #if NET5_0_OR_GREATER bool isSpam = this.SpamKeywords.Any(keyword => message.Contains(keyword, StringComparison.OrdinalIgnoreCase)); @@ -47,12 +63,15 @@ public ValueTask HandleAsync(string message, IWorkflowContext context) bool isSpam = this.SpamKeywords.Any(keyword => message.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0); #endif - return new ValueTask(isSpam); + await context.SendMessageAsync(isSpam).ConfigureAwait(false); + return isSpam; } } internal sealed class RespondToMessageExecutor : Executor, IMessageHandler { + public const string ActionResult = "Message processed successfully."; + public async ValueTask HandleAsync(bool message, IWorkflowContext context) { if (message) @@ -63,13 +82,15 @@ public async ValueTask HandleAsync(bool message, IWorkflowContext context) await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay - await context.AddEventAsync(new WorkflowCompletedEvent { Data = "Message processed successfully." }) + await context.AddEventAsync(new WorkflowCompletedEvent { Data = RespondToMessageExecutor.ActionResult }) .ConfigureAwait(false); } } internal sealed class RemoveSpamExecutor : Executor, IMessageHandler { + public const string ActionResult = "Spam message removed."; + public async ValueTask HandleAsync(bool message, IWorkflowContext context) { if (!message) @@ -80,7 +101,7 @@ public async ValueTask HandleAsync(bool message, IWorkflowContext context) await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay - await context.AddEventAsync(new WorkflowCompletedEvent { Data = "Spam message removed." }) + await context.AddEventAsync(new WorkflowCompletedEvent { Data = RemoveSpamExecutor.ActionResult }) .ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/03_Simple_Workflow_Loop.cs similarity index 61% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs rename to dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/03_Simple_Workflow_Loop.cs index df69c47167..ead24833e4 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02b_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/03_Simple_Workflow_Loop.cs @@ -1,14 +1,16 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.IO; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; using Microsoft.Agents.Workflows.Execution; namespace Microsoft.Agents.Workflows.Sample; -internal static class Step2bEntryPoint +internal static class Step3EntryPoint { - public static async ValueTask RunAsync() + public static async ValueTask RunAsync(TextWriter writer) { GuessNumberExecutor guessNumber = new(1, 100); JudgeExecutor judge = new(42); // Let's say the target number is 42 @@ -20,7 +22,23 @@ public static async ValueTask RunAsync() LocalRunner runner = new(workflow); StreamingExecutionHandle handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); - await handle.RunToCompletionAsync(); + + await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + { + switch (evt) + { + case WorkflowCompletedEvent workflowCompleteEvt: + // The workflow has completed successfully, return the result + string workflowResult = workflowCompleteEvt.Data!.ToString()!; + writer.WriteLine($"Result: {workflowResult}"); + return workflowResult; + case ExecutorCompleteEvent executorCompleteEvt: + writer.WriteLine($"'{executorCompleteEvt.ExecutorId}: {executorCompleteEvt.Data}"); + break; + } + } + + throw new InvalidOperationException("Workflow failed to yield the completion event."); } } @@ -63,7 +81,9 @@ await context.AddEventAsync(new WorkflowCompletedEvent { Data = $"Guessed the nu break; } - return this._currGuess = this.NextGuess; + this._currGuess = this.NextGuess; + await context.SendMessageAsync(this._currGuess).ConfigureAwait(false); + return this._currGuess; } } @@ -76,19 +96,23 @@ public JudgeExecutor(int targetNumber) this._targetNumber = targetNumber; } - public ValueTask HandleAsync(int message, IWorkflowContext context) + public async ValueTask HandleAsync(int message, IWorkflowContext context) { + NumberSignal result; if (message == this._targetNumber) { - return new ValueTask(NumberSignal.Matched); + result = NumberSignal.Matched; } else if (message < this._targetNumber) { - return new ValueTask(NumberSignal.Below); + result = NumberSignal.Below; } else { - return new ValueTask(NumberSignal.Above); + result = NumberSignal.Above; } + + await context.SendMessageAsync(result).ConfigureAwait(false); + return result; } } diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs index 7fdee9601c..1acf4aa1d8 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs @@ -27,4 +27,28 @@ public async Task Test_RunSample_Step1Async() line => Assert.Contains($"ReverseTextExecutor: {new string(INPUT.ToUpperInvariant().Reverse().ToArray())}", line) ); } + + [Fact] + public async Task Test_RunSample_Step2Async() + { + using StringWriter writer = new(); + + string spamResult = await Step2EntryPoint.RunAsync(writer); + + Assert.Equal(RemoveSpamExecutor.ActionResult, spamResult); + + string nonSpamResult = await Step2EntryPoint.RunAsync(writer, "This is a valid message."); + + Assert.Equal(RespondToMessageExecutor.ActionResult, nonSpamResult); + } + + [Fact] + public async Task Test_RunSample_Step3Async() + { + using StringWriter writer = new(); + + string guessResult = await Step3EntryPoint.RunAsync(writer); + + Assert.Equal("Guessed the number: 42", guessResult); + } } From 997966aee0cb02f7201a16d49726e3034452c6ea Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 12:02:13 -0400 Subject: [PATCH 125/232] fix: ExternalRequest should block Workflow completion --- .../Microsoft.Agents.Workflow/Core/Events.cs | 2 +- .../Core/Executor.cs | 2 +- .../Core/ExternalRequest.cs | 3 +- .../Core/RouteBuilder.cs | 40 ++++++++++ .../Core/Workflow.cs | 3 +- .../Execution/EdgeMap.cs | 2 +- .../Execution/ISuperStepRunner.cs | 3 + .../Execution/LocalRunner.cs | 22 +++--- .../Execution/StreamingExecutionHandle.cs | 25 +++++- .../Microsoft.Agents.Workflow/ExecutorIsh.cs | 2 +- .../Specialized/OutputCollectorExecutor.cs | 15 +++- .../Specialized/RequestInputExecutor.cs | 25 ++++-- .../StreamingAggregators.cs | 9 ++- .../WorkflowBuilder.cs | 10 ++- .../WorkflowBuilderExtensions.cs | 22 ++---- .../05_Simple_Workflow_ExternalRequest.cs | 76 +++++++++++++++++++ .../SampleSmokeTest.cs | 38 ++++++++++ 17 files changed, 250 insertions(+), 49 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs index 0a06cf1bb0..63013e03a3 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs @@ -22,7 +22,7 @@ public record WorkflowCompletedEvent : WorkflowEvent; /// /// . /// -public record RequestInputEvent(ExternalRequest request) : WorkflowEvent; +public record RequestInputEvent(ExternalRequest Request) : WorkflowEvent; /// /// . diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index e3f5af19d9..4312f2a7cf 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -78,7 +78,7 @@ internal MessageRouter Router await context.AddEventAsync(new ExecutorInvokeEvent(this.Id)).ConfigureAwait(false); CallResult? result = await this.Router.RouteMessageAsync(message, context, requireRoute: true) - .ConfigureAwait(false); + .ConfigureAwait(false); ExecutorCompleteEvent completeEvent = new(this.Id) { diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs index c7ab3b2d5e..e06cdfaf02 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs @@ -66,8 +66,7 @@ public ExternalResponse CreateResponse(object data) /// . /// /// - /// /// /// - public ExternalResponse CreateResponse(InputPort port, T data) => this.CreateResponse(port, (object)Throw.IfNull(data)); + public ExternalResponse CreateResponse(T data) => this.CreateResponse((object)Throw.IfNull(data)); } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs index e480aeb97d..83eb64020a 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs @@ -44,6 +44,46 @@ internal RouteBuilder AddHandler(Type messageType, MessageHandlerF handler, bool return this; } + /// + /// . + /// + /// + /// + /// + /// + internal RouteBuilder AddHandler(Type type, Func handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddHandler(type, WrappedHandlerAsync, overwrite); + + async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) + { + await handler.Invoke(msg, ctx).ConfigureAwait(false); + return CallResult.ReturnVoid(); + } + } + + /// + /// . + /// + /// + /// + /// + /// + internal RouteBuilder AddHandler(Type type, Func> handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddHandler(type, WrappedHandlerAsync, overwrite); + + async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) + { + TResult result = await handler.Invoke(msg, ctx).ConfigureAwait(false); + return CallResult.ReturnResult(result); + } + } + /// /// . /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs index b12ff25113..d26ee4f12f 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs @@ -25,7 +25,7 @@ public class Workflow /// /// . /// - public Dictionary Ports { get; } = new(); + public Dictionary Ports { get; internal init; } = new(); /// /// . @@ -68,6 +68,7 @@ internal Workflow Promote(OutputSink outputSource) { ExecutorProviders = this.ExecutorProviders, Edges = this.Edges, + Ports = this.Ports }; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs index 34e13c898d..80c3daab19 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs @@ -100,6 +100,6 @@ public EdgeMap(IRunnerContext runContext, throw new InvalidOperationException($"Port {response.Port.Id} not found in the edge map."); } - return [await portRunner.ChaseAsync(response.Data).ConfigureAwait(false)]; + return [await portRunner.ChaseAsync(response).ConfigureAwait(false)]; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs index f2c6b5f929..073d8d398c 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs @@ -9,6 +9,9 @@ namespace Microsoft.Agents.Workflows.Execution; internal interface ISuperStepRunner { + bool HasUnservicedRequests { get; } + bool HasUnprocessedMessages { get; } + ValueTask EnqueueMessageAsync(object message); event EventHandler? WorkflowEvent; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index 0a089619e6..54158616dc 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -89,23 +89,19 @@ public async ValueTask StreamAsync(TInput input, Cance return new StreamingExecutionHandle(this); } - private StepContext? _currentStep = null; + bool ISuperStepRunner.HasUnservicedRequests => this.RunContext.HasUnservicedRequests; + bool ISuperStepRunner.HasUnprocessedMessages => this.RunContext.NextStepHasActions; + + //private StepContext? _currentStep = null; async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cancellation) { cancellation.ThrowIfCancellationRequested(); - if (this._currentStep == null) - { - // TODO: Python-side does not raise this event. - // await this.RunContext.AddEventAsync(this.Workflow.StartExecutorId, new WorkflowStartedEvent()).ConfigureAwait(false); - this._currentStep = this.RunContext.Advance(); - } + StepContext currentStep = this.RunContext.Advance(); - if (this._currentStep.HasMessages) + if (currentStep.HasMessages) { - await this.RunSuperstepAsync(this._currentStep).ConfigureAwait(false); - this._currentStep = this.RunContext.Advance(); - + await this.RunSuperstepAsync(currentStep).ConfigureAwait(false); return true; } @@ -175,11 +171,11 @@ public LocalRunner(Workflow workflow) /// /// /// - public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) + public async ValueTask> StreamAsync(TInput input, CancellationToken cancellation = default) { await this._innerRunner.EnqueueMessageAsync(input).ConfigureAwait(false); - return new StreamingExecutionHandle(this._innerRunner); + return new StreamingExecutionHandle(this); } /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs index 1fee2c7695..47d5b24ecd 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs @@ -15,6 +15,7 @@ namespace Microsoft.Agents.Workflows.Execution; /// public class StreamingExecutionHandle { + private TaskCompletionSource? _waitForResponseSource = null; private readonly ISuperStepRunner _stepRunner; internal StreamingExecutionHandle(ISuperStepRunner stepRunner) @@ -30,6 +31,8 @@ internal StreamingExecutionHandle(ISuperStepRunner stepRunner) /// public ValueTask SendResponseAsync(ExternalResponse response) { + this._waitForResponseSource?.TrySetResult(new()); + return this._stepRunner.EnqueueMessageAsync(response); } @@ -47,8 +50,11 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell try { - while (await this._stepRunner.RunSuperStepAsync(cancellation).ConfigureAwait(false)) + do { + // Drain SuperSteps while there are steps to run + await this._stepRunner.RunSuperStepAsync(cancellation).ConfigureAwait(false); + bool hadCompletionEvent = false; List outputEvents = Interlocked.Exchange(ref eventSink, new()); foreach (WorkflowEvent raisedEvent in outputEvents) @@ -67,7 +73,22 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell // If we had a completion event, we are done. yield break; } - } + + // If we do not have any actions to take on the Workflow, but have unprocessed + // requests, wait for the responses to come in before exiting out of the workflow + // execution. + if (!this._stepRunner.HasUnprocessedMessages && + this._stepRunner.HasUnservicedRequests) + { + if (this._waitForResponseSource == null) + { + this._waitForResponseSource = new(); + } + + await this._waitForResponseSource.Task.ConfigureAwait(false); + this._waitForResponseSource = null; + } + } while (this._stepRunner.HasUnprocessedMessages); } finally { diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs index 28384a2cf8..1a1bfb193e 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -48,7 +48,7 @@ public enum Type private readonly string? _idValue; private readonly Executor? _executorValue; - private readonly InputPort? _inputPortValue; + internal readonly InputPort? _inputPortValue; private readonly AIAgent? _aiAgentValue; /// diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs index f3658fb479..a1a6099572 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; @@ -17,14 +18,24 @@ internal OutputSink(string? id = null) : base(id) internal class OutputCollectorExecutor : OutputSink, IMessageHandler { private readonly StreamingAggregator _aggregator; - public OutputCollectorExecutor(StreamingAggregator aggregator, string? id = null) : base(id) + private readonly Func? _completionCondition; + + public OutputCollectorExecutor(StreamingAggregator aggregator, Func? completionCondition = null, string? id = null) : base(id) { this._aggregator = Throw.IfNull(aggregator); + this._completionCondition = completionCondition; } public ValueTask HandleAsync(TInput message, IWorkflowContext context) { - this.Result = this._aggregator(message); + this.Result = this._aggregator(message, this.Result); + + if (this._completionCondition is not null && + this._completionCondition!(message, this.Result)) + { + return context.AddEventAsync(new WorkflowCompletedEvent() { Data = this.Result }); + } + return default; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs b/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs index be460d2955..9cd3a8e2c9 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.Workflows.Specialized; -internal class RequestInputExecutor : Executor, IMessageHandler, IMessageHandler +internal class RequestInputExecutor : Executor, IMessageHandler, IMessageHandler { private InputPort Port { get; } private IExternalRequestSink? RequestSink { get; set; } @@ -18,19 +18,32 @@ public RequestInputExecutor(InputPort port) : base(port.Id) this.Port = port; } + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + return routeBuilder + // Handle incoming requests (as raw request payloads) + .AddHandler(this.Port.Request, this.HandleAsync) + .AddHandler(typeof(object), this.HandleAsync) + // Handle incoming responses (as wrapped Response object) + .AddHandler(this.HandleAsync); + } + internal void AttachRequestSink(IExternalRequestSink requestSink) { this.RequestSink = Throw.IfNull(requestSink); } - public ValueTask HandleAsync(object message, IWorkflowContext context) + public async ValueTask HandleAsync(object message, IWorkflowContext context) { Throw.IfNull(message); - return this.RequestSink!.PostAsync(ExternalRequest.Create(this.Port, message)); + ExternalRequest request = ExternalRequest.Create(this.Port, message); + await this.RequestSink!.PostAsync(request).ConfigureAwait(false); + + return request; } - public ValueTask HandleAsync(ExternalResponse message, IWorkflowContext context) + public async ValueTask HandleAsync(ExternalResponse message, IWorkflowContext context) { Throw.IfNull(message); Throw.IfNull(message.Data); @@ -41,6 +54,8 @@ public ValueTask HandleAsync(ExternalResponse message, IWorkflowContext context) $"Message type {message.Data.GetType().Name} is not assignable to the response type {this.Port.Response.Name} of input port {this.Port.Id}."); } - return context.SendMessageAsync(message.Data); + await context.SendMessageAsync(message.Data).ConfigureAwait(false); + + return message; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs b/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs index 942eb81ab6..b90048a43a 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs @@ -11,8 +11,9 @@ namespace Microsoft.Agents.Workflows; /// /// /// +/// /// -public delegate TResult? StreamingAggregator(TInput input); +public delegate TResult? StreamingAggregator(TInput input, TResult? runningResult); /// /// . @@ -34,7 +35,7 @@ public static StreamingAggregator First(Func Last(Func> Union Aggregate(TInput input) + IEnumerable Aggregate(TInput input, IEnumerable? runningResult) { results.Add(conversion(input)); return results; diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index d92bef1c8a..cb96b72c78 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -37,6 +37,7 @@ private record struct EdgeId(string SourceId, string TargetId) private readonly Dictionary> _edges = new(); private readonly HashSet _unboundExecutors = new(); private readonly HashSet _conditionlessEdges = new(); + private readonly Dictionary _inputPorts = new(); private readonly string _startExecutorId; @@ -67,6 +68,12 @@ private ExecutorIsh Track(ExecutorIsh executorish) this._executors[executorish.Id] = provider; } + if (executorish.ExecutorType == ExecutorIsh.Type.InputPort) + { + InputPort port = executorish._inputPortValue!; + this._inputPorts[port.Id] = port; + } + return executorish; } @@ -217,7 +224,8 @@ public Workflow Build() return new Workflow(this._startExecutorId) // Why does it not see the default ctor? { ExecutorProviders = this._executors, - Edges = this._edges + Edges = this._edges, + Ports = this._inputPorts }; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index 7643d05078..69894710fa 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -74,26 +74,18 @@ public static WorkflowBuilder AddExternalCall(this Workflow /// /// /// + /// /// - public static Workflow BuildWithOutput(this WorkflowBuilder builder, ExecutorIsh outputSource, StreamingAggregator aggregator) - => builder.BuildWithOutput(outputSource, aggregator); - - /// - /// . - /// - /// - /// - /// - /// - /// - /// - /// - public static Workflow BuildWithOutput(this WorkflowBuilder builder, ExecutorIsh outputSource, StreamingAggregator aggregator) + public static Workflow BuildWithOutput( + this WorkflowBuilder builder, + ExecutorIsh outputSource, + StreamingAggregator aggregator, + Func? completionCondition = null) { Throw.IfNull(outputSource); Throw.IfNull(aggregator); - OutputCollectorExecutor outputSink = new(aggregator); + OutputCollectorExecutor outputSink = new(aggregator, completionCondition); // TODO: Check taht the outputSource has a TResult output? builder.AddEdge(outputSource, outputSink); diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs new file mode 100644 index 0000000000..ca2de73632 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; +using Microsoft.Agents.Workflows.Sample; + +namespace Microsoft.Agents.Workflow.UnitTests.Sample; + +internal static class Step5EntryPoint +{ + public static async ValueTask RunAsync(TextWriter writer, Func userGuessCallback) + { + InputPort guessNumber = new("GuessNumber", typeof(NumberSignal), typeof(int)); + JudgeExecutor judge = new(42); // Let's say the target number is 42 + + Workflow workflow = new WorkflowBuilder(guessNumber) + .AddEdge(guessNumber, judge) + .AddEdge(judge, guessNumber, (message) => message is NumberSignal signal && signal != NumberSignal.Matched) + .BuildWithOutput(judge, ComputeStreamingOutput, (NumberSignal s, string? _) => s == NumberSignal.Matched); + + LocalRunner runner = new(workflow); + StreamingExecutionHandle handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); + + await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + { + switch (evt) + { + case RequestInputEvent requestInputEvt: + ExternalResponse response = ExecuteExternalRequest(requestInputEvt.Request, userGuessCallback, workflow.RunningOutput); + await handle.SendResponseAsync(response).ConfigureAwait(false); + break; + + case WorkflowCompletedEvent workflowCompleteEvt: + // The workflow has completed successfully, return the result + string workflowResult = workflowCompleteEvt.Data!.ToString()!; + writer.WriteLine($"Result: {workflowResult}"); + return workflowResult; + case ExecutorCompleteEvent executorCompleteEvt: + writer.WriteLine($"'{executorCompleteEvt.ExecutorId}: {executorCompleteEvt.Data}"); + break; + } + } + + throw new InvalidOperationException("Workflow failed to yield the completion event."); + } + + private static ExternalResponse ExecuteExternalRequest( + ExternalRequest request, + Func userGuessCallback, + string? runningState) + { + object result = request.Port.Id switch + { + "GuessNumber" => userGuessCallback(runningState ?? "Guess the number."), + _ => throw new NotSupportedException($"Request {request.Port.Id} is not supported") + }; + + return request.CreateResponse(result); + } + + private static string ComputeStreamingOutput(NumberSignal signal, string? runningResult) + { + return signal switch + { + NumberSignal.Matched => "You guessed correctly! You Win!", + NumberSignal.Above => "Your guess was too high. Try again.", + NumberSignal.Below => "Your guess was too low. Try again.", + + _ => runningResult ?? string.Empty + }; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs index 1acf4aa1d8..7ec53c5756 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using Microsoft.Agents.Workflow.UnitTests.Sample; using Microsoft.Agents.Workflows.Sample; namespace Microsoft.Agents.Workflow.UnitTests; @@ -51,4 +52,41 @@ public async Task Test_RunSample_Step3Async() Assert.Equal("Guessed the number: 42", guessResult); } + + [Fact] + public async Task Test_RunSample_Step5Async() + { + using StringWriter writer = new(); + + VerifyingPlaybackResponder responder = new( + ("Guess the number.", 50), + ("Your guess was too high. Try again.", 23), + ("Your guess was too low. Try again.", 42)); + + string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext); + Assert.Equal("You guessed correctly! You Win!", guessResult); + } +} + +internal sealed class VerifyingPlaybackResponder +{ + public (TInput input, TResponse response)[] Responses { get; } + private int _position = 0; + + public VerifyingPlaybackResponder(params (TInput input, TResponse response)[] responses) + { + this.Responses = responses; + } + + public int Remaining => Math.Max(0, this.Responses.Length - this._position); + + public TResponse InvokeNext(TInput input) + { + Assert.True(this.Remaining > 0); + + (TInput expectedInput, TResponse expectedResponse) = this.Responses[this._position++]; + Assert.Equal(expectedInput, input); + + return expectedResponse; + } } From dfb4e61db529c5ae0e592f11edeb13e8f38071fd Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 13:54:53 -0400 Subject: [PATCH 126/232] feat: Normalize API surface against Python * Also adds xmldoc to all public APIs --- .../Microsoft.Agents.Workflow/Core/Edge.cs | 64 +++++---- .../Microsoft.Agents.Workflow/Core/Events.cs | 32 +++-- .../Core/Executor.cs | 125 +++--------------- .../Core/ExecutorCapabilities.cs | 69 ---------- .../Core/ExternalRequest.cs | 48 +++---- .../Core/ExternalResponse.cs | 8 +- .../Core/InputPort.cs | 14 +- .../Core/RouteBuilder.cs | 52 ++++---- .../Core/Workflow.cs | 40 +++--- .../Execution/IRunnerWithOutput.cs | 10 ++ .../Execution/IRunnerWithResult.cs | 13 -- .../Execution/LocalRunner.cs | 74 ++++++----- .../Execution/LocalRunnerContext.cs | 2 - .../Execution/StreamingExecutionHandle.cs | 66 +++++---- .../Microsoft.Agents.Workflow/ExecutorIsh.cs | 52 ++++---- .../StreamingAggregators.cs | 91 ++++++++----- .../WorkflowBuilder.cs | 75 +++++++---- .../WorkflowBuilderExtensions.cs | 60 ++++++--- 18 files changed, 418 insertions(+), 477 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithOutput.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs index de2dc0ed44..0769d0012e 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs @@ -10,32 +10,34 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// Represents a directed edge between two nodes, optionally associated with a condition that determines whether the +/// edge is active. /// -/// -/// -/// +/// The id of the source executor node. +/// The id of the target executor node. +/// A predicate determining whether the edge is active for a given message. public record DirectEdgeData( string SourceId, string SinkId, PredicateT? Condition = null) { /// - /// . + /// Converts a instance to an using an implicit conversion. /// - /// + /// The to convert to an . Cannot be null. public static implicit operator Edge(DirectEdgeData data) { - return new Edge(data); + return new Edge(Throw.IfNull(data)); } } /// -/// . +/// Represents a connection from a single node to a set of nodes, optionally associated with a paritition selector +/// function which maps incoming messages to a subset of the target set. /// -/// -/// -/// +/// The id of the source executor node. +/// A list of ids of the target executor nodes. +/// A function that maps an incoming message to a subset of the target executor nodes. public record FanOutEdgeData( string SourceId, List SinkIds, @@ -52,26 +54,29 @@ public static implicit operator Edge(FanOutEdgeData data) } /// -/// . +/// Specifies the condition under which a fan-in operation is triggered in a workflow. +/// Use to trigger the operation when all incoming edges have data, or +/// to trigger when any incoming edge has data. /// public enum FanInTrigger { /// - /// . + /// Trigger when all incoming edges have data. /// WhenAll, /// - /// . + /// Trigger when any incoming edge has data. /// WhenAny } /// -/// . +/// Represents a connection from a set of nodes to a single node. It can trigger either when all edges have data +/// or when any of them have data. /// -/// -/// -/// +/// An enumeration of ids of the source executor nodes. +/// The id of the target executor node. +/// The that determines when the fan-in edge is activated. public record FanInEdgeData( IEnumerable SourceIds, string SinkId, @@ -90,37 +95,46 @@ public static implicit operator Edge(FanInEdgeData data) } /// -/// . +/// Represents a connection or relationship between nodes, characterized by its type and associated data. /// +/// +/// An can be of type , , or , as specified by the property. The property holds +/// additional information relevant to the edge, and its concrete type depends on the value of , functioning as a tagged union. +/// public class Edge { /// - /// . + /// Specified the edge type. /// public enum Type { /// - /// . + /// A direct connection from one node to another. /// Direct, /// - /// . + /// A connection from one node to a set of nodes. /// FanOut, /// - /// . + /// A connection from a set of nodes to a single node. /// FanIn } /// - /// . + /// Specifies the type of the edge, which determines how the edge is processed in the workflow. /// public Type EdgeType { get; init; } /// - /// . + /// The -dependent edge data. /// + /// + /// + /// public object Data { get; init; } internal Edge(DirectEdgeData data) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs index 63013e03a3..1bbf5150a5 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs @@ -5,27 +5,31 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// Base class for -scoped events. /// public record WorkflowEvent(object? Data = null); /// -/// . +/// Event triggered when a workflow starts execution. /// public record WorkflowStartedEvent : WorkflowEvent; /// -/// . +/// Event triggered when a workflow completes execution. /// +/// +/// The user is expected to raise this event from a terminating , or to build +/// the workflow with output capture using . +/// public record WorkflowCompletedEvent : WorkflowEvent; /// -/// . +/// Event triggered when a workflow executor request external information. /// public record RequestInputEvent(ExternalRequest Request) : WorkflowEvent; /// -/// . +/// Base class for -scoped events. /// public record ExecutorEvent : WorkflowEvent { @@ -35,8 +39,11 @@ public record ExecutorEvent : WorkflowEvent public string ExecutorId { get; } /// - /// . + /// Initializes a new instance of the class with the specified executor identifier and + /// optional event data. /// + /// The unique identifier of the executor associated with this event. Cannot be null. + /// Optional event data to associate with the event. May be null if no additional data is required. public ExecutorEvent(string executorId, object? data = null) : base(data) { this.ExecutorId = Throw.IfNull(executorId); @@ -44,12 +51,12 @@ public ExecutorEvent(string executorId, object? data = null) : base(data) } /// -/// . +/// Event triggered when an executor handler is invoked. /// public record ExecutorInvokeEvent : ExecutorEvent { /// - /// . + /// Initializes a new instance of the class. /// public ExecutorInvokeEvent(string executorId, object? data = null) : base(executorId, data) { @@ -57,14 +64,17 @@ public ExecutorInvokeEvent(string executorId, object? data = null) : base(execut } /// -/// . +/// Event triggered when an executor handler has completed. /// public record ExecutorCompleteEvent : ExecutorEvent { /// - /// . + /// Initializes a new instance of the class to signal that an executor has + /// completed its operation. /// - public ExecutorCompleteEvent(string executorId, object? data = null) : base(executorId, data) { } + /// The unique identifier of the executor that has completed. Cannot be null or empty. + /// The result produced by the executor upon completion, or null if no result is available. + public ExecutorCompleteEvent(string executorId, object? result = null) : base(executorId, result) { } } // TODO: This is a placeholder for streaming chat message content. diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs index 4312f2a7cf..8081bb9af3 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs @@ -2,49 +2,39 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Diagnostics; using System.Reflection; using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// A component that processes messages in a . /// -[DebuggerDisplay("{GetType().Name}{Id}({Name})")] +[DebuggerDisplay("{GetType().Name}{Id}")] public abstract class Executor : IIdentified, IAsyncDisposable { /// - /// . + /// A unique identifier for the executor. /// public string Id { get; } - /// - /// . - /// - public string Name { get; } - private Dictionary State { get; } = new(); /// - /// . + /// Initialize the executor with a unique identifier /// - /// - /// - protected Executor(string? id = null, string? name = null) + /// A optional unique identifier for the executor. If null, a type-tagged + /// UUID will be generated. + protected Executor(string? id = null) { - this.Name = name ?? this.GetType().Name; - this.Id = id ?? $"{this.Name}{Guid.NewGuid():N}"; + this.Id = id ?? $"{this.GetType().Name}/{Guid.NewGuid():N}"; } /// /// Override this method to register handlers for the executor. The deafult implementation uses reflection to /// look for implementations of and . /// - /// - /// protected virtual RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) { return routeBuilder.ReflectHandlers(this); @@ -66,13 +56,13 @@ internal MessageRouter Router } /// - /// . + /// Process an incoming message using the registered handlers. /// - /// - /// - /// - /// - /// + /// The message to be processed by the executor. + /// The workflow context in which the executor executes. + /// A ValueTask representing the asynchronous operation, wrapping the output from the executor. + /// No handler found for the message type. + /// An exception is generated while handling the message. public async ValueTask ExecuteAsync(object message, IWorkflowContext context) { await context.AddEventAsync(new ExecutorInvokeEvent(this.Id)).ConfigureAwait(false); @@ -124,104 +114,29 @@ protected void CheckInitialized() } /// - /// . + /// A set of s, representing the messages this executor can handle. /// public ISet InputTypes => this.Router.IncomingTypes; /// - /// . + /// A set of s, representing the messages this executor can produce as output. /// - public virtual ISet OutputTypes => new HashSet(); + public virtual ISet OutputTypes => new HashSet([typeof(object)]); /// - /// . + /// Checks if the executor can handle a specific message type. /// /// /// public bool CanHandle(Type messageType) => this.Router.CanHandle(messageType); - /// - /// . - /// - /// - /// - public async ValueTask InitializeAsync(IWorkflowContext context) - { - if (this._initialized) - { - return; - } - - await this.InitializeOverride(context).ConfigureAwait(false); - - this._initialized = true; - } - - /// - /// . - /// - public ExecutorCapabilities Capabilities - => new() - { - Id = this.Id, - Name = this.Name, - ExecutorType = this.GetType(), - HandledMessageTypes = new HashSet(this.InputTypes), - IsInitialized = this._initialized, - StateKeys = new HashSet(this.State.Keys) - }; - - /// - /// . - /// - /// - public ReadOnlyDictionary CurrentState => new(this.State); - - /// - /// . - /// - /// - /// - public void RestoreState(IDictionary state) - { - Throw.IfNull(state); - - this.State.Clear(); - - foreach (KeyValuePair kvp in state) - { - this.State[kvp.Key] = kvp.Value; - } - } - - /// - /// . - /// - /// - protected virtual ValueTask PrepareForCheckpointAsync() => default; - - /// - /// . - /// - /// - protected virtual ValueTask AfterCheckpointRestoreAsync() => default; - - /// - /// . - /// - /// - /// - protected virtual ValueTask InitializeOverride(IWorkflowContext context) => default; - - /// - /// . - /// - /// + /// protected virtual async ValueTask DisposeAsync() { this._initialized = false; } + /// ValueTask IAsyncDisposable.DisposeAsync() { GC.SuppressFinalize(this); // Should we be suppressing the finalizer here? CodeAnalysis seems to want it (CA1816) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs deleted file mode 100644 index 7f6ab5aebd..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/ExecutorCapabilities.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// . -/// -public record ExecutorCapabilities -{ - /// - /// . - /// - public string Id { get; init; } - /// - /// . - /// - public string Name { get; init; } - /// - /// . - /// - public Type ExecutorType { get; init; } - /// - /// . - /// - public ISet HandledMessageTypes { get; init; } - /// - /// . - /// - public bool IsInitialized { get; init; } - /// - /// . - /// - public ISet StateKeys { get; init; } - - /// - /// . - /// - public ExecutorCapabilities() - { - this.Id = string.Empty; - this.Name = string.Empty; - this.ExecutorType = typeof(Executor); - this.HandledMessageTypes = new HashSet(); - this.IsInitialized = false; - this.StateKeys = new HashSet(); - } - - /// - /// . - /// - /// - /// - /// - /// - /// - /// - public ExecutorCapabilities(string id, string name, Type executorType, ISet handledMessageTypes, bool isInitialized, ISet stateKeys) - { - this.Id = id; - this.Name = name; - this.ExecutorType = executorType; - this.HandledMessageTypes = handledMessageTypes; - this.IsInitialized = isInitialized; - this.StateKeys = stateKeys; - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs index e06cdfaf02..0dcc3008a8 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs @@ -7,21 +7,21 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// Represents a request to an external input port. /// -/// -/// -/// +/// The port to invoke. +/// A unique identifier for this request instance. +/// The data contained in the request. public record ExternalRequest(InputPort Port, string RequestId, object Data) { /// - /// . + /// Creates a new for the specified input port and data payload. /// - /// - /// - /// - /// - /// + /// The port to invoke. + /// The data contained in the request. + /// An optional unique identifier for this request instance. If null, a UUID will be generated. + /// An instance containing the specified port, data, and request identifier. + /// Thrown when the input data object does not match the expected request type. public static ExternalRequest Create(InputPort port, [NotNull] object data, string? requestId = null) { if (!port.Request.IsAssignableFrom(Throw.IfNull(data).GetType())) @@ -36,21 +36,21 @@ public static ExternalRequest Create(InputPort port, [NotNull] object data, stri } /// - /// . + /// Creates a new for the specified input port and data payload. /// - /// - /// - /// - /// - /// + /// The type of request data. + /// The input port that identifies the target endpoint for the request. Must not be null. + /// The data payload to include in the request. Must not be null. + /// An optional identifier for the request. If null, a default identifier may be assigned. + /// An instance containing the specified port, data, and request identifier. public static ExternalRequest Create(InputPort port, T data, string? requestId = null) => Create(port, (object)Throw.IfNull(data), requestId); /// - /// . + /// Creates a new corresponding to the request, with the speicified data payload. /// - /// - /// - /// + /// The data contained in the response. + /// An instance corresponding to this request with the specified data. + /// Thrown when the input data object does not match the expected response type. public ExternalResponse CreateResponse(object data) { if (!Throw.IfNull(this.Port).Response.IsAssignableFrom(Throw.IfNull(data).GetType())) @@ -63,10 +63,10 @@ public ExternalResponse CreateResponse(object data) } /// - /// . + /// Creates a new corresponding to the request, with the speicified data payload. /// - /// - /// - /// + /// The type of the response data. + /// The data contained in the response. + /// An instance corresponding to this request with the specified data. public ExternalResponse CreateResponse(T data) => this.CreateResponse((object)Throw.IfNull(data)); } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs index 00dde3bcbd..58ed3a1be9 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs @@ -3,11 +3,11 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// Represents a request from an external input port. /// -/// -/// -/// +/// The port invoked. +/// The unique identifier of the corresponding request. +/// The data contained in the response. public record ExternalResponse(InputPort Port, string RequestId, object Data) { } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs index cfa4e27f8d..bf49afb78f 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs @@ -5,10 +5,20 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// An external request port for a with the specified request and response types. /// /// /// /// public record InputPort(string Id, Type Request, Type Response) -{ }; +{ + /// + /// Creates a new instance configured for the specified request and response types. + /// + /// The type of the request messages that the input port will accept. + /// The type of the response messages that the input port will produce. + /// The unique identifier for the input port. + /// An instance associated with the specified , configured to handle + /// requests of type and responses of type . + public static InputPort Create(string id) => new(id, typeof(TRequest), typeof(TResponse)); +}; diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs index 83eb64020a..467a374bfa 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs @@ -15,8 +15,13 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// Provides a builder for configuring message type handlers for an . /// +/// +/// Override the method to customize the routing of messages to handlers. By +/// default, uses reflection to find implementations of and +/// . +/// public class RouteBuilder { private readonly Dictionary _typedHandlers = new(); @@ -44,13 +49,6 @@ internal RouteBuilder AddHandler(Type messageType, MessageHandlerF handler, bool return this; } - /// - /// . - /// - /// - /// - /// - /// internal RouteBuilder AddHandler(Type type, Func handler, bool overwrite = false) { Throw.IfNull(handler); @@ -64,13 +62,6 @@ async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx } } - /// - /// . - /// - /// - /// - /// - /// internal RouteBuilder AddHandler(Type type, Func> handler, bool overwrite = false) { Throw.IfNull(handler); @@ -85,12 +76,18 @@ async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx } /// - /// . + /// Registers a handler for messages of the specified input type in the workflow route. /// + /// If a handler for the specified input type already exists and is + /// , the existing handler will not be replaced. Handlers are invoked asynchronously and are + /// expected to complete their processing before the workflow continues. /// - /// - /// - /// + /// A delegate that processes messages of type within the workflow context. The + /// delegate is invoked for each incoming message of the specified type. + /// to replace any existing handler for the specified input type; otherwise, to preserve the existing handler. + /// The current instance, enabling fluent configuration of additional handlers or route + /// options. public RouteBuilder AddHandler(Func handler, bool overwrite = false) { Throw.IfNull(handler); @@ -105,13 +102,18 @@ async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx } /// - /// . + /// Registers a handler function for messages of the specified input type in the workflow route. /// - /// - /// - /// - /// - /// + /// If a handler for the given input type already exists, setting to + /// will replace the existing handler; otherwise, an exception may be thrown. The handler + /// receives the input message and workflow context, and returns a result asynchronously. + /// The type of input message the handler will process. + /// The type of result produced by the handler. + /// A function that processes messages of type within the workflow context and returns + /// a representing the asynchronous result. + /// to replace any existing handler for the input type; otherwise, to + /// preserve existing handlers. + /// The current instance, enabling fluent configuration of workflow routes. public RouteBuilder AddHandler(Func> handler, bool overwrite = false) { Throw.IfNull(handler); diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs index d26ee4f12f..0376119559 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs @@ -8,54 +8,61 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// . +/// A class that represents a workflow that can be executed. /// public class Workflow { /// - /// . + /// A dictionary of executor providers, keyed by executor ID. /// public Dictionary> ExecutorProviders { get; internal init; } = new(); /// - /// . + /// Gets the collection of edges grouped by their source node identifier. /// public Dictionary> Edges { get; internal init; } = new(); /// - /// . + /// Gets the collection of external request ports, keyed by their ID. /// + /// + /// Each port has a corresponding entry in the dictionary. + /// public Dictionary Ports { get; internal init; } = new(); /// - /// . + /// Gets the identifier of the starting executor of the workflow. /// public string StartExecutorId { get; } /// - /// . + /// Gets the type of input expected by the starting executor of the workflow. /// public Type InputType { get; } + /// + /// Initializes a new instance of the class with the specified starting executor identifier + /// and input type. + /// + /// The unique identifier of the starting executor for the workflow. Cannot be null. + /// The representing the input data for the workflow. Cannot be null. internal Workflow(string startExecutorId, Type type) { this.StartExecutorId = Throw.IfNull(startExecutorId); this.InputType = Throw.IfNull(type); - - // TODO: How do we (1) ensure the types are happy, and (2) work under AOT? } } /// -/// . +/// Represents a workflow that operates on data of type . /// -/// +/// The type of input to the workflow. public class Workflow : Workflow { /// - /// . + /// Initializes a new instance of the class with the specified starting executor identifier /// - /// + /// The unique identifier of the starting executor for the workflow. Cannot be null. public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) { } @@ -74,10 +81,11 @@ internal Workflow Promote(OutputSink outputSource) } /// -/// . +/// Represents a workflow that operates on data of type , resulting in +/// . /// -/// -/// +/// The type of input to the workflow. +/// The type of the output from the workflow. public class Workflow : Workflow { private readonly OutputSink _output; @@ -89,7 +97,7 @@ internal Workflow(string startExecutorId, OutputSink outputSource) } /// - /// . + /// The running (partial) output of the workflow, if any. /// public TResult? RunningOutput => this._output.Result; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithOutput.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithOutput.cs new file mode 100644 index 0000000000..032fe9e944 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithOutput.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows.Execution; + +internal interface IRunnerWithOutput +{ + ISuperStepRunner StepRunner { get; } + + TResult? RunningOutput { get; } +} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs deleted file mode 100644 index 0d8a8ff422..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithResult.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Execution; - -internal interface IRunnerWithResult -{ - ISuperStepRunner StepRunner { get; } - - ValueTask GetResultAsync(CancellationToken cancellation = default); -} diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs index 54158616dc..729b95bedb 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs @@ -11,15 +11,22 @@ namespace Microsoft.Agents.Workflows.Execution; /// -/// . +/// Provides a local, in-process runner for executing a workflow using the specified input type. /// -/// +/// enables step-by-step execution of a workflow graph entirely +/// within the current process, without distributed coordination. It is primarily intended for testing, debugging, or +/// scenarios where workflow execution does not require executor distribution. +/// The type of input accepted by the workflow. Must be non-nullable. public class LocalRunner : ISuperStepRunner where TInput : notnull { /// - /// . + /// Initializes a new instance of the class to execute the specified workflow + /// locally. /// - /// + /// The manages the execution context and edge mapping for the + /// provided workflow, enabling local, in-process execution. The workflow's structure, including its edges and + /// ports, is used to set up the runner's internal state. + /// The workflow to be executed. Must not be null. public LocalRunner(Workflow workflow) { this.Workflow = Throw.IfNull(workflow); @@ -77,11 +84,15 @@ private bool IsResponse(object message) } /// - /// . + /// Initiates an asynchronous streaming execution using the specified input. /// - /// - /// - /// + /// The returned provides methods to observe and control + /// the ongoing streaming execution. The operation will continue until the streaming execution is finished or + /// cancelled. + /// The input message to be processed as part of the streaming execution. + /// A that can be used to cancel the streaming operation. + /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming execution. public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) { await this.RunContext.AddExternalMessageAsync(input).ConfigureAwait(false); @@ -146,19 +157,25 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) } /// -/// . +/// Provides a local, in-process runner for executing a workflow with input and producing a result. /// -/// -/// -public class LocalRunner : IRunnerWithResult where TInput : notnull +/// manages the execution of a instance locally, allowing for streaming input and asynchronous result retrieval. +/// This class is intended for scenarios where workflow execution does not require distributed procesing. +/// It supports streaming execution and exposes methods to retrieve the final result asynchronously. +/// +/// The type of input accepted by the workflow. Must be non-nullable. +/// The type of output produced by the workflow. +public class LocalRunner : IRunnerWithOutput where TInput : notnull { private readonly Workflow _workflow; private readonly ISuperStepRunner _innerRunner; /// - /// . + /// Initializes a new instance of the class to execute the specified + /// workflow locally. /// - /// + /// The workflow to be executed. Must not be null. public LocalRunner(Workflow workflow) { this._workflow = Throw.IfNull(workflow); @@ -166,11 +183,15 @@ public LocalRunner(Workflow workflow) } /// - /// . + /// Initiates an asynchronous streaming execution for the specified input. /// - /// - /// - /// + /// The returned can be used to retrieve results + /// as they become available. If the operation is cancelled via the token, the + /// streaming execution will be terminated. + /// The input value to be processed by the streaming execution. + /// A that can be used to cancel the streaming operation. + /// A that provides access to the results of the streaming + /// execution. public async ValueTask> StreamAsync(TInput input, CancellationToken cancellation = default) { await this._innerRunner.EnqueueMessageAsync(input).ConfigureAwait(false); @@ -178,21 +199,8 @@ public async ValueTask> StreamAsync(TInput inp return new StreamingExecutionHandle(this); } - /// - /// . - /// - /// - /// - public ValueTask GetResultAsync(CancellationToken cancellation = default) - { - // TODO: Block on finishing consuming StreamAsync()? - return new ValueTask(this.RunningOutput!); - } - - /// - /// . - /// + /// public TResult? RunningOutput => this._workflow.RunningOutput; - ISuperStepRunner IRunnerWithResult.StepRunner => this._innerRunner; + ISuperStepRunner IRunnerWithOutput.StepRunner => this._innerRunner; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs index ad6fafe741..71ccc6d1d1 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs @@ -35,8 +35,6 @@ public async ValueTask EnsureExecutorAsync(string executorId) this._executors[executorId] = executor = provider(); - await executor.InitializeAsync(this.Bind(executor.Id)).ConfigureAwait(false); - if (executor is RequestInputExecutor requestInputExecutor) { requestInputExecutor.AttachRequestSink(this); diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs index 47d5b24ecd..6a93989a85 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs @@ -11,7 +11,8 @@ namespace Microsoft.Agents.Workflows.Execution; /// -/// . +/// Provides a handle for managing and interacting with a streaming workflow execution, enabling asynchronous response +/// delivery and event monitoring. /// public class StreamingExecutionHandle { @@ -24,11 +25,13 @@ internal StreamingExecutionHandle(ISuperStepRunner stepRunner) } /// - /// . + /// Asynchronously sends the specified response to the external system and signals completion of the current + /// response wait operation. /// - /// - /// - /// + /// The response will be queued for processing for the next superstep. + /// The to send. Must not be null. + /// A that represents the asynchronous send operation. The task completes when the response + /// has been enqueued for processing, but will not wait for processing to complete. public ValueTask SendResponseAsync(ExternalResponse response) { this._waitForResponseSource?.TrySetResult(new()); @@ -37,11 +40,15 @@ public ValueTask SendResponseAsync(ExternalResponse response) } /// - /// . + /// Asynchronously streams workflow events as they occur during workflow execution. /// - /// - /// - /// + /// This method yields instances in real time as the workflow + /// progresses. The stream completes when a is encountered. Events are + /// delivered in the order they are raised. + /// A that can be used to cancel the streaming operation. If cancellation is + /// requested, the stream will end and no further events will be yielded. + /// An asynchronous stream of objects representing significant workflow state changes. + /// The stream ends when the workflow completes or when cancellation is requested. public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancellation] CancellationToken cancellation = default) { List eventSink = new(); @@ -103,33 +110,25 @@ void OnWorkflowEvent(object? sender, WorkflowEvent e) } /// -/// . +/// Represents a handle for managing and retrieving the result of a streaming execution operation. /// /// public class StreamingExecutionHandle : StreamingExecutionHandle { - private readonly IRunnerWithResult _resultSource; + private readonly IRunnerWithOutput _resultSource; - internal StreamingExecutionHandle(IRunnerWithResult runner) + internal StreamingExecutionHandle(IRunnerWithOutput runner) : base(Throw.IfNull(runner.StepRunner)) { this._resultSource = runner; } - /// - /// . - /// - /// - /// - /// - public ValueTask GetResultAsync(CancellationToken cancellation = default) - { - return this._resultSource.GetResultAsync(cancellation); - } + /// + public TResult? RunningOutput => this._resultSource.RunningOutput; } /// -/// . +/// Provides extension methods for processing and executing workflows using streaming execution handles. /// public static class ExecutionHandleExtensions { @@ -141,10 +140,9 @@ public static class ExecutionHandleExtensions /// non- response, the response is sent back to the workflow using the handle. /// The representing the workflow execution stream to monitor. /// An optional callback function invoked for each received from the stream. - /// The /// callback can return a response object to be sent back to the workflow, or if no response + /// The callback can return a response object to be sent back to the workflow, or if no response /// is required. - /// A to observe while waiting for events. Defaults to . + /// A to observe while waiting for events. /// A that represents the asynchronous operation. The task completes when the workflow /// execution stream is fully processed. public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) @@ -169,20 +167,18 @@ public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle /// is provided, it will be invoked for each event emitted during the workflow's /// execution, allowing for custom event handling. /// The type of the result produced by the workflow. - /// The representing the workflow to execute. This parameter cannot - /// be . - /// An optional callback function that is invoked for each emitted during the workflow - /// execution. The callback can process the event and return an object, or if no processing - /// is required. - /// A that can be used to cancel the workflow execution. The default value is . - /// A that represents the asynchronous operation. The task's result is the final + /// The representing the workflow to execute. + /// An optional callback function that is invoked for each + /// emitted during execution. The callback can process the event and return an object, or + /// if no response is required. + /// A that can be used to cancel the workflow execution. + /// A that represents the asynchronous operation. The task's result is the final /// result of the workflow execution. public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) { Throw.IfNull(handle); await handle.RunToCompletionAsync(eventCallback, cancellation).ConfigureAwait(false); - return await handle.GetResultAsync(cancellation).ConfigureAwait(false); + return handle.RunningOutput!; } } diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs index 1a1bfb193e..428fec3491 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs @@ -9,7 +9,8 @@ namespace Microsoft.Agents.Workflows; /// -/// . +/// A tagged union representing an object that can function like an in a , +/// or a reference to one by ID. /// public sealed class ExecutorIsh : IIdentified, @@ -18,31 +19,30 @@ public sealed class ExecutorIsh : IEquatable { /// - /// . + /// The type of the . /// public enum Type { /// - /// . + /// An unbound executor reference, identified only by ID. /// Unbound, /// - /// . + /// An actual instance. /// Executor, /// - /// . + /// An for servicing external requests. /// InputPort, /// - /// . + /// An instance. /// Agent, - //ProcessStep } /// - /// . + /// Gets the type of data contained in this instance. /// public Type ExecutorType { get; init; } @@ -52,9 +52,9 @@ public enum Type private readonly AIAgent? _aiAgentValue; /// - /// . + /// Initializes a new instance of the class as an unbound reference by ID. /// - /// + /// A unique identifier for an in the public ExecutorIsh(string id) { this.ExecutorType = Type.Unbound; @@ -62,9 +62,9 @@ public ExecutorIsh(string id) } /// - /// . + /// Initializes a new instance of the ExecutorIsh class using the specified executor. /// - /// + /// The executor instance to be wrapped. public ExecutorIsh(Executor executor) { this.ExecutorType = Type.Executor; @@ -72,9 +72,9 @@ public ExecutorIsh(Executor executor) } /// - /// . + /// Initializes a new instance of the ExecutorIsh class using the specified input port. /// - /// + /// The input port to associate to be wrapped. public ExecutorIsh(InputPort port) { this.ExecutorType = Type.InputPort; @@ -82,7 +82,7 @@ public ExecutorIsh(InputPort port) } /// - /// . + /// Initializes a new instance of the ExecutorIsh class using the specified AI agent. /// /// public ExecutorIsh(AIAgent aiAgent) @@ -100,12 +100,12 @@ public ExecutorIsh(AIAgent aiAgent) Type.Executor => this._executorValue!.Id, Type.InputPort => this._inputPortValue!.Id, Type.Agent => this._aiAgentValue!.Id, - //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") }; /// - /// . + /// Gets an that can be used to obtain an instance + /// corresponding to this . /// public ExecutorProvider ExecutorProvider => this.ExecutorType switch { @@ -113,32 +113,31 @@ public ExecutorIsh(AIAgent aiAgent) Type.Executor => () => this._executorValue!, Type.InputPort => () => new RequestInputExecutor(this._inputPortValue!), Type.Agent => () => new AIAgentHostExecutor(this._aiAgentValue!), - //Type.ProcessStep => throw new NotImplementedException("ProcessStep type is not yet implemented."), _ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}") }; /// - /// . + /// Defines an implicit conversion from an instance to an object. /// - /// + /// The instance to convert to . public static implicit operator ExecutorIsh(Executor executor) => new(executor); /// - /// . + /// Defines an implicit conversion from an to an instance. /// - /// + /// The to convert to an . public static implicit operator ExecutorIsh(InputPort inputPort) => new(inputPort); /// - /// . + /// Defines an implicit conversion from an to an instance. /// - /// + /// The to convert to an . public static implicit operator ExecutorIsh(AIAgent aiAgent) => new(aiAgent); /// - /// . + /// Defines an implicit conversion from a string to an instance. /// - /// + /// The string ID to convert to an . public static implicit operator ExecutorIsh(string id) { return new ExecutorIsh(id); @@ -204,7 +203,6 @@ public override string ToString() Type.Executor => $"'{this.Id}':{this._executorValue!.GetType().Name}", Type.InputPort => $"'{this.Id}':Input({this._inputPortValue!.Request.Name}->{this._inputPortValue!.Response.Name})", Type.Agent => $"{this.Id}':AIAgent(@{this._aiAgentValue!.GetType().Name})", - //Type.ProcessStep => $"ExecutorIsh for ProcessStep with ID '{this.Id}'", _ => $"'{this.Id}':" }; } diff --git a/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs b/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs index b90048a43a..4a767bb282 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs @@ -6,28 +6,36 @@ namespace Microsoft.Agents.Workflows; /// -/// . +/// Represents a function that incrementally aggregates a sequence of input values, producing an updated result for each +/// input. /// -/// -/// -/// -/// -/// -public delegate TResult? StreamingAggregator(TInput input, TResult? runningResult); +/// The type of the input value to be aggregated. +/// The type of the aggregation result produced by the function. +/// The current input value to be incorporated into the aggregation. +/// The current aggregated result, or null if this is the first input. +/// The updated aggregation result after processing the input value, or null if no result can be produced. +public delegate TResult? StreamingAggregator(TInput input, TResult? runningResult); /// -/// . +/// Provides a set of streaming aggregation functions for processing sequences of input values in a stateful, +/// incremental manner. /// public static class StreamingAggregators { /// - /// . + /// Creates a streaming aggregator that returns the result of applying the specified conversion function to the + /// first input value, or a default value if no input is provided. /// - /// - /// - /// - /// - /// + /// Subsequent inputs after the first are ignored by the aggregator. This method is useful for + /// scenarios where only the first occurrence in a stream is relevant. The conversion function is invoked at most + /// once. + /// The type of the input elements to be aggregated. + /// The type of the result produced by the conversion function. + /// A function that converts an input value of type to a result of type . This function is applied to the first input received. + /// The value to return if no input is provided. + /// A that yields the converted result of the first input, or the + /// specified default value if no input is received. public static StreamingAggregator First(Func conversion, TResult? defaultValue = default) { bool hasRun = false; @@ -47,22 +55,26 @@ public static StreamingAggregator First(Func - /// . + /// Creates a streaming aggregator that returns the first input element, or a specified default value if no elements + /// are provided. /// - /// - /// - /// + /// The type of the input elements to aggregate. + /// The value to return if the input sequence contains no elements. + /// A that yields the first input element, or if the sequence is empty. public static StreamingAggregator First(TInput? defaultValue = default) => First(input => input, defaultValue); /// - /// . + /// Creates a streaming aggregator that returns the result of applying the specified conversion to the most recent + /// input value. /// - /// - /// - /// - /// - /// + /// The type of the input elements to be aggregated. + /// The type of the result produced by the conversion function. + /// A function that converts each input value to a result. Cannot be null. + /// The initial result value to use before any input is processed. + /// A streaming aggregator that yields the converted value of the last input received, or the specified default + /// value if no input has been processed. public static StreamingAggregator Last(Func conversion, TResult? defaultValue = default) { TResult? local = defaultValue; @@ -77,21 +89,25 @@ public static StreamingAggregator Last(Func - /// . + /// Creates a streaming aggregator that returns the last element in a sequence, or a specified default value if the + /// sequence is empty. /// - /// - /// - /// + /// The type of elements in the input sequence. + /// The value to return if the input sequence contains no elements. + /// A that yields the last element of the sequence, or if the sequence is empty. public static StreamingAggregator Last(TInput? defaultValue = default) => Last(input => input, defaultValue); /// - /// . + /// Creates a streaming aggregator that produces the union of results by applying a conversion function to each + /// input and accumulating the results. /// - /// - /// - /// - /// + /// The type of the input elements to be aggregated. + /// The type of the result elements produced by the conversion function. + /// A function that converts each input element to a result element to be included in the union. + /// A streaming aggregator that, for each input, returns an enumerable containing all result elements produced so + /// far. public static StreamingAggregator> Union(Func conversion) { List results = new(); @@ -106,10 +122,13 @@ IEnumerable Aggregate(TInput input, IEnumerable? runningResult } /// - /// . + /// Creates a streaming aggregator that produces the union of all input sequences of type TInput. /// - /// - /// + /// The resulting aggregator combines all input sequences into a single sequence containing + /// distinct elements. The order of elements in the output sequence is not guaranteed. + /// The type of the elements in the input sequences to be aggregated. + /// A StreamingAggregator that, when applied to multiple input sequences, returns an IEnumerable containing the + /// union of all elements from those sequences. public static StreamingAggregator> Union() => Union(input => input); } diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index cb96b72c78..e7084b2895 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -24,8 +24,13 @@ public delegate TExecutor ExecutorProvider() where TExecutor : Executor; /// -/// . +/// Provides a builder for constructing and configuring a workflow by defining executors and the connections between +/// them. /// +/// Use the WorkflowBuilder to incrementally add executors and edges, including fan-in and fan-out +/// patterns, before building a strongly-typed workflow instance. Executors must be bound before building the workflow. +/// All executors must be bound by calling into if they were intially specified as +/// . public class WorkflowBuilder { private record struct EdgeId(string SourceId, string TargetId) @@ -42,9 +47,9 @@ private record struct EdgeId(string SourceId, string TargetId) private readonly string _startExecutorId; /// - /// . + /// Initializes a new instance of the WorkflowBuilder class with the specified starting executor. /// - /// + /// The executor that defines the starting point of the workflow. Cannot be null. public WorkflowBuilder(ExecutorIsh start) { this._startExecutorId = this.Track(start).Id; @@ -83,11 +88,11 @@ private void UpdateExecutor(string id, ExecutorProvider provider) } /// - /// . + /// Binds the specified executor to the workflow, allowing it to participate in workflow execution. /// - /// - /// - /// + /// The executor instance to bind. The executor must exist in the workflow and not be already bound. + /// The current instance, enabling fluent configuration. + /// Thrown if the specified executor is already bound or does not exist in the workflow. public WorkflowBuilder BindExecutor(Executor executor) { if (!this._unboundExecutors.Contains(executor.Id)) @@ -114,13 +119,16 @@ private HashSet EnsureEdgesFor(string sourceId) } /// - /// . + /// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a + /// condition. /// - /// - /// - /// - /// - /// + /// The executor that acts as the source node of the edge. Cannot be null. + /// The executor that acts as the target node of the edge. Cannot be null. + /// An optional predicate that determines whether the edge should be followed based on the input. + /// If null, the edge is always activated when the source sends a message. + /// The current instance of . + /// Thrown if an unconditional edge between the specified source and target + /// executors already exists. public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func? condition = null) { // Add an edge from source to target with an optional condition. @@ -144,12 +152,16 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func - /// . + /// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a + /// custom partitioning function. /// - /// - /// - /// - /// + /// If a partitioner function is provided, it will be used to distribute input across the target + /// executors. The order of targets determines their mapping in the partitioning process. + /// The source executor from which the fan-out edge originates. Cannot be null. + /// An optional function that determines how input is partitioned among the target executors. + /// If null, messages will route to all targets. + /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. + /// The current instance of . public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func>? partitioner = null, params ExecutorIsh[] targets) { Throw.IfNull(source); @@ -165,13 +177,18 @@ public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func - /// . + /// Adds a fan-in edge to the workflow, connecting multiple source executors to a single target executor with an + /// optional trigger condition. /// - /// - /// - /// - /// - public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = default, params ExecutorIsh[] sources) + /// This method establishes a fan-in relationship, allowing the target executor to be activated + /// based on the completion or state of multiple sources. The trigger parameter can be used to customize activation + /// behavior. + /// The target executor that receives input from the specified source executors. Cannot be null. + /// An optional trigger condition that determines when the fan-in edge activates. Defaults to + /// . + /// One or more source executors that provide input to the target. Cannot be null or empty. + /// The current instance of . + public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = FanInTrigger.WhenAll, params ExecutorIsh[] sources) { Throw.IfNull(target); Throw.IfNullOrEmpty(sources); @@ -190,11 +207,13 @@ public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = d } /// - /// . + /// Builds and returns a workflow instance configured to process messages of the specified input type. /// - /// - /// - /// + /// The type of input messages that the workflow will accept and process. + /// A new instance of . + /// Thrown if there are unbound executors in the workflow definition, + /// if the start executor is not bound, or if the start executor does not contain a handler for the specified input + /// type . public Workflow Build() { if (this._unboundExecutors.Count > 0) diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs index 69894710fa..c765bc53ca 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs @@ -9,18 +9,24 @@ namespace Microsoft.Agents.Workflows; /// -/// . +/// Provides extension methods for configuring and building workflows using the WorkflowBuilder type. /// +/// These extension methods simplify the process of connecting executors, adding external calls, and +/// constructing workflows with output aggregation. They are intended to streamline workflow graph construction and +/// promote common patterns for chaining and aggregating workflow steps. public static class WorkflowBuilderExtensions { /// - /// . + /// Adds a sequential chain of executors to the workflow, connecting each executor in order so that each is + /// executed after the previous one. /// - /// - /// - /// - /// - /// + /// Each executor in the chain is connected so that execution flows from the source to each subsequent + /// executor in the order provided. + /// The workflow builder to which the executor chain will be added. + /// The initial executor in the chain. Cannot be null. + /// An ordered array of executors to be added to the chain after the source. + /// The original workflow builder instance with the specified executor chain added. + /// Thrown if there is a cycle in the chain. public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params ExecutorIsh[] executors) { Throw.IfNull(builder); @@ -47,14 +53,18 @@ public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh } /// - /// . + /// Adds an external call to the workflow by connecting the specified source to a new input port with the given + /// request and response types. /// - /// - /// - /// - /// - /// - /// + /// This method creates a bidirectional connection between the source and the new input port, + /// allowing the workflow to send requests and receive responses through the specified external call. The port is + /// configured to handle messages of the specified request and response types. + /// The type of the request message that the external call will accept. + /// The type of the response message that the external call will produce. + /// The workflow builder to which the external call will be added. + /// The source executor representing the external system or process to connect. Cannot be null. + /// The unique identifier for the input port that will handle the external call. Cannot be null. + /// The original workflow builder instance with the external call added. public static WorkflowBuilder AddExternalCall(this WorkflowBuilder builder, ExecutorIsh source, string portId) { Throw.IfNull(builder); @@ -67,15 +77,21 @@ public static WorkflowBuilder AddExternalCall(this Workflow } /// - /// . + /// Builds a workflow that collects output from the specified executor, aggregates results using the provided + /// streaming aggregator, and optionally completes based on a custom condition. /// - /// - /// - /// - /// - /// - /// - /// + /// The returned workflow promotes the output collector as its result source, allowing consumers + /// to access the aggregated output directly. The completion condition can be used to implement custom termination + /// logic, such as early stopping when a desired result is reached. + /// The type of input items processed by the workflow. + /// The type of aggregated result produced by the workflow. + /// The workflow builder used to construct the workflow and define its execution graph. + /// The executor that produces output items to be collected and aggregated. Cannot be null. + /// The streaming aggregator that processes input items and produces aggregated results. Cannot be null. + /// An optional predicate that determines when the workflow should complete based on the current input and + /// aggregated result. If null, the workflow will not raise a . + /// A workflow that collects output from the specified executor, aggregates results, and exposes the aggregated + /// output. public static Workflow BuildWithOutput( this WorkflowBuilder builder, ExecutorIsh outputSource, From 17063f85a318db75a7272554914cd4e2594d3c61 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 14:00:39 -0400 Subject: [PATCH 127/232] refactor: Normalize UnitTest and Sample namespaces --- .../ReflectionSmokeTest.cs | 2 +- .../Sample/05_Simple_Workflow_ExternalRequest.cs | 4 +--- .../Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs index ae6cb303a1..5430e30d94 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs @@ -5,7 +5,7 @@ using Microsoft.Agents.Workflows.Core; using Moq; -namespace Microsoft.Agents.Orchestration.UnitTest; +namespace Microsoft.Agents.Workflows.UnitTests; public class BaseTestExecutor : Executor { diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs index ca2de73632..548790cc27 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs @@ -3,12 +3,10 @@ using System; using System.IO; using System.Threading.Tasks; -using Microsoft.Agents.Workflows; using Microsoft.Agents.Workflows.Core; using Microsoft.Agents.Workflows.Execution; -using Microsoft.Agents.Workflows.Sample; -namespace Microsoft.Agents.Workflow.UnitTests.Sample; +namespace Microsoft.Agents.Workflows.Sample; internal static class Step5EntryPoint { diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs index 7ec53c5756..12fe7f8f37 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs @@ -4,10 +4,9 @@ using System.IO; using System.Linq; using System.Threading.Tasks; -using Microsoft.Agents.Workflow.UnitTests.Sample; using Microsoft.Agents.Workflows.Sample; -namespace Microsoft.Agents.Workflow.UnitTests; +namespace Microsoft.Agents.Workflows.UnitTests; public class SampleSmokeTest { From 1d65380d5ae1ac9c5384d34bc9cd88d907814946 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 14:21:28 -0400 Subject: [PATCH 128/232] fix: Formatting --- dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs | 4 ++-- dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs | 7 ------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs b/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs index 0769d0012e..4334a0651d 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs @@ -1,11 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using Microsoft.Shared.Diagnostics; -using PredicateT = System.Func; using PartitionerT = System.Func>; -using System; +using PredicateT = System.Func; namespace Microsoft.Agents.Workflows.Core; diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs index e7084b2895..da80121a36 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs @@ -1,17 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. -#pragma warning disable IDE0005 // Using directive is unnecessary. using System; using System.Collections.Generic; using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Execution; using Microsoft.Agents.Workflows.Core; using Microsoft.Shared.Diagnostics; -using System.Collections.Concurrent; - -#pragma warning restore IDE0005 // Using directive is unnecessary. namespace Microsoft.Agents.Workflows; From 4188cbee3754ca7c5723c3941f684991c9dd052a Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 15:15:03 -0400 Subject: [PATCH 129/232] refactor: Normalize project/folder names --- dotnet/agent-framework-dotnet.slnx | 4 ++-- .../Core/CallResult.cs | 0 .../Core/Edge.cs | 0 .../Core/Events.cs | 0 .../Core/Executor.cs | 0 .../Core/ExternalRequest.cs | 0 .../Core/ExternalResponse.cs | 0 .../Core/IIdentified.cs | 0 .../Core/IMessageHandler.cs | 0 .../Core/IMessageRouter.cs | 0 .../Core/IWorkflowContext.cs | 0 .../Core/InputPort.cs | 0 .../Core/Message.cs | 0 .../Core/MessageHandlerInfo.cs | 0 .../Core/MessageRouter.cs | 0 .../Core/RouteBuilder.cs | 0 .../Core/RouteBuilderExtensions.cs | 0 .../Core/StreamsMessageAttribute.cs | 0 .../Core/ValueTaskTypeErasure.cs | 0 .../Core/Workflow.cs | 0 .../Execution/DirectEdgeRunner.cs | 0 .../Execution/EdgeMap.cs | 0 .../Execution/EdgeRunner.cs | 0 .../Execution/ExecutorIdentity.cs | 0 .../Execution/FanInEdgeRunner.cs | 0 .../Execution/FanInEdgeState.cs | 0 .../Execution/FanOutEdgeRunner.cs | 0 .../Execution/IExternalRequestSink.cs | 0 .../Execution/IRunnerContext.cs | 0 .../Execution/IRunnerWithOutput.cs | 0 .../Execution/ISuperStepRunner.cs | 0 .../Execution/InputEdgeRunner.cs | 0 .../Execution/LocalRunner.cs | 0 .../Execution/LocalRunnerContext.cs | 0 .../Execution/StepContext.cs | 0 .../Execution/StreamingExecutionHandle.cs | 0 .../ExecutorIsh.cs | 0 .../Microsoft.Agents.Workflows.csproj | 2 +- .../Specialized/AIAgentHostExecutor.cs | 0 .../Specialized/OutputCollectorExecutor.cs | 0 .../Specialized/RequestInputExecutor.cs | 0 .../StreamingAggregators.cs | 0 .../WorkflowBuilder.cs | 0 .../WorkflowBuilderExtensions.cs | 0 .../Microsoft.Agents.Workflows.UnitTests.csproj} | 2 +- .../ReflectionSmokeTest.cs | 0 .../Sample/01_Simple_Workflow_Sequential.cs | 0 .../Sample/02_Simple_Workflow_Condition.cs | 0 .../Sample/03_Simple_Workflow_Loop.cs | 0 .../Sample/05_Simple_Workflow_ExternalRequest.cs | 0 .../SampleSmokeTest.cs | 0 51 files changed, 4 insertions(+), 4 deletions(-) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/CallResult.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/Edge.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/Events.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/Executor.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/ExternalRequest.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/ExternalResponse.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/IIdentified.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/IMessageHandler.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/IMessageRouter.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/IWorkflowContext.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/InputPort.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/Message.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/MessageHandlerInfo.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/MessageRouter.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/RouteBuilder.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/RouteBuilderExtensions.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/StreamsMessageAttribute.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/ValueTaskTypeErasure.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Core/Workflow.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/DirectEdgeRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/EdgeMap.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/EdgeRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/ExecutorIdentity.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/FanInEdgeRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/FanInEdgeState.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/FanOutEdgeRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/IExternalRequestSink.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/IRunnerContext.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/IRunnerWithOutput.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/ISuperStepRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/InputEdgeRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/LocalRunner.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/LocalRunnerContext.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/StepContext.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Execution/StreamingExecutionHandle.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/ExecutorIsh.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Microsoft.Agents.Workflows.csproj (94%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Specialized/AIAgentHostExecutor.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Specialized/OutputCollectorExecutor.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/Specialized/RequestInputExecutor.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/StreamingAggregators.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/WorkflowBuilder.cs (100%) rename dotnet/src/{Microsoft.Agents.Workflow => Microsoft.Agents.Workflows}/WorkflowBuilderExtensions.cs (100%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj => Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj} (92%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests => Microsoft.Agents.Workflows.UnitTests}/ReflectionSmokeTest.cs (100%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests => Microsoft.Agents.Workflows.UnitTests}/Sample/01_Simple_Workflow_Sequential.cs (100%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests => Microsoft.Agents.Workflows.UnitTests}/Sample/02_Simple_Workflow_Condition.cs (100%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests => Microsoft.Agents.Workflows.UnitTests}/Sample/03_Simple_Workflow_Loop.cs (100%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests => Microsoft.Agents.Workflows.UnitTests}/Sample/05_Simple_Workflow_ExternalRequest.cs (100%) rename dotnet/tests/{Microsoft.Agents.Workflow.UnitTests => Microsoft.Agents.Workflows.UnitTests}/SampleSmokeTest.cs (100%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index e79922db8d..253364f376 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -116,7 +116,7 @@ - + @@ -129,7 +129,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/CallResult.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/Edge.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/Events.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/Executor.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ExternalRequest.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/ExternalRequest.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/ExternalRequest.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ExternalResponse.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/ExternalResponse.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/ExternalResponse.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IIdentified.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IIdentified.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/IIdentified.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/IIdentified.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IMessageHandler.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/IMessageHandler.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/IMessageHandler.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IMessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IMessageRouter.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/IMessageRouter.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/IMessageRouter.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/IWorkflowContext.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/InputPort.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/InputPort.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/InputPort.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Message.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/Message.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/Message.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandlerInfo.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/MessageHandlerInfo.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageRouter.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/MessageRouter.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/MessageRouter.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilder.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/RouteBuilderExtensions.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/StreamsMessageAttribute.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/StreamsMessageAttribute.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/StreamsMessageAttribute.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/ValueTaskTypeErasure.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/ValueTaskTypeErasure.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Core/Workflow.cs rename to dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/DirectEdgeRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/DirectEdgeRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/DirectEdgeRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/EdgeMap.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeMap.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/EdgeMap.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/EdgeRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/EdgeRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/EdgeRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutorIdentity.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/ExecutorIdentity.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/ExecutorIdentity.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/ExecutorIdentity.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeState.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeState.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/FanInEdgeState.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeState.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/FanOutEdgeRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IExternalRequestSink.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/IExternalRequestSink.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/IExternalRequestSink.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/IExternalRequestSink.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerContext.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerContext.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerContext.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithOutput.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerWithOutput.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/IRunnerWithOutput.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerWithOutput.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/ISuperStepRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/ISuperStepRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/ISuperStepRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/InputEdgeRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/InputEdgeRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/InputEdgeRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunner.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/LocalRunnerContext.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StepContext.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/StepContext.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/StepContext.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingExecutionHandle.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Execution/StreamingExecutionHandle.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingExecutionHandle.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/ExecutorIsh.cs rename to dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj b/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj similarity index 94% rename from dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj rename to dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj index 65f8a0dfab..4cd49dd698 100644 --- a/dotnet/src/Microsoft.Agents.Workflow/Microsoft.Agents.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj @@ -26,7 +26,7 @@ - + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Specialized/AIAgentHostExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Specialized/OutputCollectorExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInputExecutor.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/Specialized/RequestInputExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInputExecutor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs b/dotnet/src/Microsoft.Agents.Workflows/StreamingAggregators.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/StreamingAggregators.cs rename to dotnet/src/Microsoft.Agents.Workflows/StreamingAggregators.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilder.cs rename to dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs diff --git a/dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.Workflow/WorkflowBuilderExtensions.cs rename to dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj similarity index 92% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj index d384557d8f..006de84807 100644 --- a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Microsoft.Agents.Workflow.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj @@ -6,7 +6,7 @@ - + diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/ReflectionSmokeTest.cs rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/01_Simple_Workflow_Sequential.cs rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/02_Simple_Workflow_Condition.cs rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/03_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/03_Simple_Workflow_Loop.cs rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs similarity index 100% rename from dotnet/tests/Microsoft.Agents.Workflow.UnitTests/SampleSmokeTest.cs rename to dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs From c2cfc50ecf3f41270ce890054913616e0b41a612 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 16:20:25 -0400 Subject: [PATCH 130/232] feat: Remove DynamicCodeExecution from ValueTaskTypeErasure --- .../Core/ReflectionExtensions.cs | 45 ++++++++++ .../Core/ValueTaskTypeErasure.cs | 88 +++++++++++-------- 2 files changed, 97 insertions(+), 36 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs new file mode 100644 index 0000000000..8ec77241db --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics; +using System.Reflection; + +#if !NET +using System.Linq; +#endif + +namespace Microsoft.Agents.Workflows.Core; + +internal static class ReflectionExtensions +{ + public static object? ReflectionInvoke(this MethodInfo method, object? target, params object?[] arguments) + { +#if NET + return method.Invoke(target, BindingFlags.DoNotWrapExceptions, binder: null, arguments, culture: null); +#else + try + { + return method.Invoke(target, BindingFlags.Default, binder: null, arguments, culture: null); + } + catch (TargetInvocationException e) when (e.InnerException is not null) + { + // If we're targeting .NET Framework, such that BindingFlags.DoNotWrapExceptions + // is ignored, the original exception will be wrapped in a TargetInvocationException. + // Unwrap it and throw that original exception, maintaining its stack information. + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(e.InnerException).Throw(); + throw; + } +#endif + } + + public static MethodInfo GetMethodFromGenericMethodDefinition(this Type specializedType, MethodInfo genericMethodDefinition) + { + Debug.Assert(specializedType.IsGenericType && specializedType.GetGenericTypeDefinition() == genericMethodDefinition.DeclaringType, "generic member definition doesn't match type."); +#if NET + return (MethodInfo)specializedType.GetMemberWithSameMetadataDefinitionAs(genericMethodDefinition); +#else + const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; + return specializedType.GetMethods(All).First(m => m.MetadataToken == genericMethodDefinition.MetadataToken); +#endif + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs index 8166a7a5c6..238fc95fef 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs @@ -1,60 +1,76 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics; using System.Reflection; using System.Threading.Tasks; namespace Microsoft.Agents.Workflows.Core; +internal static class ValueTaskReflection +{ + private const string Nameof_AsTask = nameof(ValueTask.AsTask); + internal static readonly MethodInfo AsTask = typeof(ValueTask<>).GetMethod(Nameof_AsTask, BindingFlags.Public | BindingFlags.Instance)!; + + internal static MethodInfo ReflectAsTask(this Type specializedType) + { + Debug.Assert(specializedType.IsGenericType && + specializedType.GetGenericTypeDefinition() == typeof(ValueTask<>), "specializedType must be a ValueTask<> type."); + + return specializedType.GetMethodFromGenericMethodDefinition(AsTask); + } + + internal static bool IsValueTaskType(this Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>); +} + +internal static class TaskReflection +{ + private const string Nameof_Result = nameof(Task.Result); + internal static readonly MethodInfo Result_get = typeof(Task<>).GetProperty(Nameof_Result)!.GetMethod!; + + internal static MethodInfo ReflectResult_get(this Type specializedType) + { + Debug.Assert(specializedType.IsGenericType && + specializedType.GetGenericTypeDefinition() == typeof(Task<>), "specializedType must be a ValueTask<> type."); + + return specializedType.GetMethodFromGenericMethodDefinition(Result_get); + } + + internal static bool IsTaskType(this Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Task<>); +} + internal static class ValueTaskTypeErasure { - internal static Func> CreateErasingUnwrapper() + internal static Func> UnwrapperFor(Type expectedResultType) { return UnwrapAndEraseAsync; - static async ValueTask UnwrapAndEraseAsync(object maybeValueTask) + async ValueTask UnwrapAndEraseAsync(object maybeGenericVT) { - if (maybeValueTask is ValueTask vt) + // This method handles only ValueTask types. + Type maybeVTType = maybeGenericVT.GetType(); + + if (!maybeVTType.IsValueTaskType()) { - // If the input is a ValueTask, unwrap it. - TResult result = await vt.ConfigureAwait(false); - return (object?)result; + throw new InvalidOperationException($"Expected ValueTask or ValueTask<{expectedResultType.Name}>, but got {maybeGenericVT.GetType().Name}."); } - throw new InvalidOperationException($"Expected ValueTask or ValueTask<{typeof(TResult).Name}>, but got {maybeValueTask.GetType().Name}."); - } - } - -#if NET5_0_OR_GREATER - // This suppression is qualified because for some reason VS is not recognizing the attribute's presence, treating the - // import as an error (due to unnecessary using). - [System.Diagnostics.CodeAnalysis.RequiresDynamicCode("Calls System.Reflection.MethodInfo.MakeGenericMethod(params Type[])")] -#endif - internal static Func> UnwrapperFor(Type resultType) - { - // This method creates a type-erased unwrapper for ValueTask. - // It uses reflection to create a delegate that can handle any TResult type. + MethodInfo asTaskMethod = maybeVTType.ReflectAsTask(); + Debug.Assert(asTaskMethod.ReturnType.IsTaskType(), "AsTask must return a Task<> type."); - // TODO: AOT: This method is marked with RequiresDynamicCodeAttribute, which will not work well in NativeAOT - // scenarios; the solution is to break this up into a Cached/Reflector version (like the MessageRouter does - // with handlers), and SourceGenerate the UnwrapAndEraseAsync-equivalent method for each TResult type. + MethodInfo getResultMethod = asTaskMethod.ReturnType.ReflectResult_get(); + Type actualResultType = getResultMethod.ReturnType; - // Note that this is only necessary because ValueTask is a class-generic, rather than an interface - // type, which means that the type cannot be co/contravariantly used (e.g. ValueTask is not a valid - // supertype of ValueTask or ValueTask, T != object?). + if (!expectedResultType.IsAssignableFrom(actualResultType)) + { + throw new InvalidOperationException($"Expected ValueTask<{expectedResultType.Name}> or a compatible type, but got ValueTask<{actualResultType.Name}>."); + } - MethodInfo createMethod = - typeof(ValueTaskTypeErasure) - .GetMethod(nameof(CreateErasingUnwrapper), BindingFlags.NonPublic | BindingFlags.Static) - !.MakeGenericMethod(resultType); + Task task = (Task)asTaskMethod.ReflectionInvoke(maybeGenericVT)!; + await task.ConfigureAwait(false); // TODO: Could we need to capture the context here? + object? result = getResultMethod.ReflectionInvoke(task); - // Invoke createMethod (as static) to get the delegate. - object? maybeUnwrapper = createMethod.Invoke(null, Array.Empty()); - if (maybeUnwrapper is not Func> unwrapper) - { - throw new InvalidOperationException($"Expected a Func> delegate, but got {maybeUnwrapper?.GetType().Name ?? "null"}."); + return result; } - - return unwrapper; } } From 1d217a488668e20a4d51dea6cef859f639d97b6e Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 16:34:11 -0400 Subject: [PATCH 131/232] fix: Fix ILTrim warnings --- .../Core/RouteBuilderExtensions.cs | 106 +++++++++++++----- 1 file changed, 76 insertions(+), 30 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs index 2beadc3ec5..fc4c3c9845 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs @@ -3,23 +3,56 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Reflection; using Microsoft.Shared.Diagnostics; +#if NET9_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif + namespace Microsoft.Agents.Workflows.Core; +internal static class IMessageHandlerReflection +{ + private const string Nameof_HandleAsync = nameof(IMessageHandler.HandleAsync); + internal static readonly MethodInfo HandleAsync_1 = typeof(IMessageHandler<>).GetMethod(Nameof_HandleAsync, BindingFlags.Public | BindingFlags.Instance)!; + internal static readonly MethodInfo HandleAsync_2 = typeof(IMessageHandler<,>).GetMethod(Nameof_HandleAsync, BindingFlags.Public | BindingFlags.Instance)!; + + internal static MethodInfo ReflectHandleAsync(this Type specializedType, int genericArgumentCount) + { + Debug.Assert(specializedType.IsGenericType && + (specializedType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || + specializedType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>)), + "specializedType must be an IMessageHandler<> or IMessageHandler<,> type."); + return genericArgumentCount switch + { + 1 => specializedType.GetMethodFromGenericMethodDefinition(HandleAsync_1), + 2 => specializedType.GetMethodFromGenericMethodDefinition(HandleAsync_2), + _ => throw new ArgumentOutOfRangeException(nameof(genericArgumentCount), "Must be 1 or 2.") + }; + } + + internal static int GenericArgumentCount(this Type type) + { + Debug.Assert(type.IsMessageHandlerType(), "type must be an IMessageHandler<> or IMessageHandler<,> type."); + return type.GetGenericArguments().Length; + } + + internal static bool IsMessageHandlerType(this Type type) => + type.IsGenericType && + (type.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || + type.GetGenericTypeDefinition() == typeof(IMessageHandler<,>)); +} + internal static class RouteBuilderExtensions { - [SuppressMessage("Trimming", - "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value " + - "of the source method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - [SuppressMessage("Trimming", - "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of " + - "method does not have matching annotations.", - Justification = "Trimming attributes are inaccessible in 472")] - private static IEnumerable GetHandlerInfos(this Type executorType) + private static IEnumerable GetHandlerInfos( +#if NET9_0_OR_GREATER + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.Interfaces)] +#endif + this Type executorType) { // Handlers are defined by implementations of IMessageHandler or IMessageHandler Debug.Assert(typeof(Executor).IsAssignableFrom(executorType), "executorType must be an Executor type."); @@ -27,33 +60,37 @@ private static IEnumerable GetHandlerInfos(this Type executo foreach (Type interfaceType in executorType.GetInterfaces()) { // Check if the interface is a message handler. - if (!interfaceType.IsGenericType) + if (!interfaceType.IsMessageHandlerType()) { continue; } - if (interfaceType.IsGenericType && (interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || - interfaceType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>))) + // Get the generic arguments of the interface. + Type[] genericArguments = interfaceType.GetGenericArguments(); + if (genericArguments.Length < 1 || genericArguments.Length > 2) + { + continue; // Invalid handler signature. + } + Type inType = genericArguments[0]; + Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; + + MethodInfo? method = interfaceType.ReflectHandleAsync(genericArguments.Length); + + if (method != null) { - // Get the generic arguments of the interface. - Type[] genericArguments = interfaceType.GetGenericArguments(); - if (genericArguments.Length < 1 || genericArguments.Length > 2) - { - continue; // Invalid handler signature. - } - Type inType = genericArguments[0]; - Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; - MethodInfo? method = interfaceType.GetMethod("HandleAsync"); - - if (method != null) - { - yield return new MessageHandlerInfo(method) { InType = inType, OutType = outType }; - } + yield return new MessageHandlerInfo(method) { InType = inType, OutType = outType }; } } } - public static RouteBuilder ReflectHandlers(this RouteBuilder builder, Type executorType, Executor executor) + public static RouteBuilder ReflectHandlers(this RouteBuilder builder, +#if NET9_0_OR_GREATER + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.Interfaces)] +#endif + Type executorType, + Executor executor) { Throw.IfNull(builder); Throw.IfNull(executorType); @@ -68,6 +105,15 @@ public static RouteBuilder ReflectHandlers(this RouteBuilder builder, Type execu return builder; } - public static RouteBuilder ReflectHandlers(this RouteBuilder builder, TExecutor executor) where TExecutor : Executor - => builder.ReflectHandlers(executor.GetType(), executor); + public static RouteBuilder ReflectHandlers< +#if NET9_0_OR_GREATER + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.Interfaces)] +#endif + TExecutor + >(this RouteBuilder builder, TExecutor executor) + { + return builder.ReflectHandlers(typeof(TExecutor), (Executor)(object)executor!); + } } From f5979d4b7878feede78108f79a0e0127e8429b1f Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 16:39:58 -0400 Subject: [PATCH 132/232] docs: Add missing docs and fix typos --- .../Microsoft.Agents.Workflows/Core/CallResult.cs | 2 +- dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs index 482a228e1a..79df6aba82 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs @@ -41,7 +41,7 @@ private CallResult(bool isVoid = false) } /// - /// Create a indicating a successful that returned a result (non-void). + /// Create a indicating a successful call that returned a result (non-void). /// /// The result to return. /// A indicating the result of the call. diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs index 4334a0651d..878ac98a09 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs @@ -22,9 +22,9 @@ public record DirectEdgeData( PredicateT? Condition = null) { /// - /// Converts a instance to an using an implicit conversion. + /// Converts a instance to an . /// - /// The to convert to an . Cannot be null. + /// The to convert t. public static implicit operator Edge(DirectEdgeData data) { return new Edge(Throw.IfNull(data)); @@ -44,9 +44,9 @@ public record FanOutEdgeData( PartitionerT? Partitioner = null) { /// - /// . + /// Converts a instance to an . /// - /// + /// The to convert. public static implicit operator Edge(FanOutEdgeData data) { return new Edge(data); @@ -85,9 +85,9 @@ public record FanInEdgeData( internal Guid UniqueKey { get; } = Guid.NewGuid(); /// - /// . + /// Converts a instance to an . /// - /// + /// The to convert. public static implicit operator Edge(FanInEdgeData data) { return new Edge(data); From 45e75ab057145c73d25abe5416be64e3a9149aa3 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 16:46:31 -0400 Subject: [PATCH 133/232] feat: Hosted Agents should report Run events --- .../Microsoft.Agents.Workflows/Core/Events.cs | 49 +++---------------- .../Specialized/AIAgentHostExecutor.cs | 1 + 2 files changed, 8 insertions(+), 42 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs index 1bbf5150a5..06d2759d53 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Extensions.AI.Agents; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; @@ -77,44 +78,8 @@ public record ExecutorCompleteEvent : ExecutorEvent public ExecutorCompleteEvent(string executorId, object? result = null) : base(executorId, result) { } } -// TODO: This is a placeholder for streaming chat message content. /// -/// . -/// -public class StreamingChatMessageContent -{ } - -/// -/// . -/// -public record AgentRunStreamingEvent : ExecutorEvent -{ - /// - /// Initializes a new instance of the class. - /// - /// The identifier of the executor that generated this event. - /// - public AgentRunStreamingEvent(string executorId, StreamingChatMessageContent? content = null) : base(executorId, data: content) - { - this.Content = content; - } - - /// - /// Gets the content of the streaming chat message. - /// - public StreamingChatMessageContent? Content { get; } -} - -// TODO: This is a placeholder for non-streaming chat message content. -/// -/// . -/// -public class ChatMessageContent -{ -} - -/// -/// . +/// Event triggered when an agent run is completed. /// public record AgentRunEvent : ExecutorEvent { @@ -122,14 +87,14 @@ public record AgentRunEvent : ExecutorEvent /// Initializes a new instance of the class. /// /// The identifier of the executor that generated this event. - /// - public AgentRunEvent(string executorId, ChatMessageContent? content = null) : base(executorId, data: content) + /// + public AgentRunEvent(string executorId, AgentRunResponse? response = null) : base(executorId, data: response) { - this.Content = content; + this.Response = response; } /// - /// Gets the content of the chat message. + /// Gets the content of the agent response. /// - public ChatMessageContent? Content { get; } + public AgentRunResponse? Response { get; } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs index 2f156d9191..15129858a6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs @@ -26,6 +26,7 @@ public async ValueTask HandleAsync(IList message, IWorkflowContext // incremental updates from the chat model. AgentRunResponse runResponse = await this.Agent.RunAsync(messageList).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunEvent(this.Id, runResponse)).ConfigureAwait(false); await context.SendMessageAsync(runResponse).ConfigureAwait(false); } } From 9becc976e89f9a7a46dced91cc0c5d474ca033a4 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 Aug 2025 17:17:19 -0400 Subject: [PATCH 134/232] fix: Fix type propagation for ILTrim changes --- .../Microsoft.Agents.Workflows/Core/Events.cs | 4 +-- .../Core/Executor.cs | 34 ++++++++++++++---- .../Core/IWorkflowContext.cs | 2 +- .../Core/MessageHandlerInfo.cs | 9 +++-- .../Core/RouteBuilder.cs | 4 +-- .../Core/RouteBuilderExtensions.cs | 36 +++++-------------- .../Core/Workflow.cs | 8 ++--- .../Execution/DirectEdgeRunner.cs | 4 +-- .../Execution/FanInEdgeRunner.cs | 4 +-- .../Execution/FanOutEdgeRunner.cs | 4 +-- .../Execution/IRunnerContext.cs | 2 +- .../Execution/InputEdgeRunner.cs | 4 +-- .../Execution/LocalRunnerContext.cs | 6 ++-- .../Microsoft.Agents.Workflows/ExecutorIsh.cs | 18 +++++----- .../Microsoft.Agents.Workflows.csproj | 2 ++ .../Specialized/AIAgentHostExecutor.cs | 2 +- .../Specialized/OutputCollectorExecutor.cs | 14 ++++---- .../Specialized/RequestInputExecutor.cs | 2 +- .../WorkflowBuilder.cs | 14 ++++---- .../ReflectionSmokeTest.cs | 16 ++++----- .../Sample/01_Simple_Workflow_Sequential.cs | 4 +-- .../Sample/02_Simple_Workflow_Condition.cs | 6 ++-- .../Sample/03_Simple_Workflow_Loop.cs | 4 +-- 23 files changed, 107 insertions(+), 96 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs index 06d2759d53..0581b12ca6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs @@ -19,7 +19,7 @@ public record WorkflowStartedEvent : WorkflowEvent; /// Event triggered when a workflow completes execution. /// /// -/// The user is expected to raise this event from a terminating , or to build +/// The user is expected to raise this event from a terminating , or to build /// the workflow with output capture using . /// public record WorkflowCompletedEvent : WorkflowEvent; @@ -30,7 +30,7 @@ public record WorkflowCompletedEvent : WorkflowEvent; public record RequestInputEvent(ExternalRequest Request) : WorkflowEvent; /// -/// Base class for -scoped events. +/// Base class for -scoped events. /// public record ExecutorEvent : WorkflowEvent { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs index 8081bb9af3..284f656a2d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Threading.Tasks; @@ -12,7 +13,7 @@ namespace Microsoft.Agents.Workflows.Core; /// A component that processes messages in a . /// [DebuggerDisplay("{GetType().Name}{Id}")] -public abstract class Executor : IIdentified, IAsyncDisposable +public abstract class ExecutorBase : IIdentified, IAsyncDisposable { /// /// A unique identifier for the executor. @@ -26,7 +27,7 @@ public abstract class Executor : IIdentified, IAsyncDisposable /// /// A optional unique identifier for the executor. If null, a type-tagged /// UUID will be generated. - protected Executor(string? id = null) + protected ExecutorBase(string? id = null) { this.Id = id ?? $"{this.GetType().Name}/{Guid.NewGuid():N}"; } @@ -35,10 +36,7 @@ protected Executor(string? id = null) /// Override this method to register handlers for the executor. The deafult implementation uses reflection to /// look for implementations of and . /// - protected virtual RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) - { - return routeBuilder.ReflectHandlers(this); - } + protected abstract RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder); private MessageRouter? _router = null; internal MessageRouter Router @@ -145,3 +143,27 @@ ValueTask IAsyncDisposable.DisposeAsync() return this.DisposeAsync(); } } + +/// +/// A component that processes messages in a . +/// +/// The actual type of the . +/// This is used to reflectively discover handlers for messages without violating ILTrim requirements. +/// +public class Executor< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.Interfaces)] +TExecutor + > : ExecutorBase where TExecutor : Executor +{ + /// + protected Executor(string? id = null) : base(id) + { } + + /// + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + return routeBuilder.ReflectHandlers(this); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs index bf5528db9c..49495ca19f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs @@ -5,7 +5,7 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// Provides services for an during the execution of a workflow. +/// Provides services for an during the execution of a workflow. /// public interface IWorkflowContext { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs index da55f649c2..2b9d55fca9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs @@ -17,8 +17,6 @@ internal struct MessageHandlerInfo public MethodInfo HandlerInfo { get; init; } public Func>? Unwrapper { get; init; } = null; - [SuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality " + - "when AOT compiling.", Justification = "")] public MessageHandlerInfo(MethodInfo handlerInfo) { // The method is one of the following: @@ -118,7 +116,12 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext } } - public Func> Bind(Executor executor, bool checkType = false) + public Func> Bind< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.Interfaces)] TExecutor> + (Executor executor, bool checkType = false) + where TExecutor : Executor { MethodInfo handlerMethod = this.HandlerInfo; return MessageHandlerInfo.Bind(InvokeHandler, checkType, this.OutType, this.Unwrapper); diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs index 467a374bfa..b7d5bc22b5 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs @@ -15,10 +15,10 @@ namespace Microsoft.Agents.Workflows.Core; /// -/// Provides a builder for configuring message type handlers for an . +/// Provides a builder for configuring message type handlers for an . /// /// -/// Override the method to customize the routing of messages to handlers. By +/// Override the method to customize the routing of messages to handlers. By /// default, uses reflection to find implementations of and /// . /// diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs index fc4c3c9845..91d594686e 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs @@ -3,13 +3,10 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using Microsoft.Shared.Diagnostics; -#if NET9_0_OR_GREATER -using System.Diagnostics.CodeAnalysis; -#endif - namespace Microsoft.Agents.Workflows.Core; internal static class IMessageHandlerReflection @@ -47,15 +44,13 @@ internal static bool IsMessageHandlerType(this Type type) => internal static class RouteBuilderExtensions { private static IEnumerable GetHandlerInfos( -#if NET9_0_OR_GREATER [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods | DynamicallyAccessedMemberTypes.Interfaces)] -#endif this Type executorType) { // Handlers are defined by implementations of IMessageHandler or IMessageHandler - Debug.Assert(typeof(Executor).IsAssignableFrom(executorType), "executorType must be an Executor type."); + Debug.Assert(typeof(ExecutorBase).IsAssignableFrom(executorType), "executorType must be an Executor type."); foreach (Type interfaceType in executorType.GetInterfaces()) { @@ -83,19 +78,18 @@ private static IEnumerable GetHandlerInfos( } } - public static RouteBuilder ReflectHandlers(this RouteBuilder builder, -#if NET9_0_OR_GREATER + public static RouteBuilder ReflectHandlers< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods | - DynamicallyAccessedMemberTypes.Interfaces)] -#endif - Type executorType, - Executor executor) + DynamicallyAccessedMemberTypes.Interfaces)] TExecutor> + (this RouteBuilder builder, Executor executor) + where TExecutor : Executor { Throw.IfNull(builder); - Throw.IfNull(executorType); - Debug.Assert(typeof(Executor).IsAssignableFrom(executorType), "executorType must be an Executor type."); + Type executorType = typeof(TExecutor); + Debug.Assert(executorType.IsAssignableFrom(executor.GetType()), + "executorType must be the same type or a base type of the executor instance."); foreach (MessageHandlerInfo handlerInfo in executorType.GetHandlerInfos()) { @@ -104,16 +98,4 @@ public static RouteBuilder ReflectHandlers(this RouteBuilder builder, return builder; } - - public static RouteBuilder ReflectHandlers< -#if NET9_0_OR_GREATER - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | - DynamicallyAccessedMemberTypes.NonPublicMethods | - DynamicallyAccessedMemberTypes.Interfaces)] -#endif - TExecutor - >(this RouteBuilder builder, TExecutor executor) - { - return builder.ReflectHandlers(typeof(TExecutor), (Executor)(object)executor!); - } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs index 0376119559..bf7c5ca3d0 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs @@ -15,7 +15,7 @@ public class Workflow /// /// A dictionary of executor providers, keyed by executor ID. /// - public Dictionary> ExecutorProviders { get; internal init; } = new(); + public Dictionary> ExecutorProviders { get; internal init; } = new(); /// /// Gets the collection of edges grouped by their source node identifier. @@ -67,7 +67,7 @@ public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) { } - internal Workflow Promote(OutputSink outputSource) + internal Workflow Promote(IOutputSink outputSource) { Throw.IfNull(outputSource); @@ -88,9 +88,9 @@ internal Workflow Promote(OutputSink outputSource) /// The type of the output from the workflow. public class Workflow : Workflow { - private readonly OutputSink _output; + private readonly IOutputSink _output; - internal Workflow(string startExecutorId, OutputSink outputSource) + internal Workflow(string startExecutorId, IOutputSink outputSource) : base(startExecutorId) { this._output = Throw.IfNull(outputSource); diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/DirectEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/DirectEdgeRunner.cs index df70b2b620..03ba90dbaf 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/DirectEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/DirectEdgeRunner.cs @@ -11,7 +11,7 @@ internal class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData edgeDa { public IWorkflowContext WorkflowContext { get; } = runContext.Bind(edgeData.SinkId); - private async ValueTask FindRouterAsync() + private async ValueTask FindRouterAsync() { return await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) .ConfigureAwait(false); @@ -24,7 +24,7 @@ private async ValueTask FindRouterAsync() return []; } - Executor target = await this.FindRouterAsync().ConfigureAwait(false); + ExecutorBase target = await this.FindRouterAsync().ConfigureAwait(false); if (target.CanHandle(message.GetType())) { return [await target.ExecuteAsync(message, this.WorkflowContext).ConfigureAwait(false)]; diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeRunner.cs index 9b790bd0e6..ed7847a1c4 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeRunner.cs @@ -22,8 +22,8 @@ internal class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData edgeData return null; } - Executor target = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) - .ConfigureAwait(false); + ExecutorBase target = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId) + .ConfigureAwait(false); if (target.CanHandle(message.GetType())) { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs index 7a21accf8b..e8508b90c9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs @@ -27,8 +27,8 @@ internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeDa async Task ProcessTargetAsync(string targetId) { - Executor executor = await this.RunContext.EnsureExecutorAsync(targetId) - .ConfigureAwait(false); + ExecutorBase executor = await this.RunContext.EnsureExecutorAsync(targetId) + .ConfigureAwait(false); if (executor.CanHandle(message.GetType())) { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerContext.cs index 692abdf3af..59d9afdb02 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerContext.cs @@ -14,5 +14,5 @@ internal interface IRunnerContext : IExternalRequestSink StepContext Advance(); IWorkflowContext Bind(string executorId); - ValueTask EnsureExecutorAsync(string executorId); + ValueTask EnsureExecutorAsync(string executorId); } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/InputEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/InputEdgeRunner.cs index bfe002b9bd..19dd1a3be9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/InputEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/InputEdgeRunner.cs @@ -19,14 +19,14 @@ public static InputEdgeRunner ForPort(IRunnerContext runContext, InputPort port) return new InputEdgeRunner(runContext, port.Id); } - private async ValueTask FindExecutorAsync() + private async ValueTask FindExecutorAsync() { return await this.RunContext.EnsureExecutorAsync(this.EdgeData).ConfigureAwait(false); } public async ValueTask ChaseAsync(object message) { - Executor target = await this.FindExecutorAsync().ConfigureAwait(false); + ExecutorBase target = await this.FindExecutorAsync().ConfigureAwait(false); if (target.CanHandle(message.GetType())) { return await target.ExecuteAsync(message, this.WorkflowContext) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs index 71ccc6d1d1..7b4786cd22 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs @@ -15,8 +15,8 @@ namespace Microsoft.Agents.Workflows.Execution; internal class LocalRunnerContext : IRunnerContext { private StepContext _nextStep = new(); - private readonly Dictionary> _executorProviders; - private readonly Dictionary _executors = new(); + private readonly Dictionary> _executorProviders; + private readonly Dictionary _executors = new(); private readonly Dictionary _externalRequests = new(); public LocalRunnerContext(Workflow workflow, ILogger? logger = null) @@ -24,7 +24,7 @@ public LocalRunnerContext(Workflow workflow, ILogger? logger = null) this._executorProviders = Throw.IfNull(workflow).ExecutorProviders; } - public async ValueTask EnsureExecutorAsync(string executorId) + public async ValueTask EnsureExecutorAsync(string executorId) { if (!this._executors.TryGetValue(executorId, out var executor)) { diff --git a/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs index 428fec3491..7c11ca15ee 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.Workflows; /// -/// A tagged union representing an object that can function like an in a , +/// A tagged union representing an object that can function like an in a , /// or a reference to one by ID. /// public sealed class ExecutorIsh : @@ -47,14 +47,14 @@ public enum Type public Type ExecutorType { get; init; } private readonly string? _idValue; - private readonly Executor? _executorValue; + private readonly ExecutorBase? _executorValue; internal readonly InputPort? _inputPortValue; private readonly AIAgent? _aiAgentValue; /// /// Initializes a new instance of the class as an unbound reference by ID. /// - /// A unique identifier for an in the + /// A unique identifier for an in the public ExecutorIsh(string id) { this.ExecutorType = Type.Unbound; @@ -65,7 +65,7 @@ public ExecutorIsh(string id) /// Initializes a new instance of the ExecutorIsh class using the specified executor. /// /// The executor instance to be wrapped. - public ExecutorIsh(Executor executor) + public ExecutorIsh(ExecutorBase executor) { this.ExecutorType = Type.Executor; this._executorValue = Throw.IfNull(executor); @@ -104,10 +104,10 @@ public ExecutorIsh(AIAgent aiAgent) }; /// - /// Gets an that can be used to obtain an instance + /// Gets an that can be used to obtain an instance /// corresponding to this . /// - public ExecutorProvider ExecutorProvider => this.ExecutorType switch + public ExecutorProvider ExecutorProvider => this.ExecutorType switch { Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."), Type.Executor => () => this._executorValue!, @@ -117,10 +117,10 @@ public ExecutorIsh(AIAgent aiAgent) }; /// - /// Defines an implicit conversion from an instance to an object. + /// Defines an implicit conversion from an instance to an object. /// - /// The instance to convert to . - public static implicit operator ExecutorIsh(Executor executor) => new(executor); + /// The instance to convert to . + public static implicit operator ExecutorIsh(ExecutorBase executor) => new(executor); /// /// Defines an implicit conversion from an to an instance. diff --git a/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj b/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj index 4cd49dd698..4af22a5e8b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj @@ -9,6 +9,8 @@ true true + true + diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs index 15129858a6..ab29118d03 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.Workflows.Specialized; -internal class AIAgentHostExecutor : Executor, IMessageHandler> +internal class AIAgentHostExecutor : Executor, IMessageHandler> { private AIAgent Agent { get; set; } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs index a1a6099572..2cabca3bda 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs @@ -7,19 +7,21 @@ namespace Microsoft.Agents.Workflows.Specialized; -internal class OutputSink : Executor +internal interface IOutputSink { - public TResult? Result { get; protected set; } = default; - - internal OutputSink(string? id = null) : base(id) - { } + TResult? Result { get; } } -internal class OutputCollectorExecutor : OutputSink, IMessageHandler +internal class OutputCollectorExecutor : + Executor>, + IMessageHandler, + IOutputSink { private readonly StreamingAggregator _aggregator; private readonly Func? _completionCondition; + public TResult? Result { get; private set; } + public OutputCollectorExecutor(StreamingAggregator aggregator, Func? completionCondition = null, string? id = null) : base(id) { this._aggregator = Throw.IfNull(aggregator); diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInputExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInputExecutor.cs index 9cd3a8e2c9..20b5968639 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInputExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInputExecutor.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.Workflows.Specialized; -internal class RequestInputExecutor : Executor, IMessageHandler, IMessageHandler +internal class RequestInputExecutor : Executor, IMessageHandler, IMessageHandler { private InputPort Port { get; } private IExternalRequestSink? RequestSink { get; set; } diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs index da80121a36..e005d08caa 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs @@ -14,7 +14,7 @@ namespace Microsoft.Agents.Workflows; /// The executor type. /// A new instance. public delegate TExecutor ExecutorProvider() - where TExecutor : Executor; + where TExecutor : ExecutorBase; /// /// Provides a builder for constructing and configuring a workflow by defining executors and the connections between @@ -31,7 +31,7 @@ private record struct EdgeId(string SourceId, string TargetId) public override string ToString() => $"{this.SourceId} -> {this.TargetId}"; } - private readonly Dictionary> _executors = new(); + private readonly Dictionary> _executors = new(); private readonly Dictionary> _edges = new(); private readonly HashSet _unboundExecutors = new(); private readonly HashSet _conditionlessEdges = new(); @@ -50,7 +50,7 @@ public WorkflowBuilder(ExecutorIsh start) private ExecutorIsh Track(ExecutorIsh executorish) { - ExecutorProvider provider = executorish.ExecutorProvider; + ExecutorProvider provider = executorish.ExecutorProvider; // If the executor is unbound, create an entry for it, unless it already exists. // Otherwise, update the entry for it, and remove the unbound tag @@ -75,7 +75,7 @@ private ExecutorIsh Track(ExecutorIsh executorish) return executorish; } - private void UpdateExecutor(string id, ExecutorProvider provider) + private void UpdateExecutor(string id, ExecutorProvider provider) { this._executors[id] = provider; } @@ -86,7 +86,7 @@ private void UpdateExecutor(string id, ExecutorProvider provider) /// The executor instance to bind. The executor must exist in the workflow and not be already bound. /// The current instance, enabling fluent configuration. /// Thrown if the specified executor is already bound or does not exist in the workflow. - public WorkflowBuilder BindExecutor(Executor executor) + public WorkflowBuilder BindExecutor(ExecutorBase executor) { if (!this._unboundExecutors.Contains(executor.Id)) { @@ -216,14 +216,14 @@ public Workflow Build() } // Grab the start node, and make sure it has the right type? - if (!this._executors.TryGetValue(this._startExecutorId, out ExecutorProvider? startProvider)) + if (!this._executors.TryGetValue(this._startExecutorId, out ExecutorProvider? startProvider)) { // TODO: This should never be able to be hit throw new InvalidOperationException($"Start executor with ID '{this._startExecutorId}' is not bound."); } // TODO: Delay-instantiate the start executor, and ensure it is of type T. - Executor startExecutor = startProvider(); + ExecutorBase startExecutor = startProvider(); if (!startExecutor.InputTypes.Any(t => t.IsAssignableFrom(typeof(T)))) { diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs index 5430e30d94..fde763b3b0 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.Workflows.UnitTests; -public class BaseTestExecutor : Executor +public class BaseTestExecutor : Executor where TActual : Executor { protected void OnInvokedHandler() { @@ -21,7 +21,7 @@ public bool InvokedHandler } = false; } -public class DefaultHandler : BaseTestExecutor, IMessageHandler +public class DefaultHandler : BaseTestExecutor, IMessageHandler { public ValueTask HandleAsync(object message, IWorkflowContext context) { @@ -36,7 +36,7 @@ public Func Handler } = (message, context) => default; } -public class TypedHandler : BaseTestExecutor, IMessageHandler +public class TypedHandler : BaseTestExecutor>, IMessageHandler { public ValueTask HandleAsync(TInput message, IWorkflowContext context) { @@ -51,7 +51,7 @@ public Func Handler } = (message, context) => default; } -public class TypedHandlerWithOutput : BaseTestExecutor, IMessageHandler +public class TypedHandlerWithOutput : BaseTestExecutor>, IMessageHandler { public ValueTask HandleAsync(TInput message, IWorkflowContext context) { @@ -67,7 +67,7 @@ public Func> Handler public class RoutingReflectionTests { - private async ValueTask RunTestReflectAndRouteMessageAsync(BaseTestExecutor executor, TInput? input = default) where TInput : new() + private async ValueTask RunTestReflectAndRouteMessageAsync(BaseTestExecutor executor, TInput? input = default) where TInput : new() where TE : Executor { MessageRouter router = executor.Router; @@ -88,7 +88,7 @@ public async Task Test_ReflectAndExecute_DefaultHandlerAsync() { DefaultHandler executor = new(); - CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor); + CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor); Assert.NotNull(result); Assert.True(result.IsSuccess); @@ -102,7 +102,7 @@ public async Task Test_ReflectAndExecute_HandlerReturnsVoidAsync() { TypedHandler executor = new(); - CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor, 3); + CallResult? result = await this.RunTestReflectAndRouteMessageAsync>(executor, 3); Assert.NotNull(result); Assert.True(result.IsSuccess); @@ -123,7 +123,7 @@ public async Task Test_ReflectAndExecute_HandlerReturnsValueAsync() }; const string Expected = "3"; - CallResult? result = await this.RunTestReflectAndRouteMessageAsync(executor, int.Parse(Expected)); + CallResult? result = await this.RunTestReflectAndRouteMessageAsync>(executor, int.Parse(Expected)); Assert.NotNull(result); Assert.True(result.IsSuccess); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs index 5af83b752a..cf74362684 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs @@ -32,7 +32,7 @@ public static async ValueTask RunAsync(TextWriter writer) } } -internal sealed class UppercaseExecutor() : Executor("UppercaseExecutor"), IMessageHandler +internal sealed class UppercaseExecutor() : Executor("UppercaseExecutor"), IMessageHandler { public async ValueTask HandleAsync(string message, IWorkflowContext context) { @@ -43,7 +43,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext cont } } -internal sealed class ReverseTextExecutor() : Executor("ReverseTextExecutor"), IMessageHandler +internal sealed class ReverseTextExecutor() : Executor("ReverseTextExecutor"), IMessageHandler { public async ValueTask HandleAsync(string message, IWorkflowContext context) { diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs index c6f52d7166..62365d6beb 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs @@ -46,7 +46,7 @@ public static async ValueTask RunAsync(TextWriter writer, string input = } } -internal sealed class DetectSpamExecutor : Executor, IMessageHandler +internal sealed class DetectSpamExecutor : Executor, IMessageHandler { public string[] SpamKeywords { get; } @@ -68,7 +68,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext contex } } -internal sealed class RespondToMessageExecutor : Executor, IMessageHandler +internal sealed class RespondToMessageExecutor : Executor, IMessageHandler { public const string ActionResult = "Message processed successfully."; @@ -87,7 +87,7 @@ await context.AddEventAsync(new WorkflowCompletedEvent { Data = RespondToMessage } } -internal sealed class RemoveSpamExecutor : Executor, IMessageHandler +internal sealed class RemoveSpamExecutor : Executor, IMessageHandler { public const string ActionResult = "Spam message removed."; diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs index ead24833e4..5bfabb92f2 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs @@ -50,7 +50,7 @@ internal enum NumberSignal Matched } -internal sealed class GuessNumberExecutor : Executor, IMessageHandler +internal sealed class GuessNumberExecutor : Executor, IMessageHandler { public int LowerBound { get; private set; } public int UpperBound { get; private set; } @@ -87,7 +87,7 @@ await context.AddEventAsync(new WorkflowCompletedEvent { Data = $"Guessed the nu } } -internal sealed class JudgeExecutor : Executor, IMessageHandler +internal sealed class JudgeExecutor : Executor, IMessageHandler { private readonly int _targetNumber; From ed9f6b2ad1d84a40c2cc53e47911082e81f1303d Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Fri, 8 Aug 2025 11:15:08 -0400 Subject: [PATCH 135/232] refactor: Simplify DynamicallyAccessedMembers annotations --- dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs | 7 +++---- .../Core/MessageHandlerInfo.cs | 7 ++++--- .../Core/ReflectionExtensions.cs | 9 +++++++++ .../Core/RouteBuilderExtensions.cs | 10 ++++------ 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs index 284f656a2d..c6a8f7825c 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs @@ -151,10 +151,9 @@ ValueTask IAsyncDisposable.DisposeAsync() /// This is used to reflectively discover handlers for messages without violating ILTrim requirements. /// public class Executor< - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | - DynamicallyAccessedMemberTypes.NonPublicMethods | - DynamicallyAccessedMemberTypes.Interfaces)] -TExecutor + [DynamicallyAccessedMembers( + ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation) + ] TExecutor > : ExecutorBase where TExecutor : Executor { /// diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs index 2b9d55fca9..07a41ef5aa 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs @@ -117,9 +117,10 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext } public Func> Bind< - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | - DynamicallyAccessedMemberTypes.NonPublicMethods | - DynamicallyAccessedMemberTypes.Interfaces)] TExecutor> + [DynamicallyAccessedMembers( + ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation) + ] TExecutor + > (Executor executor, bool checkType = false) where TExecutor : Executor { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs index 8ec77241db..30ceb72f61 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Reflection; #if !NET @@ -10,6 +11,14 @@ namespace Microsoft.Agents.Workflows.Core; +internal static class ReflectionDemands +{ + internal const DynamicallyAccessedMemberTypes ReflectedMethods = DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods; + internal const DynamicallyAccessedMemberTypes ReflectedInterfaces = DynamicallyAccessedMemberTypes.Interfaces; + + internal const DynamicallyAccessedMemberTypes RuntimeInterfaceDiscoveryAndInvocation = ReflectedMethods | ReflectedInterfaces; +} + internal static class ReflectionExtensions { public static object? ReflectionInvoke(this MethodInfo method, object? target, params object?[] arguments) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs index 91d594686e..deebf89599 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs @@ -44,9 +44,7 @@ internal static bool IsMessageHandlerType(this Type type) => internal static class RouteBuilderExtensions { private static IEnumerable GetHandlerInfos( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | - DynamicallyAccessedMemberTypes.NonPublicMethods | - DynamicallyAccessedMemberTypes.Interfaces)] + [DynamicallyAccessedMembers(ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation)] this Type executorType) { // Handlers are defined by implementations of IMessageHandler or IMessageHandler @@ -79,9 +77,9 @@ private static IEnumerable GetHandlerInfos( } public static RouteBuilder ReflectHandlers< - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | - DynamicallyAccessedMemberTypes.NonPublicMethods | - DynamicallyAccessedMemberTypes.Interfaces)] TExecutor> + [DynamicallyAccessedMembers( + ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation) + ] TExecutor> (this RouteBuilder builder, Executor executor) where TExecutor : Executor { From 52c69c63c09ea5195ee0c28f22c06ea3aa607dff Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Fri, 8 Aug 2025 11:15:53 -0400 Subject: [PATCH 136/232] sample: Use static-Type construction of InputPort --- .../Sample/05_Simple_Workflow_ExternalRequest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs index 548790cc27..10412f377a 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs @@ -12,7 +12,7 @@ internal static class Step5EntryPoint { public static async ValueTask RunAsync(TextWriter writer, Func userGuessCallback) { - InputPort guessNumber = new("GuessNumber", typeof(NumberSignal), typeof(int)); + InputPort guessNumber = InputPort.Create("GuessNumber"); JudgeExecutor judge = new(42); // Let's say the target number is 42 Workflow workflow = new WorkflowBuilder(guessNumber) From e5e933ee07089d12b3995ffa78b1d1ca5ee0887d Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Fri, 8 Aug 2025 12:23:18 -0400 Subject: [PATCH 137/232] feat: Support non-Streaming Run Mode --- .../Execution/LocalRunner.cs | 59 +++++-- .../Execution/Run.cs | 155 ++++++++++++++++++ ...mingExecutionHandle.cs => StreamingRun.cs} | 62 +++++-- .../Sample/02_Simple_Workflow_Condition.cs | 2 +- .../Sample/03_Simple_Workflow_Loop.cs | 2 +- .../05_Simple_Workflow_ExternalRequest.cs | 2 +- 6 files changed, 249 insertions(+), 33 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflows/Execution/Run.cs rename dotnet/src/Microsoft.Agents.Workflows/Execution/{StreamingExecutionHandle.cs => StreamingRun.cs} (74%) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs index 729b95bedb..3210169abc 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs @@ -86,24 +86,40 @@ private bool IsResponse(object message) /// /// Initiates an asynchronous streaming execution using the specified input. /// - /// The returned provides methods to observe and control + /// The returned provides methods to observe and control /// the ongoing streaming execution. The operation will continue until the streaming execution is finished or /// cancelled. - /// The input message to be processed as part of the streaming execution. + /// The input message to be processed as part of the streaming run. /// A that can be used to cancel the streaming operation. - /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming execution. - public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) + /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. + public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) { await this.RunContext.AddExternalMessageAsync(input).ConfigureAwait(false); - return new StreamingExecutionHandle(this); + return new StreamingRun(this); + } + + /// + /// Initiates a non-streaming execution of the workflow with the specified input. + /// + /// The workflow will run until its first halt, and the returned will capture + /// all outgoing events. Use the Run instance to resume execution with responses to outgoing events. + /// The input message to be processed as part of the run. + /// A that can be used to cancel the streaming operation. + /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. + public async ValueTask RunAsync(TInput input, CancellationToken cancellation = default) + { + StreamingRun streamingRun = await this.StreamAsync(input, cancellation).ConfigureAwait(false); + cancellation.ThrowIfCancellationRequested(); + + return await Run.CaptureStreamAsync(streamingRun, cancellation).ConfigureAwait(false); } bool ISuperStepRunner.HasUnservicedRequests => this.RunContext.HasUnservicedRequests; bool ISuperStepRunner.HasUnprocessedMessages => this.RunContext.NextStepHasActions; - //private StepContext? _currentStep = null; async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cancellation) { cancellation.ThrowIfCancellationRequested(); @@ -185,18 +201,35 @@ public LocalRunner(Workflow workflow) /// /// Initiates an asynchronous streaming execution for the specified input. /// - /// The returned can be used to retrieve results + /// The returned can be used to retrieve results /// as they become available. If the operation is cancelled via the token, the /// streaming execution will be terminated. - /// The input value to be processed by the streaming execution. + /// The input value to be processed by the streaming run. /// A that can be used to cancel the streaming operation. - /// A that provides access to the results of the streaming - /// execution. - public async ValueTask> StreamAsync(TInput input, CancellationToken cancellation = default) + /// A that provides access to the results of the streaming + /// run. + public async ValueTask> StreamAsync(TInput input, CancellationToken cancellation = default) { await this._innerRunner.EnqueueMessageAsync(input).ConfigureAwait(false); - return new StreamingExecutionHandle(this); + return new StreamingRun(this); + } + + /// + /// Initiates a non-streaming execution of the workflow with the specified input. + /// + /// The workflow will run until its first halt, and the returned will capture + /// all outgoing events. Use the Run instance to resume execution with responses to outgoing events. + /// The input message to be processed as part of the run. + /// A that can be used to cancel the streaming operation. + /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. + public async ValueTask RunAsync(TInput input, CancellationToken cancellation = default) + { + StreamingRun streamingRun = await this.StreamAsync(input, cancellation).ConfigureAwait(false); + cancellation.ThrowIfCancellationRequested(); + + return await Run.CaptureStreamAsync(streamingRun, cancellation).ConfigureAwait(false); } /// diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/Run.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/Run.cs new file mode 100644 index 0000000000..8da8ccceeb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/Run.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; + +namespace Microsoft.Agents.Workflows.Execution; + +/// +/// Specifies the current operational state of a workflow run. +/// +public enum RunStatus +{ + /// + /// The run has halted, has no outstanding requets, but has not received a . + /// + Idle, + + /// + /// The run has halted, and has at least one outstanding . + /// + PendingRequests, + + /// + /// The run has halted after receiving a . + /// + Completed, + + /// + /// The workflow is currently running, and may receive events or requests. + /// + Running +} + +/// +/// Represents a workflow run that tracks execution status and emitted workflow events, supporting resumption +/// with responses to . +/// +public class Run +{ + internal static async ValueTask CaptureStreamAsync(StreamingRun run, CancellationToken cancellation = default) + { + Run result = new(run); + await result.RunToNextHaltAsync(cancellation).ConfigureAwait(false); + return result; + } + + private readonly List _eventSink = new(); + private readonly StreamingRun _streamingRun; + internal Run(StreamingRun streamingRun) + { + this._streamingRun = streamingRun; + } + + internal async ValueTask RunToNextHaltAsync(CancellationToken cancellation = default) + { + bool hadEvents = false; + bool hadCompletion = false; + this.Status = RunStatus.Running; + await foreach (WorkflowEvent evt in this._streamingRun.WatchStreamAsync(blockOnPendingRequest: false, cancellation).ConfigureAwait(false)) + { + hadEvents = true; + if (evt is WorkflowCompletedEvent) + { + hadCompletion = true; + } + + this._eventSink.Add(evt); + } + + // TODO: bookmark every halt for history visualization? + + this.Status = + hadCompletion + ? RunStatus.Completed + : this._streamingRun.HasUnservicedRequests + ? RunStatus.PendingRequests + : RunStatus.Idle; + + return hadEvents; + } + + /// + /// Gets the current execution status of the workflow run. + /// + public RunStatus Status { get; private set; } + + /// + /// Gets all events emitted by the workflow. + /// + public IEnumerable OutgoingEvents => this._eventSink; + + private int _lastBookmark = 0; + + /// + /// Gets all events emitted by the workflow since the last access to . + /// + public IEnumerable NewEvents + { + get + { + if (this._lastBookmark >= this._eventSink.Count) + { + return []; + } + + int currentBookmark = this._lastBookmark; + this._lastBookmark = this._eventSink.Count; + + return this._eventSink.Skip(currentBookmark); + } + } + + /// + /// Resume execution of the workflow with the provided external responses. + /// + /// A that can be used to cancel the workflow execution. + /// An array of objects to send to the workflow. + /// true if the workflow had any output events, false otherwise. + public async ValueTask ResumeAsync(CancellationToken cancellation = default, params ExternalResponse[] responses) + { + foreach (ExternalResponse response in responses) + { + await this._streamingRun.SendResponseAsync(response).ConfigureAwait(false); + } + + return await this.RunToNextHaltAsync(cancellation).ConfigureAwait(false); + } +} + +/// +/// Represents a workflow run that tracks execution status and emitted workflow events, supporting resumption +/// with responses to , and retrieval of the running output of the workflow. +/// +/// The type of the workflow output. +public sealed class Run : Run +{ + internal static async ValueTask> CaptureStreamAsync(StreamingRun run, CancellationToken cancellation = default) + { + Run result = new(run); + await result.RunToNextHaltAsync(cancellation).ConfigureAwait(false); + return result; + } + + private readonly StreamingRun _streamingRun; + private Run(StreamingRun streamingRun) : base(streamingRun) + { + this._streamingRun = streamingRun; + } + + /// + public TResult? RunningOutput => this._streamingRun.RunningOutput; +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingExecutionHandle.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingRun.cs similarity index 74% rename from dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingExecutionHandle.cs rename to dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingRun.cs index 6a93989a85..dfb274d6e9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingExecutionHandle.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingRun.cs @@ -11,15 +11,21 @@ namespace Microsoft.Agents.Workflows.Execution; /// -/// Provides a handle for managing and interacting with a streaming workflow execution, enabling asynchronous response -/// delivery and event monitoring. +/// A run instance supporting a streaming form of receiving workflow events, and providing +/// a mechanism to send responses back to the workflow. /// -public class StreamingExecutionHandle +public class StreamingRun { private TaskCompletionSource? _waitForResponseSource = null; private readonly ISuperStepRunner _stepRunner; - internal StreamingExecutionHandle(ISuperStepRunner stepRunner) + /// + /// Gets a value indicating whether there are any outstanding s for which a + /// has not been sent. + /// + public bool HasUnservicedRequests => this._stepRunner.HasUnservicedRequests; + + internal StreamingRun(ISuperStepRunner stepRunner) { this._stepRunner = Throw.IfNull(stepRunner); } @@ -49,7 +55,13 @@ public ValueTask SendResponseAsync(ExternalResponse response) /// requested, the stream will end and no further events will be yielded. /// An asynchronous stream of objects representing significant workflow state changes. /// The stream ends when the workflow completes or when cancellation is requested. - public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancellation] CancellationToken cancellation = default) + public IAsyncEnumerable WatchStreamAsync( + CancellationToken cancellation = default) + => this.WatchStreamAsync(blockOnPendingRequest: true, cancellation); + + internal async IAsyncEnumerable WatchStreamAsync( + bool blockOnPendingRequest, + [EnumeratorCancellation] CancellationToken cancellation = default) { List eventSink = new(); @@ -61,6 +73,10 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell { // Drain SuperSteps while there are steps to run await this._stepRunner.RunSuperStepAsync(cancellation).ConfigureAwait(false); + if (cancellation.IsCancellationRequested) + { + yield break; // Exit if cancellation is requested + } bool hadCompletionEvent = false; List outputEvents = Interlocked.Exchange(ref eventSink, new()); @@ -68,6 +84,11 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell { yield return raisedEvent; + if (cancellation.IsCancellationRequested) + { + yield break; // Exit if cancellation is requested + } + // TODO: Do we actually want to interpret this as a termination request? if (raisedEvent is WorkflowCompletedEvent) { @@ -84,7 +105,8 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell // If we do not have any actions to take on the Workflow, but have unprocessed // requests, wait for the responses to come in before exiting out of the workflow // execution. - if (!this._stepRunner.HasUnprocessedMessages && + if (blockOnPendingRequest && + !this._stepRunner.HasUnprocessedMessages && this._stepRunner.HasUnservicedRequests) { if (this._waitForResponseSource == null) @@ -92,6 +114,11 @@ public async IAsyncEnumerable WatchStreamAsync([EnumeratorCancell this._waitForResponseSource = new(); } + using CancellationTokenRegistration registration = cancellation.Register(() => + { + this._waitForResponseSource?.SetResult(new()); + }); + await this._waitForResponseSource.Task.ConfigureAwait(false); this._waitForResponseSource = null; } @@ -110,14 +137,15 @@ void OnWorkflowEvent(object? sender, WorkflowEvent e) } /// -/// Represents a handle for managing and retrieving the result of a streaming execution operation. +/// A run instance supporting a streaming form of receiving workflow events, providing +/// a mechanism to send responses back to the workflow, and retrieving the result of workflow execution. /// -/// -public class StreamingExecutionHandle : StreamingExecutionHandle +/// The type of the workflow output. +public class StreamingRun : StreamingRun { private readonly IRunnerWithOutput _resultSource; - internal StreamingExecutionHandle(IRunnerWithOutput runner) + internal StreamingRun(IRunnerWithOutput runner) : base(Throw.IfNull(runner.StepRunner)) { this._resultSource = runner; @@ -128,9 +156,9 @@ internal StreamingExecutionHandle(IRunnerWithOutput runner) } /// -/// Provides extension methods for processing and executing workflows using streaming execution handles. +/// Provides extension methods for processing and executing workflows using streaming runs. /// -public static class ExecutionHandleExtensions +public static class StreamingRunExtensions { /// /// Processes all events from the workflow execution stream until completion. @@ -138,14 +166,14 @@ public static class ExecutionHandleExtensions /// This method continuously monitors the workflow execution stream provided by and invokes the for each event. If the callback returns a /// non- response, the response is sent back to the workflow using the handle. - /// The representing the workflow execution stream to monitor. + /// The representing the workflow execution stream to monitor. /// An optional callback function invoked for each received from the stream. /// The callback can return a response object to be sent back to the workflow, or if no response /// is required. /// A to observe while waiting for events. /// A that represents the asynchronous operation. The task completes when the workflow /// execution stream is fully processed. - public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) + public static async ValueTask RunToCompletionAsync(this StreamingRun handle, Func? eventCallback = null, CancellationToken cancellation = default) { Throw.IfNull(handle); @@ -160,21 +188,21 @@ public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle } /// - /// Executes the workflow associated with the specified until it + /// Executes the workflow associated with the specified until it /// completes and returns the final result. /// /// This method ensures that the workflow runs to completion before returning the result. If an /// is provided, it will be invoked for each event emitted during the workflow's /// execution, allowing for custom event handling. /// The type of the result produced by the workflow. - /// The representing the workflow to execute. + /// The representing the workflow to execute. /// An optional callback function that is invoked for each /// emitted during execution. The callback can process the event and return an object, or /// if no response is required. /// A that can be used to cancel the workflow execution. /// A that represents the asynchronous operation. The task's result is the final /// result of the workflow execution. - public static async ValueTask RunToCompletionAsync(this StreamingExecutionHandle handle, Func? eventCallback = null, CancellationToken cancellation = default) + public static async ValueTask RunToCompletionAsync(this StreamingRun handle, Func? eventCallback = null, CancellationToken cancellation = default) { Throw.IfNull(handle); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs index 62365d6beb..8b479fd0c5 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs @@ -26,7 +26,7 @@ public static async ValueTask RunAsync(TextWriter writer, string input = LocalRunner runner = new(workflow); - StreamingExecutionHandle handle = await runner.StreamAsync(input).ConfigureAwait(false); + StreamingRun handle = await runner.StreamAsync(input).ConfigureAwait(false); await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) { switch (evt) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs index 5bfabb92f2..5180bae49b 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs @@ -21,7 +21,7 @@ public static async ValueTask RunAsync(TextWriter writer) .Build(); LocalRunner runner = new(workflow); - StreamingExecutionHandle handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); + StreamingRun handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) { diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs index 10412f377a..d7d24fbbe7 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_ExternalRequest.cs @@ -21,7 +21,7 @@ public static async ValueTask RunAsync(TextWriter writer, Func(judge, ComputeStreamingOutput, (NumberSignal s, string? _) => s == NumberSignal.Matched); LocalRunner runner = new(workflow); - StreamingExecutionHandle handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); + StreamingRun handle = await runner.StreamAsync(NumberSignal.Init).ConfigureAwait(false); await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) { From 073f1daf6add2393d6382245b3aa3ff6667fc6d7 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Fri, 8 Aug 2025 12:33:36 -0400 Subject: [PATCH 138/232] test: Add test for non-streaming execution --- .../Sample/01_Simple_Workflow_Sequential.cs | 1 + .../Sample/01a_Simple_Workflow_Sequential.cs | 37 +++++++++++++++++++ .../SampleSmokeTest.cs | 18 +++++++++ 3 files changed, 56 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs index cf74362684..67c515bdac 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs @@ -52,6 +52,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext cont string result = new(charArray); await context.SendMessageAsync(result).ConfigureAwait(false); + await context.AddEventAsync(new WorkflowCompletedEvent() { Data = result }).ConfigureAwait(false); return result; } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs new file mode 100644 index 0000000000..449b171517 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.IO; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Execution; + +namespace Microsoft.Agents.Workflows.Sample; + +internal static class Step1aEntryPoint +{ + public static async ValueTask RunAsync(TextWriter writer) + { + UppercaseExecutor uppercase = new(); + ReverseTextExecutor reverse = new(); + + WorkflowBuilder builder = new(uppercase); + builder.AddEdge(uppercase, reverse); + + Workflow workflow = builder.Build(); + LocalRunner runner = new(workflow); + + //var handle = await runner.StreamAsync("Hello, World!").ConfigureAwait(false); + + Run run = await runner.RunAsync("Hello, World!").ConfigureAwait(false); + + Assert.Equal(RunStatus.Completed, run.Status); + + foreach (WorkflowEvent evt in run.NewEvents) + { + if (evt is ExecutorCompleteEvent executorComplete) + { + writer.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}"); + } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs index 12fe7f8f37..dc08df598e 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs @@ -28,6 +28,24 @@ public async Task Test_RunSample_Step1Async() ); } + [Fact] + public async Task Test_RunSample_Step1aAsync() + { + using StringWriter writer = new(); + + await Step1aEntryPoint.RunAsync(writer); + + string result = writer.ToString(); + string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); + + const string INPUT = "Hello, World!"; + + Assert.Collection(lines, + line => Assert.Contains($"UppercaseExecutor: {INPUT.ToUpperInvariant()}", line), + line => Assert.Contains($"ReverseTextExecutor: {new string(INPUT.ToUpperInvariant().Reverse().ToArray())}", line) + ); + } + [Fact] public async Task Test_RunSample_Step2Async() { From a4a1abd6a111bde0ff3e8b4f26592b48af9c8b62 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 11 Aug 2025 15:18:07 -0400 Subject: [PATCH 139/232] refactor: Remove unused types --- .../Core/Message.cs | 117 ------------------ 1 file changed, 117 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/Message.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Message.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Message.cs deleted file mode 100644 index e94fad7848..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Message.cs +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using Microsoft.Shared.Diagnostics; -using ExecutorId = string; -// TODO: Unclear whether this should be forcibly a serializable type. -using MetadataValueT = object; -using RetryExceptionT = System.InvalidOperationException; -using TopicId = string; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// . -/// -public record MessageMetadata -{ - /// - /// . - /// - public string CorrelationId { get; init; } = Guid.NewGuid().ToString(); - /// - /// . - /// - public ExecutorId? SourceId { get; init; } - /// - /// . - /// - public ExecutorId? TargetId { get; init; } - /// - /// . - /// - public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; - /// - /// . - /// - public string IsoTimestamp => this.Timestamp.ToString("o"); - /// - /// . - /// - public TopicId? Topic { get; init; } - /// - /// . - /// - public int Priority { get; init; } = 0; // Higher values indicate higher priority. - /// - /// . - /// - public TimeSpan? Timeout { get; init; } = null; - - /// - /// . - /// - public int Retries { get; init; } = 0; - /// - /// . - /// - public int MaxRetries { get; init; } = 3; - - /// - /// . - /// - public IDictionary CustomData { get; init; } = new Dictionary(); -} - -/// -/// . -/// -/// -public record Message -{ - /// - /// . - /// - public TContent Content { get; init; } - - /// - /// . - /// - public Type ContentType => typeof(TContent); - - /// - /// . - /// - public MessageMetadata Metadata { get; init; } - - /// - /// . - /// - /// - /// - /// - public Message(TContent content, MessageMetadata metadata) - { - this.Content = Throw.IfNull(content); - this.Metadata = Throw.IfNull(metadata); - } - - /// - /// Creates a new message instance for a new target. - /// - /// The identifier of the target executor to associate with the message. - /// A new instance with the updated target identifier. - public Message WithTarget(ExecutorId targetId) - => this with { Metadata = this.Metadata with { TargetId = targetId } }; - - /// - /// Create a copy of this message for next retry attempt. - /// - /// A copy of this message with incremented retry count. - /// If the maximum number of retries has been exceeded. - public Message WithRetry() - => this.Metadata.Retries < this.Metadata.MaxRetries - ? this with { Metadata = this.Metadata with { Retries = this.Metadata.Retries + 1 } } - : throw new RetryExceptionT($"Maximum retries ({this.Metadata.MaxRetries}) exceeded for message with ID '{this.Metadata.CorrelationId}'."); -} From a55017bc56926728cdfdef67fc31ec937a9caad7 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 11 Aug 2025 15:23:53 -0400 Subject: [PATCH 140/232] refactor: Simplify Event and EdgeData type hierarchies --- .../Microsoft.Agents.Workflows/Core/Edge.cs | 100 ++++++++--------- .../Microsoft.Agents.Workflows/Core/Events.cs | 103 ++++++++++++------ .../Core/Executor.cs | 53 ++------- .../Execution/FanInEdgeState.cs | 29 ++--- .../Execution/FanOutEdgeRunner.cs | 4 +- .../Specialized/OutputCollectorExecutor.cs | 2 +- .../WorkflowBuilder.cs | 21 ++-- .../ReflectionSmokeTest.cs | 6 - .../Sample/01_Simple_Workflow_Sequential.cs | 2 +- .../Sample/02_Simple_Workflow_Condition.cs | 4 +- .../Sample/03_Simple_Workflow_Loop.cs | 2 +- 11 files changed, 153 insertions(+), 173 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs index 878ac98a09..e2c6bdbebd 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs @@ -13,85 +13,75 @@ namespace Microsoft.Agents.Workflows.Core; /// Represents a directed edge between two nodes, optionally associated with a condition that determines whether the /// edge is active. /// -/// The id of the source executor node. -/// The id of the target executor node. -/// A predicate determining whether the edge is active for a given message. -public record DirectEdgeData( - string SourceId, - string SinkId, - PredicateT? Condition = null) +/// The id of the source executor node. +/// The id of the target executor node. +/// A predicate determining whether the edge is active for a given message. +public sealed class DirectEdgeData(string sourceId, string sinkId, PredicateT? condition = null) { /// - /// Converts a instance to an . + /// The Id of the source node. /// - /// The to convert t. - public static implicit operator Edge(DirectEdgeData data) - { - return new Edge(Throw.IfNull(data)); - } + public string SourceId => sourceId; + + /// + /// The Id of the destination node. + /// + public string SinkId => sinkId; + + /// + /// An optional predicate determining whether the edge is active for a given message. If , + /// the edge is always active when a message is generated by the source. + /// + public PredicateT? Condition => condition; } /// /// Represents a connection from a single node to a set of nodes, optionally associated with a paritition selector /// function which maps incoming messages to a subset of the target set. /// -/// The id of the source executor node. -/// A list of ids of the target executor nodes. -/// A function that maps an incoming message to a subset of the target executor nodes. -public record FanOutEdgeData( - string SourceId, - List SinkIds, - PartitionerT? Partitioner = null) +/// The id of the source executor node. +/// A list of ids of the target executor nodes. +/// A function that maps an incoming message to a subset of the target executor nodes. +public sealed class FanOutEdgeData( + string sourceId, + List sinkIds, + PartitionerT? partitioner = null) { /// - /// Converts a instance to an . + /// The Id of the source node. /// - /// The to convert. - public static implicit operator Edge(FanOutEdgeData data) - { - return new Edge(data); - } -} + public string SourceId => sourceId; -/// -/// Specifies the condition under which a fan-in operation is triggered in a workflow. -/// Use to trigger the operation when all incoming edges have data, or -/// to trigger when any incoming edge has data. -/// -public enum FanInTrigger -{ /// - /// Trigger when all incoming edges have data. + /// The ordered list of Ids of the destination nodes. /// - WhenAll, + public List SinkIds => sinkIds; + /// - /// Trigger when any incoming edge has data. + /// A function mapping an incoming message to a subset of the target executor nodes (or optionally all of them). + /// If , all destination nodes are selected. /// - WhenAny + public PartitionerT? PartitionAssigner => partitioner; } /// -/// Represents a connection from a set of nodes to a single node. It can trigger either when all edges have data -/// or when any of them have data. +/// Represents a connection from a set of nodes to a single node. It will trigger either when all edges have data. /// -/// An enumeration of ids of the source executor nodes. -/// The id of the target executor node. -/// The that determines when the fan-in edge is activated. -public record FanInEdgeData( - IEnumerable SourceIds, - string SinkId, - FanInTrigger Trigger = FanInTrigger.WhenAll) +/// An enumeration of ids of the source executor nodes. +/// The id of the target executor node. +public sealed class FanInEdgeData(List sourceIds, string sinkId) { - internal Guid UniqueKey { get; } = Guid.NewGuid(); + /// + /// The ordered list of Ids of the source nodes. + /// + public List SourceIds => sourceIds; /// - /// Converts a instance to an . + /// The Id of the destination node. /// - /// The to convert. - public static implicit operator Edge(FanInEdgeData data) - { - return new Edge(data); - } + public string SinkId => sinkId; + + internal Guid UniqueKey { get; } = Guid.NewGuid(); } /// @@ -103,7 +93,7 @@ public static implicit operator Edge(FanInEdgeData data) /// additional information relevant to the edge, and its concrete type depends on the value of , functioning as a tagged union. /// -public class Edge +public sealed class Edge { /// /// Specified the edge type. diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs index 0581b12ca6..8aeb50e4b8 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs @@ -1,19 +1,37 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using Microsoft.Extensions.AI.Agents; -using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Core; /// /// Base class for -scoped events. /// -public record WorkflowEvent(object? Data = null); +public class WorkflowEvent(object? data = null) +{ + /// + /// Optional payload + /// + public object? Data => data; + + /// + public override string ToString() + { + if (this.Data != null) + { + return $"{this.GetType().Name}(Data: {this.Data.GetType()} = {this.Data})"; + } + + return $"{this.GetType().Name}()"; + } +} /// /// Event triggered when a workflow starts execution. /// -public record WorkflowStartedEvent : WorkflowEvent; +/// The message triggering the start of workflow execution. +public sealed class WorkflowStartedEvent(object? message = null) : WorkflowEvent(data: message); /// /// Event triggered when a workflow completes execution. @@ -22,66 +40,81 @@ public record WorkflowStartedEvent : WorkflowEvent; /// The user is expected to raise this event from a terminating , or to build /// the workflow with output capture using . /// -public record WorkflowCompletedEvent : WorkflowEvent; +/// The result of the execution of the workflow. +public sealed class WorkflowCompletedEvent(object? result = null) : WorkflowEvent(data: result); + +/// +/// Event triggered when a workflow encounters an error. +/// +/// +/// Optionally, the representing the error. +/// +public sealed class WorkflowErrorEvent(Exception? e) : WorkflowEvent(e); + +/// +/// Event triggered when a workflow encounters a warning-condition. +/// +/// The warning message. +public sealed class WorkflowWarningEvent(string message) : WorkflowEvent(message); /// /// Event triggered when a workflow executor request external information. /// -public record RequestInputEvent(ExternalRequest Request) : WorkflowEvent; +public sealed class RequestInputEvent(ExternalRequest request) : WorkflowEvent(request) +{ + /// + /// The request to be serviced and data payload associated with it. + /// + public ExternalRequest Request => request; +} /// /// Base class for -scoped events. /// -public record ExecutorEvent : WorkflowEvent +public class ExecutorEvent(string executorId, object? data) : WorkflowEvent(data) { /// /// The identifier of the executor that generated this event. /// - public string ExecutorId { get; } + public string ExecutorId => executorId; - /// - /// Initializes a new instance of the class with the specified executor identifier and - /// optional event data. - /// - /// The unique identifier of the executor associated with this event. Cannot be null. - /// Optional event data to associate with the event. May be null if no additional data is required. - public ExecutorEvent(string executorId, object? data = null) : base(data) + /// + public override string ToString() { - this.ExecutorId = Throw.IfNull(executorId); + if (this.Data != null) + { + return $"{this.GetType().Name}(Executor = {this.ExecutorId}, Data: {this.Data.GetType()} = {this.Data})"; + } + + return $"{this.GetType().Name}(Executor = {this.ExecutorId})"; } } /// /// Event triggered when an executor handler is invoked. /// -public record ExecutorInvokeEvent : ExecutorEvent -{ - /// - /// Initializes a new instance of the class. - /// - public ExecutorInvokeEvent(string executorId, object? data = null) : base(executorId, data) - { - } -} +/// The unique identifier of the executor being invoked. +/// The invocation message. +public sealed class ExecutorInvokeEvent(string executorId, object message) : ExecutorEvent(executorId, data: message); /// /// Event triggered when an executor handler has completed. /// -public record ExecutorCompleteEvent : ExecutorEvent -{ - /// - /// Initializes a new instance of the class to signal that an executor has - /// completed its operation. - /// - /// The unique identifier of the executor that has completed. Cannot be null or empty. - /// The result produced by the executor upon completion, or null if no result is available. - public ExecutorCompleteEvent(string executorId, object? result = null) : base(executorId, result) { } -} +/// The unique identifier of the executor that has completed. +/// The result produced by the executor upon completion, or null if no result is available. +public sealed class ExecutorCompleteEvent(string executorId, object? result) : ExecutorEvent(executorId, data: result); + +/// +/// Event triggered when an executor handler fails. +/// +/// The unique identifier of the executor that has failed. +/// The exception representing the error. +public sealed class ExecutorFailureEvent(string executorId, Exception? err) : ExecutorEvent(executorId, data: err); /// /// Event triggered when an agent run is completed. /// -public record AgentRunEvent : ExecutorEvent +public class AgentRunEvent : ExecutorEvent { /// /// Initializes a new instance of the class. diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs index c6a8f7825c..5b0e4d3f74 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs @@ -13,15 +13,13 @@ namespace Microsoft.Agents.Workflows.Core; /// A component that processes messages in a . /// [DebuggerDisplay("{GetType().Name}{Id}")] -public abstract class ExecutorBase : IIdentified, IAsyncDisposable +public abstract class ExecutorBase : IIdentified { /// /// A unique identifier for the executor. /// public string Id { get; } - private Dictionary State { get; } = new(); - /// /// Initialize the executor with a unique identifier /// @@ -63,17 +61,22 @@ internal MessageRouter Router /// An exception is generated while handling the message. public async ValueTask ExecuteAsync(object message, IWorkflowContext context) { - await context.AddEventAsync(new ExecutorInvokeEvent(this.Id)).ConfigureAwait(false); + await context.AddEventAsync(new ExecutorInvokeEvent(this.Id, message)).ConfigureAwait(false); CallResult? result = await this.Router.RouteMessageAsync(message, context, requireRoute: true) .ConfigureAwait(false); - ExecutorCompleteEvent completeEvent = new(this.Id) + ExecutorEvent executionResult; + if (result == null || result.IsSuccess) + { + executionResult = new ExecutorCompleteEvent(this.Id, result?.Result); + } + else { - Data = result == null ? null : result.IsSuccess ? result.Result : result.Exception - }; + executionResult = new ExecutorFailureEvent(this.Id, result.Exception); + } - await context.AddEventAsync(completeEvent).ConfigureAwait(false); + await context.AddEventAsync(executionResult).ConfigureAwait(false); if (result == null) { @@ -94,23 +97,6 @@ internal MessageRouter Router return result.Result; } - private bool _initialized = false; - - /// - /// Ensures that the executor has been initialized before performing operations. - /// - /// This method checks the internal state of the executor and throws an exception if it has not - /// been initialized. Call InitializeAsync before invoking any operations that require - /// initialization. - /// Thrown if the executor has not been initialized by calling InitializeAsync. - protected void CheckInitialized() - { - if (!this._initialized) - { - throw new InvalidOperationException($"Executor {this.GetType().Name} is not initialized. Call InitializeAsync first."); - } - } - /// /// A set of s, representing the messages this executor can handle. /// @@ -119,7 +105,7 @@ protected void CheckInitialized() /// /// A set of s, representing the messages this executor can produce as output. /// - public virtual ISet OutputTypes => new HashSet([typeof(object)]); + public virtual ISet OutputTypes { get; } = new HashSet([typeof(object)]); /// /// Checks if the executor can handle a specific message type. @@ -127,21 +113,6 @@ protected void CheckInitialized() /// /// public bool CanHandle(Type messageType) => this.Router.CanHandle(messageType); - - /// - protected virtual async ValueTask DisposeAsync() - { - this._initialized = false; - } - - /// - ValueTask IAsyncDisposable.DisposeAsync() - { - GC.SuppressFinalize(this); // Should we be suppressing the finalizer here? CodeAnalysis seems to want it (CA1816) - - // Chain to the virtual call to DisposeAsync. - return this.DisposeAsync(); - } } /// diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeState.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeState.cs index 2747969d91..65b98b433f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeState.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanInEdgeState.cs @@ -7,32 +7,25 @@ namespace Microsoft.Agents.Workflows.Execution; internal record FanInEdgeState(FanInEdgeData EdgeData) { - private List? _pendingMessages - = EdgeData.Trigger == FanInTrigger.WhenAll ? [] : null; + private List? _pendingMessages = []; - private HashSet? _unseen - = EdgeData.Trigger == FanInTrigger.WhenAll ? new(EdgeData.SourceIds) : null; + private HashSet? _unseen = new(EdgeData.SourceIds); public IEnumerable? ProcessMessage(string sourceId, object message) { - if (this.EdgeData.Trigger == FanInTrigger.WhenAll) - { - this._pendingMessages!.Add(message); - this._unseen!.Remove(sourceId); - - if (this._unseen.Count == 0) - { - List result = this._pendingMessages; + this._pendingMessages!.Add(message); + this._unseen!.Remove(sourceId); - this._pendingMessages = []; - this._unseen = new(this.EdgeData.SourceIds); + if (this._unseen.Count == 0) + { + List result = this._pendingMessages; - return result; - } + this._pendingMessages = []; + this._unseen = new(this.EdgeData.SourceIds); - return null; + return result; } - return [message]; + return null; } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs index e8508b90c9..c12b9fd256 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/FanOutEdgeRunner.cs @@ -18,9 +18,9 @@ internal class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeDa public async ValueTask> ChaseAsync(object message) { List targets = - this.EdgeData.Partitioner == null + this.EdgeData.PartitionAssigner == null ? this.EdgeData.SinkIds - : this.EdgeData.Partitioner(message, this.BoundContexts.Count).Select(i => this.EdgeData.SinkIds[i]).ToList(); + : this.EdgeData.PartitionAssigner(message, this.BoundContexts.Count).Select(i => this.EdgeData.SinkIds[i]).ToList(); object?[] result = await Task.WhenAll(targets.Select(ProcessTargetAsync)).ConfigureAwait(false); return result.Where(r => r is not null); diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs index 2cabca3bda..6b24b25f99 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs @@ -35,7 +35,7 @@ public ValueTask HandleAsync(TInput message, IWorkflowContext context) if (this._completionCondition is not null && this._completionCondition!(message, this.Result)) { - return context.AddEventAsync(new WorkflowCompletedEvent() { Data = this.Result }); + return context.AddEventAsync(new WorkflowCompletedEvent(this.Result)); } return default; diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs index e005d08caa..200fcd0f35 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs @@ -138,8 +138,9 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func this.Track(target).Id).ToList(), - partitioner)); + partitioner); + + this.EnsureEdgesFor(source.Id).Add(new(fanOutEdge)); return this; } @@ -177,23 +179,20 @@ public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func /// The target executor that receives input from the specified source executors. Cannot be null. - /// An optional trigger condition that determines when the fan-in edge activates. Defaults to - /// . /// One or more source executors that provide input to the target. Cannot be null or empty. /// The current instance of . - public WorkflowBuilder AddFanInEdge(ExecutorIsh target, FanInTrigger trigger = FanInTrigger.WhenAll, params ExecutorIsh[] sources) + public WorkflowBuilder AddFanInEdge(ExecutorIsh target, params ExecutorIsh[] sources) { Throw.IfNull(target); Throw.IfNullOrEmpty(sources); FanInEdgeData edgeData = new( sources.Select(source => this.Track(source).Id).ToList(), - this.Track(target).Id, - trigger); + this.Track(target).Id); foreach (string sourceId in edgeData.SourceIds) { - this.EnsureEdgesFor(sourceId).Add(edgeData); + this.EnsureEdgesFor(sourceId).Add(new(edgeData)); } return this; diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs index fde763b3b0..adb45c15fd 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs @@ -93,8 +93,6 @@ public async Task Test_ReflectAndExecute_DefaultHandlerAsync() Assert.NotNull(result); Assert.True(result.IsSuccess); Assert.True(result.IsVoid); - - await ((IAsyncDisposable)executor).DisposeAsync(); } [Fact] @@ -107,8 +105,6 @@ public async Task Test_ReflectAndExecute_HandlerReturnsVoidAsync() Assert.NotNull(result); Assert.True(result.IsSuccess); Assert.True(result.IsVoid); - - await ((IAsyncDisposable)executor).DisposeAsync(); } [Fact] @@ -130,7 +126,5 @@ public async Task Test_ReflectAndExecute_HandlerReturnsValueAsync() Assert.False(result.IsVoid); Assert.Equal(Expected, result.Result); - - await ((IAsyncDisposable)executor).DisposeAsync(); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs index 67c515bdac..1a262eb4ce 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs @@ -52,7 +52,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext cont string result = new(charArray); await context.SendMessageAsync(result).ConfigureAwait(false); - await context.AddEventAsync(new WorkflowCompletedEvent() { Data = result }).ConfigureAwait(false); + await context.AddEventAsync(new WorkflowCompletedEvent(result)).ConfigureAwait(false); return result; } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs index 8b479fd0c5..883eebad07 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs @@ -82,7 +82,7 @@ public async ValueTask HandleAsync(bool message, IWorkflowContext context) await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay - await context.AddEventAsync(new WorkflowCompletedEvent { Data = RespondToMessageExecutor.ActionResult }) + await context.AddEventAsync(new WorkflowCompletedEvent(RespondToMessageExecutor.ActionResult)) .ConfigureAwait(false); } } @@ -101,7 +101,7 @@ public async ValueTask HandleAsync(bool message, IWorkflowContext context) await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay - await context.AddEventAsync(new WorkflowCompletedEvent { Data = RemoveSpamExecutor.ActionResult }) + await context.AddEventAsync(new WorkflowCompletedEvent(RemoveSpamExecutor.ActionResult)) .ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs index 5180bae49b..da3affc589 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs @@ -69,7 +69,7 @@ public async ValueTask HandleAsync(NumberSignal message, IWorkflowContext c switch (message) { case NumberSignal.Matched: - await context.AddEventAsync(new WorkflowCompletedEvent { Data = $"Guessed the number: {this._currGuess}" }) + await context.AddEventAsync(new WorkflowCompletedEvent($"Guessed the number: {this._currGuess}")) .ConfigureAwait(false); break; From ebecbfcd5227a95c2c53bd8bdd56e07a89d1f5e5 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 12 Aug 2025 11:42:31 -0400 Subject: [PATCH 141/232] feat: Add Switch (=Conditional Edge Group) control flow --- .../SwitchBuilder.cs | 104 ++++++++++++++++++ .../WorkflowBuilderExtensions.cs | 22 ++++ 2 files changed, 126 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs new file mode 100644 index 0000000000..507b363a38 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows; + +/// +/// Provides a builder for constructing a switch-like control flow that maps predicates to one or more executors. +/// Enables the configuration of case-based and default execution logic for dynamic input handling. +/// +public sealed class SwitchBuilder +{ + private readonly List _executors = []; + private readonly Dictionary _executorIndicies = []; + private readonly List<(Func Predicate, HashSet OutgoingIndicies)> _caseMap = []; + private readonly HashSet _defaultIndicies = []; + + /// + /// Adds a case to the switch builder that associates a predicate with one or more executors. + /// + /// + /// Cases are evaluated in the order they are added. + /// + /// A function that determines whether the associated executors should be considered for execution. The function + /// receives an input object and returns to select the case; otherwise, . + /// One or more executors to associate with the predicate. Each executor will be invoked if the predicate matches. + /// Cannot be null. + /// The current instance, allowing for method chaining. + public SwitchBuilder AddCase(Func predicate, params ExecutorIsh[] executors) + { + Throw.IfNull(predicate); + Throw.IfNull(executors); + + HashSet indicies = []; + + foreach (ExecutorIsh executor in executors) + { + if (!this._executorIndicies.TryGetValue(executor.Id, out int index)) + { + index = this._executors.Count; + this._executors.Add(executor); + this._executorIndicies[executor.Id] = index; + } + + indicies.Add(index); + } + + this._caseMap.Add((predicate, indicies)); + + return this; + } + + /// + /// Adds one or more executors to be used as the default case when no other predicates match. + /// + /// + /// + public SwitchBuilder WithDefault(params ExecutorIsh[] executors) + { + Throw.IfNull(executors); + + foreach (ExecutorIsh executor in executors) + { + if (!this._executorIndicies.TryGetValue(executor.Id, out int index)) + { + index = this._executors.Count; + this._executors.Add(executor); + this._executorIndicies[executor.Id] = index; + } + + this._defaultIndicies.Add(index); + } + + return this; + } + + internal WorkflowBuilder ReduceToFanOut(WorkflowBuilder builder, ExecutorIsh source) + { + List<(Func Predicate, HashSet OutgoingIndicies)> caseMap = this._caseMap; + HashSet defaultIndicies = this._defaultIndicies; + + return builder.AddFanOutEdge(source, CasePartitioner, this._executors.ToArray()); + + IEnumerable CasePartitioner(object? input, int targetCount) + { + Debug.Assert(targetCount == this._executors.Count); + + for (int i = 0; i < caseMap.Count; i++) + { + (Func predicate, HashSet outgoingIndicies) = caseMap[i]; + if (predicate(input)) + { + return outgoingIndicies; + } + } + + return defaultIndicies; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs index c765bc53ca..62110bd304 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs @@ -76,6 +76,28 @@ public static WorkflowBuilder AddExternalCall(this Workflow .AddEdge(port, source); } + /// + /// Adds a switch step to the workflow, allowing conditional branching based on the specified source executor. + /// + /// Use this method to introduce conditional logic into a workflow, enabling execution to follow + /// different paths based on the outcome of the source executor. The switch configuration defines the available + /// branches and their associated conditions. + /// The workflow builder to which the switch step will be added. Cannot be null. + /// The source executor that determines the branching condition for the switch. Cannot be null. + /// An action used to configure the switch builder, specifying the branches and their conditions. Cannot be null. + /// The workflow builder instance with the configured switch step added. + public static WorkflowBuilder AddSwitch(this WorkflowBuilder builder, ExecutorIsh source, Action configureSwitch) + { + Throw.IfNull(builder); + Throw.IfNull(source); + Throw.IfNull(configureSwitch); + + SwitchBuilder switchBuilder = new(); + configureSwitch(switchBuilder); + + return switchBuilder.ReduceToFanOut(builder, source); + } + /// /// Builds a workflow that collects output from the specified executor, aggregates results using the provided /// streaming aggregator, and optionally completes based on a custom condition. From fe105e929f086b1bb23670b2a004c507bd994214 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 13 Aug 2025 06:35:45 -0400 Subject: [PATCH 142/232] feat: Make .NET AutoSend the MessageHandler result --- .../Core/ExecutionConfiguration.cs | 14 ++++++++++++++ .../Microsoft.Agents.Workflows/Core/Executor.cs | 6 ++++++ .../Sample/01_Simple_Workflow_Sequential.cs | 3 --- .../Sample/02_Simple_Workflow_Condition.cs | 1 - .../Sample/03_Simple_Workflow_Loop.cs | 2 -- 5 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/ExecutionConfiguration.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/ExecutionConfiguration.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ExecutionConfiguration.cs new file mode 100644 index 0000000000..15f3163fcf --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/ExecutionConfiguration.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows.Core; + +/// +/// . +/// +public static class ExecutionConfiguration +{ + /// + /// . + /// + public static bool AutoSendMessageHandlerResultObject { get; set; } = true; +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs index 5b0e4d3f74..489f1c9f0a 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs @@ -94,6 +94,12 @@ internal MessageRouter Router return null; // Void result. } + // If we had a real return type, raise it as a SendMessage; TODO: Should we have a way to disable this behaviour? + if (result.Result != null && ExecutionConfiguration.AutoSendMessageHandlerResultObject) + { + await context.SendMessageAsync(result.Result).ConfigureAwait(false); + } + return result.Result; } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs index 1a262eb4ce..fb393f43a3 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs @@ -37,8 +37,6 @@ internal sealed class UppercaseExecutor() : Executor("Upperca public async ValueTask HandleAsync(string message, IWorkflowContext context) { string result = message.ToUpperInvariant(); - - await context.SendMessageAsync(result).ConfigureAwait(false); return result; } } @@ -51,7 +49,6 @@ public async ValueTask HandleAsync(string message, IWorkflowContext cont System.Array.Reverse(charArray); string result = new(charArray); - await context.SendMessageAsync(result).ConfigureAwait(false); await context.AddEventAsync(new WorkflowCompletedEvent(result)).ConfigureAwait(false); return result; } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs index 883eebad07..2912170548 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs @@ -63,7 +63,6 @@ public async ValueTask HandleAsync(string message, IWorkflowContext contex bool isSpam = this.SpamKeywords.Any(keyword => message.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0); #endif - await context.SendMessageAsync(isSpam).ConfigureAwait(false); return isSpam; } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs index da3affc589..c1ee6d72fb 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs @@ -82,7 +82,6 @@ await context.AddEventAsync(new WorkflowCompletedEvent($"Guessed the number: {th } this._currGuess = this.NextGuess; - await context.SendMessageAsync(this._currGuess).ConfigureAwait(false); return this._currGuess; } } @@ -112,7 +111,6 @@ public async ValueTask HandleAsync(int message, IWorkflowContext c result = NumberSignal.Above; } - await context.SendMessageAsync(result).ConfigureAwait(false); return result; } } From 1895dc1ce6181401dd83c693355fb65de1678e50 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 13 Aug 2025 08:19:14 -0400 Subject: [PATCH 143/232] feat: Implement State APIs --- .../Core/IWorkflowContext.cs | 26 +++- .../Execution/LocalRunner.cs | 3 +- .../Execution/LocalRunnerContext.cs | 8 ++ .../Execution/ScopeId.cs | 54 ++++++++ .../Execution/StateManager.cs | 98 +++++++++++++++ .../Execution/StateScope.cs | 64 ++++++++++ .../Execution/StateUpdate.cs | 30 +++++ .../Execution/UpdateKey.cs | 39 ++++++ .../StateSmokeTest.cs | 119 ++++++++++++++++++ 9 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflows/Execution/ScopeId.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows/Execution/StateManager.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows/Execution/StateScope.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows/Execution/StateUpdate.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows/Execution/UpdateKey.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs index 49495ca19f..911525a421 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs @@ -24,5 +24,29 @@ public interface IWorkflowContext /// A representing the asynchronous operation. ValueTask SendMessageAsync(object message); - // TODO: State management + /// + /// Reads a state value from the workflow's state store. If no scope is provided, the executor's private + /// scope is used. + /// + /// The type of the state value. + /// The key of the state value. + /// The name of the scope. + /// A representing the asynchronous operation. + ValueTask ReadStateAsync(string key, string? scopeName = null); + + /// + /// Asynchronously updates the state of a queue entry identified by the specified key and optional scope. + /// + /// + /// Subsequent reads by this executor will result in the new value of the state. Other executors will only see + /// the new state starting from the next SuperStep. + /// + /// The type of the value to associate with the queue entry. + /// The unique identifier for the queue entry to update. Cannot be null or empty. + /// The value to set for the queue entry. If null, the entry's state may be cleared or reset depending on + /// implementation. + /// An optional name that specifies the scope within which the queue entry resides. If null, the default scope is + /// used. + /// A ValueTask that represents the asynchronous update operation. + ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null); } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs index 3210169abc..3b2c933498 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs @@ -160,7 +160,8 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) // that we would need to avoid firing the tasks when we call InvokeEdgeAsync, or RouteExternalMessageAsync. IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); - // TODO: Commit the state updates (so they are visible to the next step) + // Commit the state updates (so they are visible to the next step) + await this.RunContext.StateManager.PublishUpdatesAsync().ConfigureAwait(false); // After the message handler invocations, we may have some events to deliver foreach (WorkflowEvent @event in this.RunContext.QueuedEvents) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs index 7b4786cd22..748d1436c6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs @@ -87,9 +87,17 @@ public ValueTask PostAsync(ExternalRequest request) public readonly List QueuedEvents = new(); + internal StateManager StateManager { get; } = new(); + private class BoundContext(LocalRunnerContext RunnerContext, string ExecutorId) : IWorkflowContext { public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => RunnerContext.AddEventAsync(workflowEvent); public ValueTask SendMessageAsync(object message) => RunnerContext.SendMessageAsync(ExecutorId, message); + + public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null) + => RunnerContext.StateManager.WriteStateAsync(ExecutorId, scopeName, key, value); + + public ValueTask ReadStateAsync(string key, string? scopeName = null) + => RunnerContext.StateManager.ReadStateAsync(ExecutorId, scopeName, key); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/ScopeId.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/ScopeId.cs new file mode 100644 index 0000000000..3d96b2bfc9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/ScopeId.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Execution; + +/// +/// A unique identifier for a scope within an executor. If a scope name is not provided, it references the +/// default scope private to the executor. Otherwise, regardless of the executorId, it references a shared +/// scope with the specified name. +/// +/// +/// +internal class ScopeId(string executorId, string? scopeName = null) +{ + public string ExecutorId { get; } = Throw.IfNullOrEmpty(executorId); + public string? ScopeName { get; } = scopeName; + + public override string ToString() + { + return $"{this.ExecutorId}/{this.ScopeName ?? "default"}"; + } + + public override bool Equals(object? obj) + { + if (obj is ScopeId other) + { + if (other.ScopeName is null && this.ScopeName is null) + { + return this.ExecutorId == other.ExecutorId; + } + else if (other.ScopeName is not null && this.ScopeName is not null) + { + return this.ScopeName == other.ScopeName; + } + else + { + return false; // One has a scope name, the other does not. + } + } + + return false; + } + + public override int GetHashCode() + { + if (this.ScopeName is null) + { + return this.ExecutorId.GetHashCode(); + } + + return this.ScopeName.GetHashCode(); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/StateManager.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StateManager.cs new file mode 100644 index 0000000000..c0b6efbe2b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/StateManager.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class StateManager +{ + private readonly Dictionary _scopes = new(); + private readonly Dictionary _queuedUpdates = new(); + + private StateScope GetOrCreateScope(ScopeId scopeId) + { + Throw.IfNull(scopeId); + + if (!this._scopes.TryGetValue(scopeId, out StateScope? scope)) + { + scope = new StateScope(scopeId); + this._scopes[scopeId] = scope; + } + + return scope; + } + + 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) + { + Throw.IfNullOrEmpty(key); + + UpdateKey stateKey = new(scopeId, key); + + // If there is executor-local state (from a queued update), read it first + if (this._queuedUpdates.TryGetValue(stateKey, out StateUpdate? result)) + { + // What's the right thing to do when we have a state object, but it is the wrong type? + if (result.IsDelete) + { + return new ValueTask((T?)default); + } + + if (result.Value is T) + { + return new ValueTask((T?)result.Value); + } + + 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); + } + + public ValueTask WriteStateAsync(string executorId, string? scopeName, string key, T? value) + => this.WriteStateAsync(new ScopeId(Throw.IfNullOrEmpty(executorId), scopeName), key, value); + + public ValueTask WriteStateAsync(ScopeId scopeId, string key, T? value) + { + Throw.IfNullOrEmpty(key); + + UpdateKey stateKey = new(scopeId, key); + StateUpdate update = value == null ? StateUpdate.Delete(key) : StateUpdate.Update(key, value); + this._queuedUpdates[stateKey] = update; + + return default; + } + + public async ValueTask PublishUpdatesAsync() + { + Dictionary>> updatesByScope = new(); + + // Aggregate the updates for each scope + foreach (UpdateKey key in this._queuedUpdates.Keys) + { + if (!updatesByScope.TryGetValue(key.ScopeId, out Dictionary>? scopeUpdates)) + { + updatesByScope[key.ScopeId] = scopeUpdates = new(); + } + + if (!scopeUpdates.TryGetValue(key.Key, out List? stateUpdates)) + { + scopeUpdates[key.Key] = stateUpdates = new(); + } + + stateUpdates.Add(this._queuedUpdates[key]); + } + + foreach (ScopeId scope in updatesByScope.Keys) + { + StateScope stateScope = this.GetOrCreateScope(scope); + await stateScope.WriteStateAsync(updatesByScope[scope]).ConfigureAwait(false); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/StateScope.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StateScope.cs new file mode 100644 index 0000000000..b832a9bb4a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/StateScope.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class StateScope +{ + private readonly Dictionary _stateData = new(); + public ScopeId ScopeId { get; } + + public StateScope(ScopeId scopeId) + { + this.ScopeId = Throw.IfNull(scopeId); + } + + public StateScope(string executor, string? scopeName = null) : this(new ScopeId(Throw.IfNullOrEmpty(executor), scopeName)) + { + } + + public ValueTask ReadStateAsync(string key) + { + Throw.IfNullOrEmpty(key); + if (this._stateData.TryGetValue(key, out object? value) && value is T typedValue) + { + return new ValueTask(typedValue); + } + + return new ValueTask((T?)default); + } + + public ValueTask WriteStateAsync(Dictionary> updates) + { + Throw.IfNull(updates); + + foreach (string key in updates.Keys) + { + if (updates[key].Count == 0) + { + continue; + } + + if (updates[key].Count > 1) + { + throw new InvalidOperationException($"Expected exactly one update for key '{key}'."); + } + + StateUpdate upadte = updates[key][0]; + if (upadte.IsDelete) + { + this._stateData.Remove(key); + } + else + { + this._stateData[key] = upadte.Value!; + } + } + + return default; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/StateUpdate.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StateUpdate.cs new file mode 100644 index 0000000000..8924e243a2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/StateUpdate.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Execution; + +internal sealed class StateUpdate +{ + public string Key { get; } + public object? Value { get; } + public bool IsDelete { get; } + + private StateUpdate(string key, object? value, bool isDelete = false) + { + this.Key = Throw.IfNullOrEmpty(key); + this.Value = value; + this.IsDelete = isDelete; + } + + public static StateUpdate Update(string key, T? value) + { + return new StateUpdate(key, value, value is null); + } + + public static StateUpdate Delete(string key) + { + Throw.IfNullOrEmpty(key); + return new StateUpdate(key, null, isDelete: true); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/UpdateKey.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/UpdateKey.cs new file mode 100644 index 0000000000..15496cedb9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/UpdateKey.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Execution; + +internal class UpdateKey(ScopeId scopeId, string key) +{ + public ScopeId ScopeId { get; } = Throw.IfNull(scopeId); + public string Key { get; } = Throw.IfNullOrEmpty(key); + + public UpdateKey(string executorId, string? scopeName, string key) + : this(new ScopeId(Throw.IfNullOrEmpty(executorId), scopeName), key) + { } + + public override string ToString() + { + return $"{this.ScopeId}/{this.Key}"; + } + + public override bool Equals(object? obj) + { + if (obj is UpdateKey other) + { + // Unlike ScopeId, UpdateKey is equal only if both the Executor and ScopeName are the same + return this.ScopeId.ExecutorId == other.ScopeId.ExecutorId && + this.ScopeId.ScopeName == other.ScopeId.ScopeName && + this.Key == other.Key; + } + + return false; + } + + public override int GetHashCode() + { + return HashCode.Combine(this.ScopeId.ExecutorId, this.ScopeId.ScopeName, this.Key); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs new file mode 100644 index 0000000000..1aeff7da3c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Execution; + +namespace Microsoft.Agents.Workflows.UnitTests; + +public class StateSmokeTest +{ + [Fact] + public void Test_ScopeId_Equality() + { + // The rules of ScopeId are simple: Private executor scopes (executorId, scopeId=null) are only equal to + // themselves. Public ScopeIds are equal when their scopeNames are equal, regardless of executorId. + + ScopeId privateScope1 = new("executor1", null); + ScopeId privateScope2 = new("executor2", null); + + Assert.NotEqual(privateScope1, privateScope2); + Assert.Equal(privateScope1, new ScopeId("executor1", null)); + + ScopeId sharedScope1 = new("executor1", "sharedScope"); + ScopeId sharedScope2 = new("executor2", "sharedScope"); + + Assert.Equal(sharedScope1, sharedScope2); + Assert.NotEqual(sharedScope1, new ScopeId("executor1", "differentScope")); + Assert.NotEqual(sharedScope1, privateScope1); + } + + [Fact] + public void Test_UpdateKey_Equality() + { + // The rules of UpdateKey are different from ScopeId. In the case of "shared scope", + // two update keys with different ExecutorIds are not the same. + + const string Key1 = "key1"; + const string Key2 = "key2"; + UpdateKey privateScope1Key = new("executor1", null, Key1); + UpdateKey privateScope1Key2 = new("executor1", null, Key2); + + Assert.NotEqual(privateScope1Key, privateScope1Key2); + + UpdateKey privateScope2Key = new("executor2", null, Key1); + + Assert.NotEqual(privateScope1Key, privateScope2Key); + + UpdateKey scope1Executor1Key = new("executor1", "sharedScope", Key1); + UpdateKey scope1Executor2Key = new("executor2", "sharedScope", Key1); + + Assert.NotEqual(scope1Executor1Key, scope1Executor2Key); + } + + [Fact] + public async Task Test_ReadQueueUpdateAsync() + { + ScopeId sharedScope1 = new("executor1", "sharedScope"); + ScopeId sharedScope2 = new("executor2", "sharedScope"); + + StateManager manager = new(); + + // Default reads on "object" should be null + const string Key = "key1"; + Assert.Null(await manager.ReadStateAsync(sharedScope1, Key)); + Assert.Null(await manager.ReadStateAsync(sharedScope1, Key)); + + await manager.WriteStateAsync(sharedScope1, Key, new object()); + + // After writing, we should be able to read the value from the executor's scope + // but not the shared scope yet + Assert.NotNull(await manager.ReadStateAsync(sharedScope1, Key)); + Assert.Null(await manager.ReadStateAsync(sharedScope2, Key)); + + // Writes to one key should not impact other keys + Assert.Null(await manager.ReadStateAsync(sharedScope1, "key2")); + + // Publish the write + await manager.PublishUpdatesAsync(); + + // Now all the executors should be able to see the new state + Assert.NotNull(await manager.ReadStateAsync(sharedScope1, Key)); + Assert.NotNull(await manager.ReadStateAsync(sharedScope2, Key)); + } + + [Fact] + public async Task Test_ConflictingWritesRaiseExceptionAsync() + { + ScopeId sharedScope1 = new("executor1", "sharedScope"); + ScopeId sharedScope2 = new("executor2", "sharedScope"); + + StateManager manager = new(); + + const string Key = "key1"; + const string Value1 = "1"; + const string Value2 = "2"; + + // Write values from both executors + await manager.WriteStateAsync(sharedScope1, Key, Value1); + await manager.WriteStateAsync(sharedScope2, Key, Value2); + + // Check that reading each will result in the right value + Assert.Equal(Value1, await manager.ReadStateAsync(sharedScope1, Key)); + Assert.Equal(Value2, await manager.ReadStateAsync(sharedScope2, Key)); + + // Try to publish the updates + try + { + await manager.PublishUpdatesAsync(); + Assert.Fail("Expected InvalidOperationException due to conflicting writes."); + } + catch (InvalidOperationException) + { + } + catch (Exception ex) + { + Assert.Fail($"Expected InvalidOperationException, but got {ex.GetType().Name}."); + } + } +} From 1596a26ebbbf3c6a31751b10fb3759ab1d28d051 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Wed, 13 Aug 2025 08:23:52 -0700 Subject: [PATCH 144/232] Add tests --- .../Execution/EditTableV2Executor.cs | 19 +- .../Execution/WorkflowActionExecutor.cs | 17 +- .../Extensions/DataValueExtensions.cs | 4 +- .../Extensions/RecordDataTypeExtensions.cs | 8 +- .../PowerFx/WorkflowExpressionEngine.cs | 4 +- .../ClearAllVariablesExecutorTest.cs | 68 ++++++ .../Execution/ResetVariableExecutorTest.cs | 73 ++++++ .../Execution/SendActivityExecutorTest.cs | 56 +++++ .../Execution/SetTextVariableExecutorTest.cs | 71 ++++++ .../Execution/SetVariableExecutorTest.cs | 209 ++++++++++++++++++ .../Execution/WorkflowActionExecutorTest.cs | 73 ++++++ ...nts.Workflows.Declarative.UnitTests.csproj | 1 + ...nScopesTests.cs => WorkflowScopesTests.cs} | 2 +- 13 files changed, 588 insertions(+), 17 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ClearAllVariablesExecutorTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ResetVariableExecutorTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetTextVariableExecutorTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetVariableExecutorTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs rename dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/{ProcessActionScopesTests.cs => WorkflowScopesTests.cs} (99%) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs index fcdac7c7cb..516fa18523 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Extensions; @@ -19,7 +20,10 @@ protected async override ValueTask ExecuteAsync(CancellationToken cancellationTo PropertyPath variablePath = Throw.IfNull(this.Model.ItemsVariable?.Path, $"{nameof(this.Model)}.{nameof(this.Model.ItemsVariable)}"); FormulaValue table = this.Context.Scopes.Get(variablePath.VariableName!, WorkflowScopeType.Parse(variablePath.VariableScopeName)); - TableValue tableValue = (TableValue)table; + if (table is not TableValue tableValue) + { + throw new WorkflowExecutionException($"Require '{variablePath.Format()}' to be a table, not: '{table.GetType().Name}'."); + } EditTableOperation? changeType = this.Model.ChangeType; if (changeType is AddItemOperation addItemOperation) @@ -33,12 +37,20 @@ protected async override ValueTask ExecuteAsync(CancellationToken cancellationTo else if (changeType is ClearItemsOperation) { await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false); + this.AssignTarget(this.Context, variablePath, tableValue); } - else if (changeType is RemoveItemOperation) // %%% SUPPORT + else if (changeType is RemoveItemOperation removeItemOperation) { + ValueExpression removeItemValue = Throw.IfNull(removeItemOperation.Value, $"{nameof(this.Model)}.{nameof(this.Model.ChangeType)}"); + EvaluationResult result = this.Context.ExpressionEngine.GetValue(removeItemValue, this.Context.Scopes); + if (result.Value.ToFormulaValue() is TableValue removeItemTable) + { + await tableValue.RemoveAsync(removeItemTable?.Rows.Select(row => row.Value), all: true, cancellationToken).ConfigureAwait(false); + } } - else if (changeType is TakeFirstItemOperation) // %%% SUPPORT + else if (changeType is TakeFirstItemOperation) { + this.AssignTarget(this.Context, variablePath, tableValue.Rows.First().Value); // %%% TABLE OR RECORD ??? } static RecordValue BuildRecord(RecordType recordType, FormulaValue value) @@ -47,7 +59,6 @@ static RecordValue BuildRecord(RecordType recordType, FormulaValue value) IEnumerable GetValues() { - // %%% TODO: expression.StructuredRecordExpression.Properties ??? foreach (NamedFormulaType fieldType in recordType.GetFieldTypes()) { if (value is RecordValue recordValue) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs index 7e4ce56bfd..f50ec0fc76 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs @@ -20,8 +20,8 @@ internal abstract class WorkflowActionExecutor(TAction model) : public new TAction Model => (TAction)base.Model; } -internal abstract class WorkflowActionExecutor(DialogAction model) : - Executor(model.Id.Value), +internal abstract class WorkflowActionExecutor : + Executor, IMessageHandler { public const string RootActionId = "(root)"; @@ -29,9 +29,20 @@ internal abstract class WorkflowActionExecutor(DialogAction model) : private string? _parentId; private WorkflowExecutionContext? _context; + public WorkflowActionExecutor(DialogAction model) + : base(model.Id.Value) + { + if (!model.HasRequiredProperties) + { + throw new WorkflowModelException($"Missing required properties for element: {model.GetId()} ({model.GetType().Name})."); + } + + this.Model = model; + } + public string ParentId => this._parentId ??= this.Model.GetParentId() ?? RootActionId; - public DialogAction Model { get; } = model; + public DialogAction Model { get; } protected WorkflowExecutionContext Context => this._context ?? diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs index 243502aeb0..8d144e65d6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs @@ -21,7 +21,7 @@ public static FormulaValue ToFormulaValue(this DataValue? value) => DateTimeDataValue dateTimeValue => FormulaValue.New(dateTimeValue.Value.DateTime), DateDataValue dateValue => FormulaValue.NewDateOnly(dateValue.Value), TimeDataValue timeValue => FormulaValue.New(timeValue.Value), - TableDataValue tableValue => FormulaValue.NewTable(ParseRecordType(tableValue.Values.First()), tableValue.Values.Select(value => value.ToRecordValue())), + TableDataValue tableValue => FormulaValue.NewTable(tableValue.Values.First().ParseRecordType(), tableValue.Values.Select(value => value.ToRecordValue())), RecordDataValue recordValue => recordValue.ToRecordValue(), //FileDataValue // %%% SUPPORT ??? //OptionDataValue // %%% SUPPORT - Enum ??? @@ -51,7 +51,7 @@ public static RecordValue ToRecordValue(this RecordDataValue recordDataValue) => recordDataValue.Properties.Select( property => new NamedValue(property.Key, property.Value.ToFormulaValue()))); - private static RecordType ParseRecordType(RecordDataValue record) + public static RecordType ParseRecordType(this RecordDataValue record) { RecordType recordType = RecordType.Empty(); foreach (KeyValuePair property in record.Properties) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs index 38ca8ad053..f01666b01b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs @@ -24,11 +24,11 @@ IEnumerable ParseValues() StringDataType => StringValue.New(propertyElement.GetString()), NumberDataType => NumberValue.New(propertyElement.GetDecimal()), BooleanDataType => BooleanValue.New(propertyElement.GetBoolean()), - DateTimeDataType dateTimeType => DateTimeValue.New(propertyElement.GetDateTime()), - DateDataType dateType => DateValue.New(propertyElement.GetDateTime()), - TimeDataType timeType => TimeValue.New(propertyElement.GetDateTimeOffset().TimeOfDay), + DateTimeDataType => DateTimeValue.New(propertyElement.GetDateTime()), + DateDataType => DateValue.New(propertyElement.GetDateTime()), + TimeDataType => TimeValue.New(propertyElement.GetDateTimeOffset().TimeOfDay), RecordDataType recordType => recordType.ParseRecord(propertyElement), - //TableDataValue tableValue => // %%% SUPPORT + //TableDataType tableType => FormulaValue.NewSingleColumnTable(propertyElement.EnumerateArray().Select(item => // %%% SUPPORT: Table ))) _ => throw new UnknownDataTypeException($"Unsupported data type '{property.Value.Type}' for property '{property.Key}'") }; yield return new NamedValue(property.Key, parsedValue); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs index cb6cd83fc7..a614b9f887 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs @@ -121,9 +121,7 @@ private EvaluationResult GetValue(StringExpression expression, T if (expressionResult.Value is RecordValue recordValue) { - JsonSerializerContext context = null!; // %%% HAXX - AOT - //context.Options = s_options; - return new EvaluationResult(JsonSerializer.Serialize(recordValue, typeof(RecordValue), context), expressionResult.Sensitivity); + return new EvaluationResult(recordValue.Format(), expressionResult.Sensitivity); } if (expressionResult.Value is not StringValue formulaValue) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ClearAllVariablesExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ClearAllVariablesExecutorTest.cs new file mode 100644 index 0000000000..12ae84be2d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ClearAllVariablesExecutorTest.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Execution; + +/// +/// Tests for . +/// +public sealed class ClearAllVariablesExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public async Task ClearWorkflowScope() + { + // Arrange + this.Scopes.Set("NoVar", FormulaValue.New("Old value")); + + ClearAllVariables model = + this.CreateModel( + this.FormatDisplayName(nameof(ClearWorkflowScope)), + VariablesToClear.ConversationScopedVariables); + + // Act + ClearAllVariablesExecutor action = new(model); + await this.Execute(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyUndefined("NoVar"); + } + + [Fact] + public async Task ClearUndefinedScope() + { + // Arrange + ClearAllVariables model = + this.CreateModel( + this.FormatDisplayName(nameof(ClearUndefinedScope)), + VariablesToClear.UserScopedVariables); + + // Act + ClearAllVariablesExecutor action = new(model); + await this.Execute(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyUndefined("NoVar"); + } + + private ClearAllVariables CreateModel(string displayName, VariablesToClear variableTarget) + { + ClearAllVariables.Builder actionBuilder = + new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + Variables = EnumExpression.Literal(VariablesToClearWrapper.Get(variableTarget)), + }; + + ClearAllVariables model = this.AssignParent(actionBuilder); + + return model; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ResetVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ResetVariableExecutorTest.cs new file mode 100644 index 0000000000..f0273b4fae --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ResetVariableExecutorTest.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Execution; + +/// +/// Tests for . +/// +public sealed class ResetVariableTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public async Task ResetDefinedValue() + { + // Arrange + this.Scopes.Set("MyVar1", FormulaValue.New("Value #1")); + this.Scopes.Set("MyVar2", FormulaValue.New("Value #2")); + + ResetVariable model = + this.CreateModel( + this.FormatDisplayName(nameof(ResetDefinedValue)), + FormatVariablePath("MyVar1")); + + // Act + ResetVariableExecutor action = new(model); + await this.Execute(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyUndefined("MyVar1"); + this.VerifyState("MyVar2", FormulaValue.New("Value #2")); + } + + [Fact] + public async Task ResetUndefinedValue() + { + // Arrange + this.Scopes.Set("MyVar1", FormulaValue.New("Value #1")); + + ResetVariable model = + this.CreateModel( + this.FormatDisplayName(nameof(ResetUndefinedValue)), + FormatVariablePath("NoVar")); + + // Act + ResetVariableExecutor action = new(model); + await this.Execute(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyUndefined("NoVar"); + this.VerifyState("MyVar1", FormulaValue.New("Value #1")); + } + + private ResetVariable CreateModel(string displayName, string variablePath) + { + ResetVariable.Builder actionBuilder = + new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + Variable = InitializablePropertyPath.Create(variablePath), + }; + + ResetVariable model = this.AssignParent(actionBuilder); + + return model; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs new file mode 100644 index 0000000000..aea76671af --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.IO; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.Agents.Workflows.Declarative.Execution; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Execution; + +/// +/// Tests for . +/// +public sealed class SendActivityExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public async Task CaptureActivity() + { + // Arrange + SendActivity model = + this.CreateModel( + this.FormatDisplayName(nameof(CaptureActivity)), + "Test activity message"); + using StringWriter activityWriter = new(); + + // Act + SendActivityExecutor action = new(model, activityWriter); + await this.Execute(action); + activityWriter.Flush(); + + // Assert + this.VerifyModel(model, action); + Assert.NotEmpty(activityWriter.ToString()); + } + + private SendActivity CreateModel(string displayName, string activityMessage, string? summary = null) + { + MessageActivityTemplate.Builder activityBuilder = + new() + { + Summary = summary, + Text = { TemplateLine.Parse(activityMessage) }, + }; + SendActivity.Builder actionBuilder = + new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + Activity = activityBuilder.Build(), + }; + + SendActivity model = this.AssignParent(actionBuilder); + + return model; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetTextVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetTextVariableExecutorTest.cs new file mode 100644 index 0000000000..87bd5ef642 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetTextVariableExecutorTest.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Execution; + +/// +/// Tests for . +/// +public sealed class SetTextVariableExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public async Task SetLiteralValue() + { + // Arrange + SetTextVariable model = + this.CreateModel( + this.FormatDisplayName(nameof(SetLiteralValue)), + FormatVariablePath("TextVar"), + "Text variable value"); + + // Act + SetTextVariableExecutor action = new(model); + await this.Execute(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyState("TextVar", FormulaValue.New("Text variable value")); + } + + [Fact] + public async Task UpdateExistingValue() + { + // Arrange + this.Scopes.Set("TextVar", FormulaValue.New("Old value")); + + SetTextVariable model = + this.CreateModel( + this.FormatDisplayName(nameof(UpdateExistingValue)), + FormatVariablePath("TextVar"), + "New value"); + + // Act + SetTextVariableExecutor action = new(model); + await this.Execute(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyState("TextVar", FormulaValue.New("New value")); + } + + private SetTextVariable CreateModel(string displayName, string variablePath, string textValue) + { + SetTextVariable.Builder actionBuilder = + new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + Variable = InitializablePropertyPath.Create(variablePath), + Value = TemplateLine.Parse(textValue), + }; + + SetTextVariable model = this.AssignParent(actionBuilder); + + return model; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetVariableExecutorTest.cs new file mode 100644 index 0000000000..92357e38a6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetVariableExecutorTest.cs @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Execution; + +/// +/// Tests for . +/// +public sealed class SetVariableExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public void InvalidModel() + { + // Arrange, Act, Assert + Assert.Throws(() => new SetVariableExecutor(new SetVariable())); + } + + [Fact] + public async Task SetNumericValue() + { + // Arrange, Act, Assert + await this.ExecuteTest( + displayName: nameof(SetNumericValue), + variableName: "TestVariable", + variableValue: new NumberDataValue(42), + expectedValue: FormulaValue.New(42)); + } + + [Fact] + public async Task SetStringValue() + { + // Arrange, Act, Assert + await this.ExecuteTest( + displayName: nameof(SetStringValue), + variableName: "TestVariable", + variableValue: new StringDataValue("Text"), + expectedValue: FormulaValue.New("Text")); + } + + [Fact] + public async Task SetBooleanValue() + { + // Arrange, Act, Assert + await this.ExecuteTest( + displayName: nameof(SetBooleanValue), + variableName: "TestVariable", + variableValue: new BooleanDataValue(true), + expectedValue: FormulaValue.New(true)); + } + + [Fact] + public async Task SetBooleanExpression() + { + // Arrange + ValueExpression.Builder expressionBuilder = new(ValueExpression.Expression("true || false")); + + // Act, Assert + await this.ExecuteTest( + displayName: nameof(SetBooleanExpression), + variableName: "TestVariable", + valueExpression: expressionBuilder, + expectedValue: FormulaValue.New(true)); + } + + [Fact] + public async Task SetNumberExpression() + { + // Arrange + ValueExpression.Builder expressionBuilder = new(ValueExpression.Expression("9 - 3")); + + // Act, Assert + await this.ExecuteTest( + displayName: nameof(SetBooleanExpression), + variableName: "TestVariable", + valueExpression: expressionBuilder, + expectedValue: FormulaValue.New(6)); + } + + [Fact] + public async Task SetStringExpression() + { + // Arrange + ValueExpression.Builder expressionBuilder = new(ValueExpression.Expression(@"Concatenate(""A"", ""B"", ""C"")")); + + // Act, Assert + await this.ExecuteTest( + displayName: nameof(SetBooleanExpression), + variableName: "TestVariable", + valueExpression: expressionBuilder, + expectedValue: FormulaValue.New("ABC")); + } + + [Fact] + public async Task SetBooleanVariable() + { + // Arrange + this.Scopes.Set("Source", FormulaValue.New(true)); + ValueExpression.Builder expressionBuilder = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source"))); + + // Act, Assert + await this.ExecuteTest( + displayName: nameof(SetBooleanExpression), + variableName: "TestVariable", + valueExpression: expressionBuilder, + expectedValue: FormulaValue.New(true)); + } + + [Fact] + public async Task SetNumberVariable() + { + // Arrange + this.Scopes.Set("Source", FormulaValue.New(321)); + ValueExpression.Builder expressionBuilder = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source"))); + + // Act, Assert + await this.ExecuteTest( + displayName: nameof(SetBooleanExpression), + variableName: "TestVariable", + valueExpression: expressionBuilder, + expectedValue: FormulaValue.New(321)); + } + + [Fact] + public async Task SetStringVariable() + { + // Arrange + this.Scopes.Set("Source", FormulaValue.New("Test")); + ValueExpression.Builder expressionBuilder = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source"))); + + // Act, Assert + await this.ExecuteTest( + displayName: nameof(SetBooleanExpression), + variableName: "TestVariable", + valueExpression: expressionBuilder, + expectedValue: FormulaValue.New("Test")); + } + + [Fact] + public async Task UpdateExistingValue() + { + // Arrange + this.Scopes.Set("VarA", FormulaValue.New(33)); + + // Act, Assert + await this.ExecuteTest( + displayName: nameof(UpdateExistingValue), + variableName: "VarA", + variableValue: new NumberDataValue(42), + expectedValue: FormulaValue.New(42)); + } + + private Task ExecuteTest( + string displayName, + string variableName, + DataValue variableValue, + FormulaValue expectedValue) + { + // Arrange + ValueExpression.Builder expressionBuilder = new(ValueExpression.Literal(variableValue)); + + // Act & Assert + return this.ExecuteTest(displayName, variableName, expressionBuilder, expectedValue); + } + + private async Task ExecuteTest( + string displayName, + string variableName, + ValueExpression.Builder valueExpression, + FormulaValue expectedValue) + { + // Arrange + SetVariable model = + this.CreateModel( + displayName, + FormatVariablePath(variableName), + valueExpression); + + this.Scopes.Set(variableName, FormulaValue.New(33)); + + // Act + SetVariableExecutor action = new(model); + await this.Execute(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyState(variableName, expectedValue); + } + + private SetVariable CreateModel(string displayName, string variablePath, ValueExpression.Builder valueExpression) + { + SetVariable.Builder actionBuilder = + new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + Variable = InitializablePropertyPath.Create(variablePath), + Value = valueExpression, + }; + + SetVariable model = this.AssignParent(actionBuilder); + + return model; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs new file mode 100644 index 0000000000..e25feffd99 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Agents.Workflows.Execution; +using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Execution; + +/// +/// Base test class for implementations. +/// +public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : WorkflowTest(output) +{ + internal WorkflowScopes Scopes { get; } = new(); + + protected ActionId CreateActionId() => new($"{this.GetType().Name}_{Guid.NewGuid():N}"); + + protected string FormatDisplayName(string name) => $"{this.GetType().Name}_{name}"; + + internal async Task Execute(WorkflowActionExecutor executor) + { + WorkflowExecutionContext context = new(RecalcEngineFactory.Create(this.Scopes), this.Scopes, () => null!, NullLogger.Instance); + executor.Attach(context); + WorkflowBuilder workflowBuilder = new(executor); + LocalRunner runner = new(workflowBuilder.Build()); + StreamingRun handle = await runner.StreamAsync(""); + WorkflowEvent[] events = await handle.WatchStreamAsync().ToArrayAsync(); + } + + internal void VerifyModel(DialogAction model, WorkflowActionExecutor action) + { + Assert.Equal(model.Id, action.Id); + Assert.Equal(model, action.Model); + } + + protected void VerifyState(string variableName, FormulaValue expectedValue) => this.VerifyState(variableName, WorkflowScopeType.Topic, expectedValue); + + internal void VerifyState(string variableName, WorkflowScopeType scope, FormulaValue expectedValue) + { + FormulaValue actualValue = this.Scopes.Get(variableName, scope); + Assert.Equivalent(expectedValue, actualValue); + } + + protected void VerifyUndefined(string variableName) => this.VerifyUndefined(variableName, WorkflowScopeType.Topic); + + internal void VerifyUndefined(string variableName, WorkflowScopeType scope) + { + Assert.IsType(this.Scopes.Get(variableName, scope)); + } + + protected TAction AssignParent(DialogAction.Builder actionBuilder) where TAction : DialogAction + { + OnActivity.Builder activityBuilder = + new() + { + Id = new("root"), + }; + + activityBuilder.Actions.Add(actionBuilder); + + OnActivity model = activityBuilder.Build(); + + return (TAction)model.Actions[0]; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Microsoft.Agents.Workflows.Declarative.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Microsoft.Agents.Workflows.Declarative.UnitTests.csproj index 1a57b35e54..5bd84d8eb2 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Microsoft.Agents.Workflows.Declarative.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Microsoft.Agents.Workflows.Declarative.UnitTests.csproj @@ -2,6 +2,7 @@ $(ProjectsTargetFrameworks) + $(NoWarn);IDE1006 diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ProcessActionScopesTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowScopesTests.cs similarity index 99% rename from dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ProcessActionScopesTests.cs rename to dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowScopesTests.cs index cd53f3c457..7b053abd81 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ProcessActionScopesTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowScopesTests.cs @@ -6,7 +6,7 @@ namespace Microsoft.Agents.Workflows.Declarative.UnitTests; -public class ProcessActionScopesTests +public class WorkflowScopesTests { [Fact] public void ConstructorInitializesAllScopes() From dcc0a013d13b2f258f5fa491b5332d4483b90ee3 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Wed, 13 Aug 2025 16:35:18 -0700 Subject: [PATCH 145/232] Fix merge from main --- dotnet/agent-framework-dotnet.slnx | 3 + dotnet/demos/DeclarativeWorkflow/Program.cs | 8 +- .../Workflows/Workflows_Declarative.cs | 8 +- .../DeclarativeWorkflowBuilder.cs | 1 - .../Execution/DeclarativeWorkflowExecutor.cs | 6 +- .../Execution/WorkflowActionExecutor.cs | 6 +- .../Execution/WorkflowDelegateExecutor.cs | 4 +- .../Interpreter/WorkflowActionVisitor.cs | 3 +- .../Interpreter/WorkflowElementWalker.cs | 1 - .../Interpreter/WorkflowModel.cs | 3 +- .../Core/CallResult.cs | 76 ------ .../Microsoft.Agents.Workflows/Core/Edge.cs | 154 ----------- .../Microsoft.Agents.Workflows/Core/Events.cs | 133 ---------- .../Core/ExecutionConfiguration.cs | 14 - .../Core/Executor.cs | 145 ----------- .../Core/ExternalRequest.cs | 72 ------ .../Core/ExternalResponse.cs | 13 - .../Core/IIdentified.cs | 14 - .../Core/IMessageHandler.cs | 37 --- .../Core/IMessageRouter.cs | 16 -- .../Core/IWorkflowContext.cs | 52 ---- .../Core/InputPort.cs | 24 -- .../Core/Message.cs | 117 --------- .../Core/MessageHandlerInfo.cs | 135 ---------- .../Core/MessageRouter.cs | 58 ----- .../Core/ReflectionExtensions.cs | 54 ---- .../Core/RouteBuilder.cs | 134 ---------- .../Core/RouteBuilderExtensions.cs | 99 -------- .../Core/StreamsMessageAttribute.cs | 27 -- .../Core/ValueTaskTypeErasure.cs | 76 ------ .../Core/Workflow.cs | 103 -------- .../Execution/LocalRunner.cs | 240 ------------------ .../Execution/LocalRunnerContext.cs | 103 -------- .../Execution/Run.cs | 155 ----------- .../Execution/StreamingRun.cs | 212 ---------------- .../Execution/WorkflowActionExecutorTest.cs | 7 +- 36 files changed, 21 insertions(+), 2292 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/ExecutionConfiguration.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/ExternalRequest.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/ExternalResponse.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/IIdentified.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/IMessageHandler.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/IMessageRouter.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/InputPort.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/Message.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/MessageRouter.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/StreamsMessageAttribute.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Execution/Run.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingRun.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 253364f376..b7ea46cd12 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -13,6 +13,7 @@ + @@ -116,6 +117,7 @@ + @@ -129,6 +131,7 @@ + diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index 4f226b5406..1f72e3febb 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -6,9 +6,8 @@ using System.Reflection; using System.Threading.Tasks; using Azure.Identity; -using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows; using Microsoft.Agents.Workflows.Declarative; -using Microsoft.Agents.Workflows.Execution; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.Diagnostics; @@ -55,9 +54,8 @@ public static async Task Main(string[] args) ////////////////////////////////////////////// // Run the workflow, just like any other workflow - LocalRunner runner = new(workflow); - StreamingRun handle = await runner.StreamAsync(""); - await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + StreamingRun run = await InProcessExecution.StreamAsync(workflow, ""); + await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { if (evt is ExecutorInvokeEvent executorInvoked) { diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index a50ef232e7..0706571adc 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -6,9 +6,8 @@ using System.Text.Json; using Azure.Identity; using Microsoft.Agents.Orchestration; -using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows; using Microsoft.Agents.Workflows.Declarative; -using Microsoft.Agents.Workflows.Execution; using Microsoft.Shared.Diagnostics; using Microsoft.Shared.Samples; @@ -73,9 +72,8 @@ public async Task RunWorkflow(string fileName, bool enableApiIntercept = false) Debug.WriteLine("\nWORKFLOW INVOKE\n"); - LocalRunner runner = new(workflow); - StreamingRun handle = await runner.StreamAsync(""); - await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + StreamingRun run = await InProcessExecution.StreamAsync(workflow, ""); + await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { if (evt is ExecutorInvokeEvent executorInvoked) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index c1cab1fd47..9d29814fbb 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -2,7 +2,6 @@ using System.Diagnostics; using System.IO; -using Microsoft.Agents.Workflows.Core; using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Agents.Workflows.Declarative.PowerFx; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs index b6271e7f0f..e95927e575 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs @@ -2,8 +2,8 @@ using System; using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Core; using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Agents.Workflows.Reflection; using Microsoft.PowerFx.Types; namespace Microsoft.Agents.Workflows.Declarative.Execution; @@ -13,8 +13,8 @@ namespace Microsoft.Agents.Workflows.Declarative.Execution; /// /// The unique identifier for the workflow. /// Scoped variable state for workflow execution. -internal sealed class DeclarativeWorkflowExecutor(string workflowId,WorkflowScopes scopes) : - Executor(workflowId), +internal sealed class DeclarativeWorkflowExecutor(string workflowId, WorkflowScopes scopes) : + ReflectingExecutor(workflowId), IMessageHandler { public async ValueTask HandleAsync(string message, IWorkflowContext context) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs index f50ec0fc76..518c2c3e8a 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs @@ -4,9 +4,9 @@ using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Core; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Agents.Workflows.Reflection; using Microsoft.Bot.ObjectModel; using Microsoft.Extensions.Logging; using Microsoft.PowerFx.Types; @@ -21,7 +21,7 @@ internal abstract class WorkflowActionExecutor(TAction model) : } internal abstract class WorkflowActionExecutor : - Executor, + ReflectingExecutor, IMessageHandler { public const string RootActionId = "(root)"; @@ -29,7 +29,7 @@ internal abstract class WorkflowActionExecutor : private string? _parentId; private WorkflowExecutionContext? _context; - public WorkflowActionExecutor(DialogAction model) + protected WorkflowActionExecutor(DialogAction model) : base(model.Id.Value) { if (!model.HasRequiredProperties) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowDelegateExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowDelegateExecutor.cs index 15d2868d37..26f5764f23 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowDelegateExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowDelegateExecutor.cs @@ -2,12 +2,12 @@ using System; using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Core; +using Microsoft.Agents.Workflows.Reflection; namespace Microsoft.Agents.Workflows.Declarative.Execution; internal sealed class WorkflowDelegateExecutor(string actionId, Func action) : - Executor(actionId), + ReflectingExecutor(actionId), IMessageHandler { public async ValueTask HandleAsync(string message, IWorkflowContext context) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index e036a106ce..416b31815a 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using System.Linq; using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Core; using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.PowerFx; @@ -443,7 +442,7 @@ private void ContinueWith( } private void ContinueWith( - ExecutorBase executor, + Executor executor, string parentId, Func? condition = null, Action? completionHandler = null) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowElementWalker.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowElementWalker.cs index 366ada41c0..9e5de528d2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowElementWalker.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowElementWalker.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using Microsoft.Agents.Workflows.Core; using Microsoft.Bot.ObjectModel; namespace Microsoft.Agents.Workflows.Declarative.Interpreter; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs index 1712efc151..9c3f552f66 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; -using Microsoft.Agents.Workflows.Core; namespace Microsoft.Agents.Workflows.Declarative.Interpreter; @@ -39,7 +38,7 @@ public int GetDepth(string? nodeId) return sourceNode.Depth; } - public void AddNode(ExecutorBase executor, string parentId, Action? completionHandler = null) + public void AddNode(Executor executor, string parentId, Action? completionHandler = null) { if (!this.Nodes.TryGetValue(parentId, out ModelNode? parentNode)) { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs deleted file mode 100644 index 79df6aba82..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/CallResult.cs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// This class represents the result of a call to a -/// or . -/// -internal sealed class CallResult -{ - /// - /// Indicates whether the call was void (i.e., no result expected). This only applies to - /// calls to handlers. - /// - public bool IsVoid { get; init; } - - /// - /// If the call was successful, this property contains the result of the call. For calls to - /// void handlers, this will be null. - /// - public object? Result { get; init; } = null; - - /// - /// If the call failed, this property contains the exception that was raised during the call. - /// - public Exception? Exception { get; init; } = null; - - /// - /// Indicates whether the call was successful. A call is considered successful if it returned - /// without throwing an exception. - /// - public bool IsSuccess => this.Exception == null; - - private CallResult(bool isVoid = false) - { - // Private constructor to enforce use of static methods. - this.IsVoid = isVoid; - } - - /// - /// Create a indicating a successful call that returned a result (non-void). - /// - /// The result to return. - /// A indicating the result of the call. - public static CallResult ReturnResult(object? result = null) - { - return new() { Result = result }; - } - - /// - /// Create a indicating a successful call that returned no result (void). - /// - /// A indicating the result of the call. - public static CallResult ReturnVoid() - { - return new(isVoid: true); - } - - /// - /// Create a indicating that an exception was raised during the call. - /// - /// A boolean specifying whether the call was void (was not expected to return - /// a value). - /// The exception that was raised during the call. - /// A indicating the result of the call. - /// Thrown when is null. - public static CallResult RaisedException(bool wasVoid, Exception exception) - { - Throw.IfNull(exception); - - return new(wasVoid) { Exception = exception }; - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs deleted file mode 100644 index e2c6bdbebd..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Edge.cs +++ /dev/null @@ -1,154 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using Microsoft.Shared.Diagnostics; - -using PartitionerT = System.Func>; -using PredicateT = System.Func; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// Represents a directed edge between two nodes, optionally associated with a condition that determines whether the -/// edge is active. -/// -/// The id of the source executor node. -/// The id of the target executor node. -/// A predicate determining whether the edge is active for a given message. -public sealed class DirectEdgeData(string sourceId, string sinkId, PredicateT? condition = null) -{ - /// - /// The Id of the source node. - /// - public string SourceId => sourceId; - - /// - /// The Id of the destination node. - /// - public string SinkId => sinkId; - - /// - /// An optional predicate determining whether the edge is active for a given message. If , - /// the edge is always active when a message is generated by the source. - /// - public PredicateT? Condition => condition; -} - -/// -/// Represents a connection from a single node to a set of nodes, optionally associated with a paritition selector -/// function which maps incoming messages to a subset of the target set. -/// -/// The id of the source executor node. -/// A list of ids of the target executor nodes. -/// A function that maps an incoming message to a subset of the target executor nodes. -public sealed class FanOutEdgeData( - string sourceId, - List sinkIds, - PartitionerT? partitioner = null) -{ - /// - /// The Id of the source node. - /// - public string SourceId => sourceId; - - /// - /// The ordered list of Ids of the destination nodes. - /// - public List SinkIds => sinkIds; - - /// - /// A function mapping an incoming message to a subset of the target executor nodes (or optionally all of them). - /// If , all destination nodes are selected. - /// - public PartitionerT? PartitionAssigner => partitioner; -} - -/// -/// Represents a connection from a set of nodes to a single node. It will trigger either when all edges have data. -/// -/// An enumeration of ids of the source executor nodes. -/// The id of the target executor node. -public sealed class FanInEdgeData(List sourceIds, string sinkId) -{ - /// - /// The ordered list of Ids of the source nodes. - /// - public List SourceIds => sourceIds; - - /// - /// The Id of the destination node. - /// - public string SinkId => sinkId; - - internal Guid UniqueKey { get; } = Guid.NewGuid(); -} - -/// -/// Represents a connection or relationship between nodes, characterized by its type and associated data. -/// -/// -/// An can be of type , , or , as specified by the property. The property holds -/// additional information relevant to the edge, and its concrete type depends on the value of , functioning as a tagged union. -/// -public sealed class Edge -{ - /// - /// Specified the edge type. - /// - public enum Type - { - /// - /// A direct connection from one node to another. - /// - Direct, - /// - /// A connection from one node to a set of nodes. - /// - FanOut, - /// - /// A connection from a set of nodes to a single node. - /// - FanIn - } - - /// - /// Specifies the type of the edge, which determines how the edge is processed in the workflow. - /// - public Type EdgeType { get; init; } - - /// - /// The -dependent edge data. - /// - /// - /// - /// - public object Data { get; init; } - - internal Edge(DirectEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.Direct; - } - - internal Edge(FanOutEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.FanOut; - } - - internal Edge(FanInEdgeData data) - { - this.Data = Throw.IfNull(data); - - this.EdgeType = Type.FanIn; - } - - internal DirectEdgeData? DirectEdgeData => this.Data as DirectEdgeData; - internal FanOutEdgeData? FanOutEdgeData => this.Data as FanOutEdgeData; - internal FanInEdgeData? FanInEdgeData => this.Data as FanInEdgeData; -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs deleted file mode 100644 index 8aeb50e4b8..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Events.cs +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using Microsoft.Extensions.AI.Agents; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// Base class for -scoped events. -/// -public class WorkflowEvent(object? data = null) -{ - /// - /// Optional payload - /// - public object? Data => data; - - /// - public override string ToString() - { - if (this.Data != null) - { - return $"{this.GetType().Name}(Data: {this.Data.GetType()} = {this.Data})"; - } - - return $"{this.GetType().Name}()"; - } -} - -/// -/// Event triggered when a workflow starts execution. -/// -/// The message triggering the start of workflow execution. -public sealed class WorkflowStartedEvent(object? message = null) : WorkflowEvent(data: message); - -/// -/// Event triggered when a workflow completes execution. -/// -/// -/// The user is expected to raise this event from a terminating , or to build -/// the workflow with output capture using . -/// -/// The result of the execution of the workflow. -public sealed class WorkflowCompletedEvent(object? result = null) : WorkflowEvent(data: result); - -/// -/// Event triggered when a workflow encounters an error. -/// -/// -/// Optionally, the representing the error. -/// -public sealed class WorkflowErrorEvent(Exception? e) : WorkflowEvent(e); - -/// -/// Event triggered when a workflow encounters a warning-condition. -/// -/// The warning message. -public sealed class WorkflowWarningEvent(string message) : WorkflowEvent(message); - -/// -/// Event triggered when a workflow executor request external information. -/// -public sealed class RequestInputEvent(ExternalRequest request) : WorkflowEvent(request) -{ - /// - /// The request to be serviced and data payload associated with it. - /// - public ExternalRequest Request => request; -} - -/// -/// Base class for -scoped events. -/// -public class ExecutorEvent(string executorId, object? data) : WorkflowEvent(data) -{ - /// - /// The identifier of the executor that generated this event. - /// - public string ExecutorId => executorId; - - /// - public override string ToString() - { - if (this.Data != null) - { - return $"{this.GetType().Name}(Executor = {this.ExecutorId}, Data: {this.Data.GetType()} = {this.Data})"; - } - - return $"{this.GetType().Name}(Executor = {this.ExecutorId})"; - } -} - -/// -/// Event triggered when an executor handler is invoked. -/// -/// The unique identifier of the executor being invoked. -/// The invocation message. -public sealed class ExecutorInvokeEvent(string executorId, object message) : ExecutorEvent(executorId, data: message); - -/// -/// Event triggered when an executor handler has completed. -/// -/// The unique identifier of the executor that has completed. -/// The result produced by the executor upon completion, or null if no result is available. -public sealed class ExecutorCompleteEvent(string executorId, object? result) : ExecutorEvent(executorId, data: result); - -/// -/// Event triggered when an executor handler fails. -/// -/// The unique identifier of the executor that has failed. -/// The exception representing the error. -public sealed class ExecutorFailureEvent(string executorId, Exception? err) : ExecutorEvent(executorId, data: err); - -/// -/// Event triggered when an agent run is completed. -/// -public class AgentRunEvent : ExecutorEvent -{ - /// - /// Initializes a new instance of the class. - /// - /// The identifier of the executor that generated this event. - /// - public AgentRunEvent(string executorId, AgentRunResponse? response = null) : base(executorId, data: response) - { - this.Response = response; - } - - /// - /// Gets the content of the agent response. - /// - public AgentRunResponse? Response { get; } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/ExecutionConfiguration.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ExecutionConfiguration.cs deleted file mode 100644 index 15f3163fcf..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/ExecutionConfiguration.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// . -/// -public static class ExecutionConfiguration -{ - /// - /// . - /// - public static bool AutoSendMessageHandlerResultObject { get; set; } = true; -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs deleted file mode 100644 index 489f1c9f0a..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Executor.cs +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// A component that processes messages in a . -/// -[DebuggerDisplay("{GetType().Name}{Id}")] -public abstract class ExecutorBase : IIdentified -{ - /// - /// A unique identifier for the executor. - /// - public string Id { get; } - - /// - /// Initialize the executor with a unique identifier - /// - /// A optional unique identifier for the executor. If null, a type-tagged - /// UUID will be generated. - protected ExecutorBase(string? id = null) - { - this.Id = id ?? $"{this.GetType().Name}/{Guid.NewGuid():N}"; - } - - /// - /// Override this method to register handlers for the executor. The deafult implementation uses reflection to - /// look for implementations of and . - /// - protected abstract RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder); - - private MessageRouter? _router = null; - internal MessageRouter Router - { - get - { - if (this._router == null) - { - RouteBuilder routeBuilder = this.ConfigureRoutes(new RouteBuilder()); - this._router = routeBuilder.Build(); - } - - return this._router; - } - } - - /// - /// Process an incoming message using the registered handlers. - /// - /// The message to be processed by the executor. - /// The workflow context in which the executor executes. - /// A ValueTask representing the asynchronous operation, wrapping the output from the executor. - /// No handler found for the message type. - /// An exception is generated while handling the message. - public async ValueTask ExecuteAsync(object message, IWorkflowContext context) - { - await context.AddEventAsync(new ExecutorInvokeEvent(this.Id, message)).ConfigureAwait(false); - - CallResult? result = await this.Router.RouteMessageAsync(message, context, requireRoute: true) - .ConfigureAwait(false); - - ExecutorEvent executionResult; - if (result == null || result.IsSuccess) - { - executionResult = new ExecutorCompleteEvent(this.Id, result?.Result); - } - else - { - executionResult = new ExecutorFailureEvent(this.Id, result.Exception); - } - - await context.AddEventAsync(executionResult).ConfigureAwait(false); - - if (result == null) - { - throw new NotSupportedException( - $"No handler found for message type {message.GetType().Name} in executor {this.GetType().Name}."); - } - - if (!result.IsSuccess) - { - throw new TargetInvocationException($"Error invoking handler for {message.GetType()}", result.Exception!); - } - - if (result.IsVoid) - { - return null; // Void result. - } - - // If we had a real return type, raise it as a SendMessage; TODO: Should we have a way to disable this behaviour? - if (result.Result != null && ExecutionConfiguration.AutoSendMessageHandlerResultObject) - { - await context.SendMessageAsync(result.Result).ConfigureAwait(false); - } - - return result.Result; - } - - /// - /// A set of s, representing the messages this executor can handle. - /// - public ISet InputTypes => this.Router.IncomingTypes; - - /// - /// A set of s, representing the messages this executor can produce as output. - /// - public virtual ISet OutputTypes { get; } = new HashSet([typeof(object)]); - - /// - /// Checks if the executor can handle a specific message type. - /// - /// - /// - public bool CanHandle(Type messageType) => this.Router.CanHandle(messageType); -} - -/// -/// A component that processes messages in a . -/// -/// The actual type of the . -/// This is used to reflectively discover handlers for messages without violating ILTrim requirements. -/// -public class Executor< - [DynamicallyAccessedMembers( - ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation) - ] TExecutor - > : ExecutorBase where TExecutor : Executor -{ - /// - protected Executor(string? id = null) : base(id) - { } - - /// - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) - { - return routeBuilder.ReflectHandlers(this); - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ExternalRequest.cs deleted file mode 100644 index 0dcc3008a8..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/ExternalRequest.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// Represents a request to an external input port. -/// -/// The port to invoke. -/// A unique identifier for this request instance. -/// The data contained in the request. -public record ExternalRequest(InputPort Port, string RequestId, object Data) -{ - /// - /// Creates a new for the specified input port and data payload. - /// - /// The port to invoke. - /// The data contained in the request. - /// An optional unique identifier for this request instance. If null, a UUID will be generated. - /// An instance containing the specified port, data, and request identifier. - /// Thrown when the input data object does not match the expected request type. - public static ExternalRequest Create(InputPort port, [NotNull] object data, string? requestId = null) - { - if (!port.Request.IsAssignableFrom(Throw.IfNull(data).GetType())) - { - throw new InvalidOperationException( - $"Message type {data.GetType().Name} is not assignable to the request type {port.Request.Name} of input port {port.Id}."); - } - - requestId ??= Guid.NewGuid().ToString("N"); - - return new ExternalRequest(port, requestId, data); - } - - /// - /// Creates a new for the specified input port and data payload. - /// - /// The type of request data. - /// The input port that identifies the target endpoint for the request. Must not be null. - /// The data payload to include in the request. Must not be null. - /// An optional identifier for the request. If null, a default identifier may be assigned. - /// An instance containing the specified port, data, and request identifier. - public static ExternalRequest Create(InputPort port, T data, string? requestId = null) => Create(port, (object)Throw.IfNull(data), requestId); - - /// - /// Creates a new corresponding to the request, with the speicified data payload. - /// - /// The data contained in the response. - /// An instance corresponding to this request with the specified data. - /// Thrown when the input data object does not match the expected response type. - public ExternalResponse CreateResponse(object data) - { - if (!Throw.IfNull(this.Port).Response.IsAssignableFrom(Throw.IfNull(data).GetType())) - { - throw new InvalidOperationException( - $"Message type {data.GetType().Name} is not assignable to the response type {this.Port.Response.Name} of input port {this.Port.Id}."); - } - - return new ExternalResponse(this.Port, this.RequestId, data); - } - - /// - /// Creates a new corresponding to the request, with the speicified data payload. - /// - /// The type of the response data. - /// The data contained in the response. - /// An instance corresponding to this request with the specified data. - public ExternalResponse CreateResponse(T data) => this.CreateResponse((object)Throw.IfNull(data)); -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/ExternalResponse.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ExternalResponse.cs deleted file mode 100644 index 58ed3a1be9..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/ExternalResponse.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// Represents a request from an external input port. -/// -/// The port invoked. -/// The unique identifier of the corresponding request. -/// The data contained in the response. -public record ExternalResponse(InputPort Port, string RequestId, object Data) -{ -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/IIdentified.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IIdentified.cs deleted file mode 100644 index 3b58e89665..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/IIdentified.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// A tag interface for objects that have a unique identifier within an appropriate namespace. -/// -public interface IIdentified -{ - /// - /// The unique identifier. - /// - string Id { get; } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/IMessageHandler.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IMessageHandler.cs deleted file mode 100644 index 1da548d9ba..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/IMessageHandler.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// A message handler interface for handling messages of type . -/// -/// -public interface IMessageHandler -{ - /// - /// Handles the incoming message asynchronously. - /// - /// The message to handle. - /// The execution context. - /// A task that represents the asynchronous operation. - ValueTask HandleAsync(TMessage message, IWorkflowContext context); -} - -/// -/// A message handler interface for handling messages of type and -/// returning a result. -/// -/// The type of message to handle. -/// The type of result returned after handling the message. -public interface IMessageHandler -{ - /// - /// Handles the incoming message asynchronously. - /// - /// The message to handle. - /// The execution context. - /// A task that represents the asynchronous operation. - ValueTask HandleAsync(TMessage message, IWorkflowContext context); -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/IMessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IMessageRouter.cs deleted file mode 100644 index 8876220a8e..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/IMessageRouter.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Core; - -internal interface IMessageRouter -{ - HashSet IncomingTypes { get; } - - bool CanHandle(object message); - bool CanHandle(Type candidateType); - ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false); -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs deleted file mode 100644 index 911525a421..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/IWorkflowContext.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// Provides services for an during the execution of a workflow. -/// -public interface IWorkflowContext -{ - /// - /// Adds an event to the workflow's output queue. These events will be raised to the caller of the workflow at the - /// end of the current SuperStep. - /// - /// The event to be raised. - /// A representing the asynchronous operation. - ValueTask AddEventAsync(WorkflowEvent workflowEvent); - - /// - /// Queues a message to be sent to connected executors. The message will be sent during the next SuperStep. - /// - /// The message to be sent. - /// A representing the asynchronous operation. - ValueTask SendMessageAsync(object message); - - /// - /// Reads a state value from the workflow's state store. If no scope is provided, the executor's private - /// scope is used. - /// - /// The type of the state value. - /// The key of the state value. - /// The name of the scope. - /// A representing the asynchronous operation. - ValueTask ReadStateAsync(string key, string? scopeName = null); - - /// - /// Asynchronously updates the state of a queue entry identified by the specified key and optional scope. - /// - /// - /// Subsequent reads by this executor will result in the new value of the state. Other executors will only see - /// the new state starting from the next SuperStep. - /// - /// The type of the value to associate with the queue entry. - /// The unique identifier for the queue entry to update. Cannot be null or empty. - /// The value to set for the queue entry. If null, the entry's state may be cleared or reset depending on - /// implementation. - /// An optional name that specifies the scope within which the queue entry resides. If null, the default scope is - /// used. - /// A ValueTask that represents the asynchronous update operation. - ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null); -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/InputPort.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/InputPort.cs deleted file mode 100644 index bf49afb78f..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/InputPort.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// An external request port for a with the specified request and response types. -/// -/// -/// -/// -public record InputPort(string Id, Type Request, Type Response) -{ - /// - /// Creates a new instance configured for the specified request and response types. - /// - /// The type of the request messages that the input port will accept. - /// The type of the response messages that the input port will produce. - /// The unique identifier for the input port. - /// An instance associated with the specified , configured to handle - /// requests of type and responses of type . - public static InputPort Create(string id) => new(id, typeof(TRequest), typeof(TResponse)); -}; diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Message.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Message.cs deleted file mode 100644 index e94fad7848..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Message.cs +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using Microsoft.Shared.Diagnostics; -using ExecutorId = string; -// TODO: Unclear whether this should be forcibly a serializable type. -using MetadataValueT = object; -using RetryExceptionT = System.InvalidOperationException; -using TopicId = string; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// . -/// -public record MessageMetadata -{ - /// - /// . - /// - public string CorrelationId { get; init; } = Guid.NewGuid().ToString(); - /// - /// . - /// - public ExecutorId? SourceId { get; init; } - /// - /// . - /// - public ExecutorId? TargetId { get; init; } - /// - /// . - /// - public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; - /// - /// . - /// - public string IsoTimestamp => this.Timestamp.ToString("o"); - /// - /// . - /// - public TopicId? Topic { get; init; } - /// - /// . - /// - public int Priority { get; init; } = 0; // Higher values indicate higher priority. - /// - /// . - /// - public TimeSpan? Timeout { get; init; } = null; - - /// - /// . - /// - public int Retries { get; init; } = 0; - /// - /// . - /// - public int MaxRetries { get; init; } = 3; - - /// - /// . - /// - public IDictionary CustomData { get; init; } = new Dictionary(); -} - -/// -/// . -/// -/// -public record Message -{ - /// - /// . - /// - public TContent Content { get; init; } - - /// - /// . - /// - public Type ContentType => typeof(TContent); - - /// - /// . - /// - public MessageMetadata Metadata { get; init; } - - /// - /// . - /// - /// - /// - /// - public Message(TContent content, MessageMetadata metadata) - { - this.Content = Throw.IfNull(content); - this.Metadata = Throw.IfNull(metadata); - } - - /// - /// Creates a new message instance for a new target. - /// - /// The identifier of the target executor to associate with the message. - /// A new instance with the updated target identifier. - public Message WithTarget(ExecutorId targetId) - => this with { Metadata = this.Metadata with { TargetId = targetId } }; - - /// - /// Create a copy of this message for next retry attempt. - /// - /// A copy of this message with incremented retry count. - /// If the maximum number of retries has been exceeded. - public Message WithRetry() - => this.Metadata.Retries < this.Metadata.MaxRetries - ? this with { Metadata = this.Metadata with { Retries = this.Metadata.Retries + 1 } } - : throw new RetryExceptionT($"Maximum retries ({this.Metadata.MaxRetries}) exceeded for message with ID '{this.Metadata.CorrelationId}'."); -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs deleted file mode 100644 index 07a41ef5aa..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/MessageHandlerInfo.cs +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Reflection; -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Core; - -internal struct MessageHandlerInfo -{ - public Type InType { get; init; } - public Type? OutType { get; init; } = null; - - public MethodInfo HandlerInfo { get; init; } - public Func>? Unwrapper { get; init; } = null; - - public MessageHandlerInfo(MethodInfo handlerInfo) - { - // The method is one of the following: - // - ValueTask HandleAsync(TMessage message, IExecutionContext context) - // - ValueTask HandleAsync(TMessage message, IExecutionContext context) - this.HandlerInfo = handlerInfo; - - ParameterInfo[] parameters = handlerInfo.GetParameters(); - if (parameters.Length != 2) - { - throw new ArgumentException("Handler method must have exactly two parameters: TMessage and IExecutionContext.", nameof(handlerInfo)); - } - - if (parameters[1].ParameterType != typeof(IWorkflowContext)) - { - throw new ArgumentException("Handler method's second parameter must be of type IExecutionContext.", nameof(handlerInfo)); - } - - this.InType = parameters[0].ParameterType; - - Type decoratedReturnType = handlerInfo.ReturnType; - if (decoratedReturnType.IsGenericType && decoratedReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) - { - // If the return type is ValueTask, extract TResult. - Type[] returnRawTypes = decoratedReturnType.GetGenericArguments(); - Debug.Assert( - returnRawTypes.Length == 1, - "ValueTask should have exactly one generic argument."); - - this.OutType = returnRawTypes.Single(); - this.Unwrapper = ValueTaskTypeErasure.UnwrapperFor(this.OutType); - } - else if (decoratedReturnType == typeof(ValueTask)) - { - // If the return type is ValueTask, there is no output type. - this.OutType = null; - } - else - { - throw new ArgumentException("Handler method must return ValueTask or ValueTask.", nameof(handlerInfo)); - } - } - - public static Func> Bind(Func handlerAsync, bool checkType, Type? resultType = null, Func>? unwrapper = null) - { - return InvokeHandlerAsync; - - async ValueTask InvokeHandlerAsync(object message, IWorkflowContext workflowContext) - { - bool expectingVoid = resultType == null || resultType == typeof(void); - - try - { - object? maybeValueTask = handlerAsync(message, workflowContext); - - if (expectingVoid) - { - if (maybeValueTask is ValueTask vt) - { - await vt.ConfigureAwait(false); - return CallResult.ReturnVoid(); - } - - throw new InvalidOperationException( - "Handler method is expected to return ValueTask or ValueTask, but returned " + - $"{maybeValueTask?.GetType().Name ?? "null"}."); - } - - Debug.Assert(resultType != null, "Expected resultType to be non-null when not expecting void."); - if (unwrapper == null) - { - throw new InvalidOperationException( - $"Handler method is expected to return ValueTask<{resultType!.Name}>, but no unwrapper is available."); - } - - if (maybeValueTask == null) - { - throw new InvalidOperationException( - $"Handler method returned null, but a ValueTask<{resultType!.Name}> was expected."); - } - - object? result = await unwrapper(maybeValueTask).ConfigureAwait(false); - - if (checkType && result != null && !resultType.IsInstanceOfType(result)) - { - throw new InvalidOperationException( - $"Handler method returned an incompatible type: expected {resultType.Name}, got {result.GetType().Name}."); - } - - return CallResult.ReturnResult(result); - } - catch (Exception ex) - { - // If the handler throws an exception, return it in the CallResult. - return CallResult.RaisedException(wasVoid: expectingVoid, exception: ex); - } - } - } - - public Func> Bind< - [DynamicallyAccessedMembers( - ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation) - ] TExecutor - > - (Executor executor, bool checkType = false) - where TExecutor : Executor - { - MethodInfo handlerMethod = this.HandlerInfo; - return MessageHandlerInfo.Bind(InvokeHandler, checkType, this.OutType, this.Unwrapper); - - object? InvokeHandler(object message, IWorkflowContext workflowContext) - { - return handlerMethod.Invoke(executor, new object[] { message, workflowContext }); - } - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/MessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/MessageRouter.cs deleted file mode 100644 index 29281c63bc..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/MessageRouter.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -using MessageHandlerF = - System.Func< - object, // message - Microsoft.Agents.Workflows.Core.IWorkflowContext, // context - System.Threading.Tasks.ValueTask - >; - -namespace Microsoft.Agents.Workflows.Core; - -internal class MessageRouter : IMessageRouter -{ - private readonly Dictionary _typedHandlers; - private readonly bool _hasCatchall; - - internal MessageRouter(Dictionary handlers) - { - this._typedHandlers = Throw.IfNull(handlers); - this._hasCatchall = this._typedHandlers.ContainsKey(typeof(object)); - } - - public HashSet IncomingTypes => [.. this._typedHandlers.Keys]; - - public bool CanHandle(object message) => this.CanHandle(Throw.IfNull(message).GetType()); - - public bool CanHandle(Type candidateType) - { - // For now we only support routing to handlers registered on the exact type (no base type delegation). - return this._hasCatchall || this._typedHandlers.ContainsKey(candidateType); - } - - public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) - { - Throw.IfNull(message); - - CallResult? result = null; - - try - { - if (this._typedHandlers.TryGetValue(message.GetType(), out MessageHandlerF? handler)) - { - result = await handler(message, context).ConfigureAwait(false); - } - } - catch (Exception e) - { - result = CallResult.RaisedException(wasVoid: true, e); - } - - return result; - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs deleted file mode 100644 index 30ceb72f61..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/ReflectionExtensions.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; - -#if !NET -using System.Linq; -#endif - -namespace Microsoft.Agents.Workflows.Core; - -internal static class ReflectionDemands -{ - internal const DynamicallyAccessedMemberTypes ReflectedMethods = DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods; - internal const DynamicallyAccessedMemberTypes ReflectedInterfaces = DynamicallyAccessedMemberTypes.Interfaces; - - internal const DynamicallyAccessedMemberTypes RuntimeInterfaceDiscoveryAndInvocation = ReflectedMethods | ReflectedInterfaces; -} - -internal static class ReflectionExtensions -{ - public static object? ReflectionInvoke(this MethodInfo method, object? target, params object?[] arguments) - { -#if NET - return method.Invoke(target, BindingFlags.DoNotWrapExceptions, binder: null, arguments, culture: null); -#else - try - { - return method.Invoke(target, BindingFlags.Default, binder: null, arguments, culture: null); - } - catch (TargetInvocationException e) when (e.InnerException is not null) - { - // If we're targeting .NET Framework, such that BindingFlags.DoNotWrapExceptions - // is ignored, the original exception will be wrapped in a TargetInvocationException. - // Unwrap it and throw that original exception, maintaining its stack information. - System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(e.InnerException).Throw(); - throw; - } -#endif - } - - public static MethodInfo GetMethodFromGenericMethodDefinition(this Type specializedType, MethodInfo genericMethodDefinition) - { - Debug.Assert(specializedType.IsGenericType && specializedType.GetGenericTypeDefinition() == genericMethodDefinition.DeclaringType, "generic member definition doesn't match type."); -#if NET - return (MethodInfo)specializedType.GetMemberWithSameMetadataDefinitionAs(genericMethodDefinition); -#else - const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; - return specializedType.GetMethods(All).First(m => m.MetadataToken == genericMethodDefinition.MetadataToken); -#endif - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs deleted file mode 100644 index b7d5bc22b5..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilder.cs +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -using MessageHandlerF = - System.Func< - object, // message - Microsoft.Agents.Workflows.Core.IWorkflowContext, // context - System.Threading.Tasks.ValueTask - >; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// Provides a builder for configuring message type handlers for an . -/// -/// -/// Override the method to customize the routing of messages to handlers. By -/// default, uses reflection to find implementations of and -/// . -/// -public class RouteBuilder -{ - private readonly Dictionary _typedHandlers = new(); - - internal RouteBuilder AddHandler(Type messageType, MessageHandlerF handler, bool overwrite = false) - { - Throw.IfNull(messageType); - Throw.IfNull(handler); - - // Overwrite must be false if the type is not registered. Overwrite must be true if the type is registered. - if (this._typedHandlers.ContainsKey(messageType) == overwrite) - { - this._typedHandlers[messageType] = handler; - } - else if (overwrite) - { - // overwrite is true, but the type is not registered. - throw new ArgumentException($"A handler for message type {messageType.FullName} has not yet been registered (overwrite = true)."); - } - else if (!overwrite) - { - throw new ArgumentException($"A handler for message type {messageType.FullName} is already registered (overwrite = false)."); - } - - return this; - } - - internal RouteBuilder AddHandler(Type type, Func handler, bool overwrite = false) - { - Throw.IfNull(handler); - - return this.AddHandler(type, WrappedHandlerAsync, overwrite); - - async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) - { - await handler.Invoke(msg, ctx).ConfigureAwait(false); - return CallResult.ReturnVoid(); - } - } - - internal RouteBuilder AddHandler(Type type, Func> handler, bool overwrite = false) - { - Throw.IfNull(handler); - - return this.AddHandler(type, WrappedHandlerAsync, overwrite); - - async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) - { - TResult result = await handler.Invoke(msg, ctx).ConfigureAwait(false); - return CallResult.ReturnResult(result); - } - } - - /// - /// Registers a handler for messages of the specified input type in the workflow route. - /// - /// If a handler for the specified input type already exists and is - /// , the existing handler will not be replaced. Handlers are invoked asynchronously and are - /// expected to complete their processing before the workflow continues. - /// - /// A delegate that processes messages of type within the workflow context. The - /// delegate is invoked for each incoming message of the specified type. - /// to replace any existing handler for the specified input type; otherwise, to preserve the existing handler. - /// The current instance, enabling fluent configuration of additional handlers or route - /// options. - public RouteBuilder AddHandler(Func handler, bool overwrite = false) - { - Throw.IfNull(handler); - - return this.AddHandler(typeof(TInput), WrappedHandlerAsync, overwrite); - - async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) - { - await handler.Invoke((TInput)msg, ctx).ConfigureAwait(false); - return CallResult.ReturnVoid(); - } - } - - /// - /// Registers a handler function for messages of the specified input type in the workflow route. - /// - /// If a handler for the given input type already exists, setting to - /// will replace the existing handler; otherwise, an exception may be thrown. The handler - /// receives the input message and workflow context, and returns a result asynchronously. - /// The type of input message the handler will process. - /// The type of result produced by the handler. - /// A function that processes messages of type within the workflow context and returns - /// a representing the asynchronous result. - /// to replace any existing handler for the input type; otherwise, to - /// preserve existing handlers. - /// The current instance, enabling fluent configuration of workflow routes. - public RouteBuilder AddHandler(Func> handler, bool overwrite = false) - { - Throw.IfNull(handler); - - return this.AddHandler(typeof(TInput), WrappedHandlerAsync, overwrite); - - async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) - { - TResult result = await handler.Invoke((TInput)msg, ctx).ConfigureAwait(false); - return CallResult.ReturnResult(result); - } - } - - internal MessageRouter Build() - { - return new MessageRouter(this._typedHandlers); - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs deleted file mode 100644 index deebf89599..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/RouteBuilderExtensions.cs +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.Workflows.Core; - -internal static class IMessageHandlerReflection -{ - private const string Nameof_HandleAsync = nameof(IMessageHandler.HandleAsync); - internal static readonly MethodInfo HandleAsync_1 = typeof(IMessageHandler<>).GetMethod(Nameof_HandleAsync, BindingFlags.Public | BindingFlags.Instance)!; - internal static readonly MethodInfo HandleAsync_2 = typeof(IMessageHandler<,>).GetMethod(Nameof_HandleAsync, BindingFlags.Public | BindingFlags.Instance)!; - - internal static MethodInfo ReflectHandleAsync(this Type specializedType, int genericArgumentCount) - { - Debug.Assert(specializedType.IsGenericType && - (specializedType.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || - specializedType.GetGenericTypeDefinition() == typeof(IMessageHandler<,>)), - "specializedType must be an IMessageHandler<> or IMessageHandler<,> type."); - return genericArgumentCount switch - { - 1 => specializedType.GetMethodFromGenericMethodDefinition(HandleAsync_1), - 2 => specializedType.GetMethodFromGenericMethodDefinition(HandleAsync_2), - _ => throw new ArgumentOutOfRangeException(nameof(genericArgumentCount), "Must be 1 or 2.") - }; - } - - internal static int GenericArgumentCount(this Type type) - { - Debug.Assert(type.IsMessageHandlerType(), "type must be an IMessageHandler<> or IMessageHandler<,> type."); - return type.GetGenericArguments().Length; - } - - internal static bool IsMessageHandlerType(this Type type) => - type.IsGenericType && - (type.GetGenericTypeDefinition() == typeof(IMessageHandler<>) || - type.GetGenericTypeDefinition() == typeof(IMessageHandler<,>)); -} - -internal static class RouteBuilderExtensions -{ - private static IEnumerable GetHandlerInfos( - [DynamicallyAccessedMembers(ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation)] - this Type executorType) - { - // Handlers are defined by implementations of IMessageHandler or IMessageHandler - Debug.Assert(typeof(ExecutorBase).IsAssignableFrom(executorType), "executorType must be an Executor type."); - - foreach (Type interfaceType in executorType.GetInterfaces()) - { - // Check if the interface is a message handler. - if (!interfaceType.IsMessageHandlerType()) - { - continue; - } - - // Get the generic arguments of the interface. - Type[] genericArguments = interfaceType.GetGenericArguments(); - if (genericArguments.Length < 1 || genericArguments.Length > 2) - { - continue; // Invalid handler signature. - } - Type inType = genericArguments[0]; - Type? outType = genericArguments.Length == 2 ? genericArguments[1] : null; - - MethodInfo? method = interfaceType.ReflectHandleAsync(genericArguments.Length); - - if (method != null) - { - yield return new MessageHandlerInfo(method) { InType = inType, OutType = outType }; - } - } - } - - public static RouteBuilder ReflectHandlers< - [DynamicallyAccessedMembers( - ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation) - ] TExecutor> - (this RouteBuilder builder, Executor executor) - where TExecutor : Executor - { - Throw.IfNull(builder); - - Type executorType = typeof(TExecutor); - Debug.Assert(executorType.IsAssignableFrom(executor.GetType()), - "executorType must be the same type or a base type of the executor instance."); - - foreach (MessageHandlerInfo handlerInfo in executorType.GetHandlerInfos()) - { - builder = builder.AddHandler(handlerInfo.InType, handlerInfo.Bind(executor, checkType: true)); - } - - return builder; - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/StreamsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/StreamsMessageAttribute.cs deleted file mode 100644 index c79d8fb8ab..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/StreamsMessageAttribute.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// This attribute indicates that a message handler streams messages during its execution. -/// -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] -public sealed class StreamsMessageAttribute : Attribute -{ - /// - /// The type of the message that the handler yields. - /// - public Type Type { get; } - - /// - /// Indicates that the message handler yields streaming messages during the course of execution. - /// - public StreamsMessageAttribute(Type type) - { - // This attribute is used to mark executors that yield messages. - this.Type = Throw.IfNull(type); - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs deleted file mode 100644 index 238fc95fef..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/ValueTaskTypeErasure.cs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Diagnostics; -using System.Reflection; -using System.Threading.Tasks; - -namespace Microsoft.Agents.Workflows.Core; - -internal static class ValueTaskReflection -{ - private const string Nameof_AsTask = nameof(ValueTask.AsTask); - internal static readonly MethodInfo AsTask = typeof(ValueTask<>).GetMethod(Nameof_AsTask, BindingFlags.Public | BindingFlags.Instance)!; - - internal static MethodInfo ReflectAsTask(this Type specializedType) - { - Debug.Assert(specializedType.IsGenericType && - specializedType.GetGenericTypeDefinition() == typeof(ValueTask<>), "specializedType must be a ValueTask<> type."); - - return specializedType.GetMethodFromGenericMethodDefinition(AsTask); - } - - internal static bool IsValueTaskType(this Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>); -} - -internal static class TaskReflection -{ - private const string Nameof_Result = nameof(Task.Result); - internal static readonly MethodInfo Result_get = typeof(Task<>).GetProperty(Nameof_Result)!.GetMethod!; - - internal static MethodInfo ReflectResult_get(this Type specializedType) - { - Debug.Assert(specializedType.IsGenericType && - specializedType.GetGenericTypeDefinition() == typeof(Task<>), "specializedType must be a ValueTask<> type."); - - return specializedType.GetMethodFromGenericMethodDefinition(Result_get); - } - - internal static bool IsTaskType(this Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Task<>); -} - -internal static class ValueTaskTypeErasure -{ - internal static Func> UnwrapperFor(Type expectedResultType) - { - return UnwrapAndEraseAsync; - - async ValueTask UnwrapAndEraseAsync(object maybeGenericVT) - { - // This method handles only ValueTask types. - Type maybeVTType = maybeGenericVT.GetType(); - - if (!maybeVTType.IsValueTaskType()) - { - throw new InvalidOperationException($"Expected ValueTask or ValueTask<{expectedResultType.Name}>, but got {maybeGenericVT.GetType().Name}."); - } - - MethodInfo asTaskMethod = maybeVTType.ReflectAsTask(); - Debug.Assert(asTaskMethod.ReturnType.IsTaskType(), "AsTask must return a Task<> type."); - - MethodInfo getResultMethod = asTaskMethod.ReturnType.ReflectResult_get(); - Type actualResultType = getResultMethod.ReturnType; - - if (!expectedResultType.IsAssignableFrom(actualResultType)) - { - throw new InvalidOperationException($"Expected ValueTask<{expectedResultType.Name}> or a compatible type, but got ValueTask<{actualResultType.Name}>."); - } - - Task task = (Task)asTaskMethod.ReflectionInvoke(maybeGenericVT)!; - await task.ConfigureAwait(false); // TODO: Could we need to capture the context here? - object? result = getResultMethod.ReflectionInvoke(task); - - return result; - } - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs deleted file mode 100644 index bf7c5ca3d0..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Core/Workflow.cs +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using Microsoft.Agents.Workflows.Specialized; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.Workflows.Core; - -/// -/// A class that represents a workflow that can be executed. -/// -public class Workflow -{ - /// - /// A dictionary of executor providers, keyed by executor ID. - /// - public Dictionary> ExecutorProviders { get; internal init; } = new(); - - /// - /// Gets the collection of edges grouped by their source node identifier. - /// - public Dictionary> Edges { get; internal init; } = new(); - - /// - /// Gets the collection of external request ports, keyed by their ID. - /// - /// - /// Each port has a corresponding entry in the dictionary. - /// - public Dictionary Ports { get; internal init; } = new(); - - /// - /// Gets the identifier of the starting executor of the workflow. - /// - public string StartExecutorId { get; } - - /// - /// Gets the type of input expected by the starting executor of the workflow. - /// - public Type InputType { get; } - - /// - /// Initializes a new instance of the class with the specified starting executor identifier - /// and input type. - /// - /// The unique identifier of the starting executor for the workflow. Cannot be null. - /// The representing the input data for the workflow. Cannot be null. - internal Workflow(string startExecutorId, Type type) - { - this.StartExecutorId = Throw.IfNull(startExecutorId); - this.InputType = Throw.IfNull(type); - } -} - -/// -/// Represents a workflow that operates on data of type . -/// -/// The type of input to the workflow. -public class Workflow : Workflow -{ - /// - /// Initializes a new instance of the class with the specified starting executor identifier - /// - /// The unique identifier of the starting executor for the workflow. Cannot be null. - public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) - { - } - - internal Workflow Promote(IOutputSink outputSource) - { - Throw.IfNull(outputSource); - - return new Workflow(this.StartExecutorId, outputSource) - { - ExecutorProviders = this.ExecutorProviders, - Edges = this.Edges, - Ports = this.Ports - }; - } -} - -/// -/// Represents a workflow that operates on data of type , resulting in -/// . -/// -/// The type of input to the workflow. -/// The type of the output from the workflow. -public class Workflow : Workflow -{ - private readonly IOutputSink _output; - - internal Workflow(string startExecutorId, IOutputSink outputSource) - : base(startExecutorId) - { - this._output = Throw.IfNull(outputSource); - } - - /// - /// The running (partial) output of the workflow, if any. - /// - public TResult? RunningOutput => this._output.Result; -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs deleted file mode 100644 index 3b2c933498..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunner.cs +++ /dev/null @@ -1,240 +0,0 @@ -// 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.Workflows.Core; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.Workflows.Execution; - -/// -/// Provides a local, in-process runner for executing a workflow using the specified input type. -/// -/// enables step-by-step execution of a workflow graph entirely -/// within the current process, without distributed coordination. It is primarily intended for testing, debugging, or -/// scenarios where workflow execution does not require executor distribution. -/// The type of input accepted by the workflow. Must be non-nullable. -public class LocalRunner : ISuperStepRunner where TInput : notnull -{ - /// - /// Initializes a new instance of the class to execute the specified workflow - /// locally. - /// - /// The manages the execution context and edge mapping for the - /// provided workflow, enabling local, in-process execution. The workflow's structure, including its edges and - /// ports, is used to set up the runner's internal state. - /// The workflow to be executed. Must not be null. - public LocalRunner(Workflow workflow) - { - this.Workflow = Throw.IfNull(workflow); - this.RunContext = new LocalRunnerContext(workflow); - - // 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); - } - - ValueTask ISuperStepRunner.EnqueueMessageAsync(object message) - { - return this.RunContext.AddExternalMessageAsync(message); - } - - private Dictionary PendingCalls { get; } = new(); - private Workflow Workflow { get; init; } - private LocalRunnerContext RunContext { get; init; } - private EdgeMap EdgeMap { get; init; } - - // TODO: Better signature? - event EventHandler? ISuperStepRunner.WorkflowEvent - { - add => this.WorkflowEvent += value; - remove => this.WorkflowEvent -= value; - } - - private event EventHandler? WorkflowEvent; - - private void RaiseWorkflowEvent(WorkflowEvent workflowEvent) - { - this.WorkflowEvent?.Invoke(this, workflowEvent); - } - - private bool IsResponse(object message) - { - return message is ExternalResponse; - } - - private ValueTask> RouteExternalMessageAsync(object message) - { - return message is ExternalResponse response - ? this.CompleteExternalResponseAsync(response) - : this.EdgeMap.InvokeInputAsync(message); - } - - private ValueTask> CompleteExternalResponseAsync(ExternalResponse response) - { - if (!this.RunContext.CompleteRequest(response.RequestId)) - { - throw new InvalidOperationException($"No pending request with ID {response.RequestId} found in the workflow context."); - } - - return this.EdgeMap.InvokeResponseAsync(response); - } - - /// - /// Initiates an asynchronous streaming execution using the specified input. - /// - /// The returned provides methods to observe and control - /// the ongoing streaming execution. The operation will continue until the streaming execution is finished or - /// cancelled. - /// The input message to be processed as part of the streaming run. - /// A that can be used to cancel the streaming operation. - /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. - public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) - { - await this.RunContext.AddExternalMessageAsync(input).ConfigureAwait(false); - - return new StreamingRun(this); - } - - /// - /// Initiates a non-streaming execution of the workflow with the specified input. - /// - /// The workflow will run until its first halt, and the returned will capture - /// all outgoing events. Use the Run instance to resume execution with responses to outgoing events. - /// The input message to be processed as part of the run. - /// A that can be used to cancel the streaming operation. - /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. - public async ValueTask RunAsync(TInput input, CancellationToken cancellation = default) - { - StreamingRun streamingRun = await this.StreamAsync(input, cancellation).ConfigureAwait(false); - cancellation.ThrowIfCancellationRequested(); - - return await Run.CaptureStreamAsync(streamingRun, cancellation).ConfigureAwait(false); - } - - bool ISuperStepRunner.HasUnservicedRequests => this.RunContext.HasUnservicedRequests; - bool ISuperStepRunner.HasUnprocessedMessages => this.RunContext.NextStepHasActions; - - async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cancellation) - { - cancellation.ThrowIfCancellationRequested(); - - StepContext currentStep = this.RunContext.Advance(); - - if (currentStep.HasMessages) - { - await this.RunSuperstepAsync(currentStep).ConfigureAwait(false); - return true; - } - - return false; - } - - private async ValueTask RunSuperstepAsync(StepContext currentStep) - { - // Deliver the messages and queue the next step - List>> edgeTasks = new(); - foreach (ExecutorIdentity sender in currentStep.QueuedMessages.Keys) - { - IEnumerable senderMessages = currentStep.QueuedMessages[sender]; - if (sender.Id is null) - { - edgeTasks.AddRange(senderMessages.Select(message => this.RouteExternalMessageAsync(message).AsTask())); - } - else if (this.Workflow.Edges.TryGetValue(sender.Id!, out HashSet? outgoingEdges)) - { - foreach (Edge outgoingEdge in outgoingEdges) - { - edgeTasks.AddRange(senderMessages.Select(message => this.EdgeMap.InvokeEdgeAsync(outgoingEdge, sender.Id, message).AsTask())); - } - } - } - - // TODO: Should we let the user specify that they want strictly turn-based execution of the edges, vs. concurrent? - // (Simply substitute a strategy that replaces Task.WhenAll with a loop with an await in the middle. Difficulty is - // that we would need to avoid firing the tasks when we call InvokeEdgeAsync, or RouteExternalMessageAsync. - IEnumerable results = (await Task.WhenAll(edgeTasks).ConfigureAwait(false)).SelectMany(r => r); - - // Commit the state updates (so they are visible to the next step) - await this.RunContext.StateManager.PublishUpdatesAsync().ConfigureAwait(false); - - // After the message handler invocations, we may have some events to deliver - foreach (WorkflowEvent @event in this.RunContext.QueuedEvents) - { - this.RaiseWorkflowEvent(@event); - } - - this.RunContext.QueuedEvents.Clear(); - } -} - -/// -/// Provides a local, in-process runner for executing a workflow with input and producing a result. -/// -/// manages the execution of a instance locally, allowing for streaming input and asynchronous result retrieval. -/// This class is intended for scenarios where workflow execution does not require distributed procesing. -/// It supports streaming execution and exposes methods to retrieve the final result asynchronously. -/// -/// The type of input accepted by the workflow. Must be non-nullable. -/// The type of output produced by the workflow. -public class LocalRunner : IRunnerWithOutput where TInput : notnull -{ - private readonly Workflow _workflow; - private readonly ISuperStepRunner _innerRunner; - - /// - /// Initializes a new instance of the class to execute the specified - /// workflow locally. - /// - /// The workflow to be executed. Must not be null. - public LocalRunner(Workflow workflow) - { - this._workflow = Throw.IfNull(workflow); - this._innerRunner = new LocalRunner(workflow); - } - - /// - /// Initiates an asynchronous streaming execution for the specified input. - /// - /// The returned can be used to retrieve results - /// as they become available. If the operation is cancelled via the token, the - /// streaming execution will be terminated. - /// The input value to be processed by the streaming run. - /// A that can be used to cancel the streaming operation. - /// A that provides access to the results of the streaming - /// run. - public async ValueTask> StreamAsync(TInput input, CancellationToken cancellation = default) - { - await this._innerRunner.EnqueueMessageAsync(input).ConfigureAwait(false); - - return new StreamingRun(this); - } - - /// - /// Initiates a non-streaming execution of the workflow with the specified input. - /// - /// The workflow will run until its first halt, and the returned will capture - /// all outgoing events. Use the Run instance to resume execution with responses to outgoing events. - /// The input message to be processed as part of the run. - /// A that can be used to cancel the streaming operation. - /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. - public async ValueTask RunAsync(TInput input, CancellationToken cancellation = default) - { - StreamingRun streamingRun = await this.StreamAsync(input, cancellation).ConfigureAwait(false); - cancellation.ThrowIfCancellationRequested(); - - return await Run.CaptureStreamAsync(streamingRun, cancellation).ConfigureAwait(false); - } - - /// - public TResult? RunningOutput => this._workflow.RunningOutput; - - ISuperStepRunner IRunnerWithOutput.StepRunner => this._innerRunner; -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs deleted file mode 100644 index 748d1436c6..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/LocalRunnerContext.cs +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Core; -using Microsoft.Agents.Workflows.Specialized; -using Microsoft.Extensions.Logging; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.Workflows.Execution; - -internal class LocalRunnerContext : IRunnerContext -{ - private StepContext _nextStep = new(); - private readonly Dictionary> _executorProviders; - private readonly Dictionary _executors = new(); - private readonly Dictionary _externalRequests = new(); - - public LocalRunnerContext(Workflow workflow, ILogger? logger = null) - { - this._executorProviders = Throw.IfNull(workflow).ExecutorProviders; - } - - public async ValueTask EnsureExecutorAsync(string executorId) - { - if (!this._executors.TryGetValue(executorId, out var executor)) - { - if (!this._executorProviders.TryGetValue(executorId, out var provider)) - { - throw new InvalidOperationException($"Executor with ID '{executorId}' is not registered."); - } - - this._executors[executorId] = executor = provider(); - - if (executor is RequestInputExecutor requestInputExecutor) - { - requestInputExecutor.AttachRequestSink(this); - } - } - - return executor; - } - - public ValueTask AddExternalMessageAsync([NotNull] object message) - { - Throw.IfNull(message); - - this._nextStep.MessagesFor(ExecutorIdentity.None).Add(message); - return default; - } - - public bool NextStepHasActions => this._nextStep.HasMessages; - public bool HasUnservicedRequests => this._externalRequests.Count > 0; - - public StepContext Advance() - { - return Interlocked.Exchange(ref this._nextStep, new StepContext()); - } - - public ValueTask AddEventAsync(WorkflowEvent workflowEvent) - { - this.QueuedEvents.Add(workflowEvent); - return default; - } - - public ValueTask SendMessageAsync(string executorId, object message) - { - this._nextStep.MessagesFor(executorId).Add(message); - return default; - } - - public IWorkflowContext Bind(string executorId) - { - return new BoundContext(this, executorId); - } - - public ValueTask PostAsync(ExternalRequest request) - { - this._externalRequests.Add(request.RequestId, request); - return this.AddEventAsync(new RequestInputEvent(request)); - } - - public bool CompleteRequest(string requestId) => this._externalRequests.Remove(requestId); - - public readonly List QueuedEvents = new(); - - internal StateManager StateManager { get; } = new(); - - private class BoundContext(LocalRunnerContext RunnerContext, string ExecutorId) : IWorkflowContext - { - public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => RunnerContext.AddEventAsync(workflowEvent); - public ValueTask SendMessageAsync(object message) => RunnerContext.SendMessageAsync(ExecutorId, message); - - public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null) - => RunnerContext.StateManager.WriteStateAsync(ExecutorId, scopeName, key, value); - - public ValueTask ReadStateAsync(string key, string? scopeName = null) - => RunnerContext.StateManager.ReadStateAsync(ExecutorId, scopeName, key); - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/Run.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/Run.cs deleted file mode 100644 index 8da8ccceeb..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/Run.cs +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Core; - -namespace Microsoft.Agents.Workflows.Execution; - -/// -/// Specifies the current operational state of a workflow run. -/// -public enum RunStatus -{ - /// - /// The run has halted, has no outstanding requets, but has not received a . - /// - Idle, - - /// - /// The run has halted, and has at least one outstanding . - /// - PendingRequests, - - /// - /// The run has halted after receiving a . - /// - Completed, - - /// - /// The workflow is currently running, and may receive events or requests. - /// - Running -} - -/// -/// Represents a workflow run that tracks execution status and emitted workflow events, supporting resumption -/// with responses to . -/// -public class Run -{ - internal static async ValueTask CaptureStreamAsync(StreamingRun run, CancellationToken cancellation = default) - { - Run result = new(run); - await result.RunToNextHaltAsync(cancellation).ConfigureAwait(false); - return result; - } - - private readonly List _eventSink = new(); - private readonly StreamingRun _streamingRun; - internal Run(StreamingRun streamingRun) - { - this._streamingRun = streamingRun; - } - - internal async ValueTask RunToNextHaltAsync(CancellationToken cancellation = default) - { - bool hadEvents = false; - bool hadCompletion = false; - this.Status = RunStatus.Running; - await foreach (WorkflowEvent evt in this._streamingRun.WatchStreamAsync(blockOnPendingRequest: false, cancellation).ConfigureAwait(false)) - { - hadEvents = true; - if (evt is WorkflowCompletedEvent) - { - hadCompletion = true; - } - - this._eventSink.Add(evt); - } - - // TODO: bookmark every halt for history visualization? - - this.Status = - hadCompletion - ? RunStatus.Completed - : this._streamingRun.HasUnservicedRequests - ? RunStatus.PendingRequests - : RunStatus.Idle; - - return hadEvents; - } - - /// - /// Gets the current execution status of the workflow run. - /// - public RunStatus Status { get; private set; } - - /// - /// Gets all events emitted by the workflow. - /// - public IEnumerable OutgoingEvents => this._eventSink; - - private int _lastBookmark = 0; - - /// - /// Gets all events emitted by the workflow since the last access to . - /// - public IEnumerable NewEvents - { - get - { - if (this._lastBookmark >= this._eventSink.Count) - { - return []; - } - - int currentBookmark = this._lastBookmark; - this._lastBookmark = this._eventSink.Count; - - return this._eventSink.Skip(currentBookmark); - } - } - - /// - /// Resume execution of the workflow with the provided external responses. - /// - /// A that can be used to cancel the workflow execution. - /// An array of objects to send to the workflow. - /// true if the workflow had any output events, false otherwise. - public async ValueTask ResumeAsync(CancellationToken cancellation = default, params ExternalResponse[] responses) - { - foreach (ExternalResponse response in responses) - { - await this._streamingRun.SendResponseAsync(response).ConfigureAwait(false); - } - - return await this.RunToNextHaltAsync(cancellation).ConfigureAwait(false); - } -} - -/// -/// Represents a workflow run that tracks execution status and emitted workflow events, supporting resumption -/// with responses to , and retrieval of the running output of the workflow. -/// -/// The type of the workflow output. -public sealed class Run : Run -{ - internal static async ValueTask> CaptureStreamAsync(StreamingRun run, CancellationToken cancellation = default) - { - Run result = new(run); - await result.RunToNextHaltAsync(cancellation).ConfigureAwait(false); - return result; - } - - private readonly StreamingRun _streamingRun; - private Run(StreamingRun streamingRun) : base(streamingRun) - { - this._streamingRun = streamingRun; - } - - /// - public TResult? RunningOutput => this._streamingRun.RunningOutput; -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingRun.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingRun.cs deleted file mode 100644 index dfb274d6e9..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/StreamingRun.cs +++ /dev/null @@ -1,212 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Core; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.Workflows.Execution; - -/// -/// A run instance supporting a streaming form of receiving workflow events, and providing -/// a mechanism to send responses back to the workflow. -/// -public class StreamingRun -{ - private TaskCompletionSource? _waitForResponseSource = null; - private readonly ISuperStepRunner _stepRunner; - - /// - /// Gets a value indicating whether there are any outstanding s for which a - /// has not been sent. - /// - public bool HasUnservicedRequests => this._stepRunner.HasUnservicedRequests; - - internal StreamingRun(ISuperStepRunner stepRunner) - { - this._stepRunner = Throw.IfNull(stepRunner); - } - - /// - /// Asynchronously sends the specified response to the external system and signals completion of the current - /// response wait operation. - /// - /// The response will be queued for processing for the next superstep. - /// The to send. Must not be null. - /// A that represents the asynchronous send operation. The task completes when the response - /// has been enqueued for processing, but will not wait for processing to complete. - public ValueTask SendResponseAsync(ExternalResponse response) - { - this._waitForResponseSource?.TrySetResult(new()); - - return this._stepRunner.EnqueueMessageAsync(response); - } - - /// - /// Asynchronously streams workflow events as they occur during workflow execution. - /// - /// This method yields instances in real time as the workflow - /// progresses. The stream completes when a is encountered. Events are - /// delivered in the order they are raised. - /// A that can be used to cancel the streaming operation. If cancellation is - /// requested, the stream will end and no further events will be yielded. - /// An asynchronous stream of objects representing significant workflow state changes. - /// The stream ends when the workflow completes or when cancellation is requested. - public IAsyncEnumerable WatchStreamAsync( - CancellationToken cancellation = default) - => this.WatchStreamAsync(blockOnPendingRequest: true, cancellation); - - internal async IAsyncEnumerable WatchStreamAsync( - bool blockOnPendingRequest, - [EnumeratorCancellation] CancellationToken cancellation = default) - { - List eventSink = new(); - - this._stepRunner.WorkflowEvent += OnWorkflowEvent; - - try - { - do - { - // Drain SuperSteps while there are steps to run - await this._stepRunner.RunSuperStepAsync(cancellation).ConfigureAwait(false); - if (cancellation.IsCancellationRequested) - { - yield break; // Exit if cancellation is requested - } - - bool hadCompletionEvent = false; - List outputEvents = Interlocked.Exchange(ref eventSink, new()); - foreach (WorkflowEvent raisedEvent in outputEvents) - { - yield return raisedEvent; - - if (cancellation.IsCancellationRequested) - { - yield break; // Exit if cancellation is requested - } - - // TODO: Do we actually want to interpret this as a termination request? - if (raisedEvent is WorkflowCompletedEvent) - { - hadCompletionEvent = true; - } - } - - if (hadCompletionEvent) - { - // If we had a completion event, we are done. - yield break; - } - - // If we do not have any actions to take on the Workflow, but have unprocessed - // requests, wait for the responses to come in before exiting out of the workflow - // execution. - if (blockOnPendingRequest && - !this._stepRunner.HasUnprocessedMessages && - this._stepRunner.HasUnservicedRequests) - { - if (this._waitForResponseSource == null) - { - this._waitForResponseSource = new(); - } - - using CancellationTokenRegistration registration = cancellation.Register(() => - { - this._waitForResponseSource?.SetResult(new()); - }); - - await this._waitForResponseSource.Task.ConfigureAwait(false); - this._waitForResponseSource = null; - } - } while (this._stepRunner.HasUnprocessedMessages); - } - finally - { - this._stepRunner.WorkflowEvent -= OnWorkflowEvent; - } - - void OnWorkflowEvent(object? sender, WorkflowEvent e) - { - eventSink.Add(e); - } - } -} - -/// -/// A run instance supporting a streaming form of receiving workflow events, providing -/// a mechanism to send responses back to the workflow, and retrieving the result of workflow execution. -/// -/// The type of the workflow output. -public class StreamingRun : StreamingRun -{ - private readonly IRunnerWithOutput _resultSource; - - internal StreamingRun(IRunnerWithOutput runner) - : base(Throw.IfNull(runner.StepRunner)) - { - this._resultSource = runner; - } - - /// - public TResult? RunningOutput => this._resultSource.RunningOutput; -} - -/// -/// Provides extension methods for processing and executing workflows using streaming runs. -/// -public static class StreamingRunExtensions -{ - /// - /// Processes all events from the workflow execution stream until completion. - /// - /// This method continuously monitors the workflow execution stream provided by and invokes the for each event. If the callback returns a - /// non- response, the response is sent back to the workflow using the handle. - /// The representing the workflow execution stream to monitor. - /// An optional callback function invoked for each received from the stream. - /// The callback can return a response object to be sent back to the workflow, or if no response - /// is required. - /// A to observe while waiting for events. - /// A that represents the asynchronous operation. The task completes when the workflow - /// execution stream is fully processed. - public static async ValueTask RunToCompletionAsync(this StreamingRun handle, Func? eventCallback = null, CancellationToken cancellation = default) - { - Throw.IfNull(handle); - - await foreach (WorkflowEvent @event in handle.WatchStreamAsync(cancellation).ConfigureAwait(false)) - { - ExternalResponse? maybeResponse = eventCallback?.Invoke(@event); - if (maybeResponse != null) - { - await handle.SendResponseAsync(maybeResponse).ConfigureAwait(false); - } - } - } - - /// - /// Executes the workflow associated with the specified until it - /// completes and returns the final result. - /// - /// This method ensures that the workflow runs to completion before returning the result. If an - /// is provided, it will be invoked for each event emitted during the workflow's - /// execution, allowing for custom event handling. - /// The type of the result produced by the workflow. - /// The representing the workflow to execute. - /// An optional callback function that is invoked for each - /// emitted during execution. The callback can process the event and return an object, or - /// if no response is required. - /// A that can be used to cancel the workflow execution. - /// A that represents the asynchronous operation. The task's result is the final - /// result of the workflow execution. - public static async ValueTask RunToCompletionAsync(this StreamingRun handle, Func? eventCallback = null, CancellationToken cancellation = default) - { - Throw.IfNull(handle); - - await handle.RunToCompletionAsync(eventCallback, cancellation).ConfigureAwait(false); - return handle.RunningOutput!; - } -} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs index e25feffd99..858a4ff180 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs @@ -3,10 +3,8 @@ using System; using System.Linq; using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Core; using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.PowerFx; -using Microsoft.Agents.Workflows.Execution; using Microsoft.Bot.ObjectModel; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.PowerFx.Types; @@ -30,9 +28,8 @@ internal async Task Execute(WorkflowActionExecutor executor) WorkflowExecutionContext context = new(RecalcEngineFactory.Create(this.Scopes), this.Scopes, () => null!, NullLogger.Instance); executor.Attach(context); WorkflowBuilder workflowBuilder = new(executor); - LocalRunner runner = new(workflowBuilder.Build()); - StreamingRun handle = await runner.StreamAsync(""); - WorkflowEvent[] events = await handle.WatchStreamAsync().ToArrayAsync(); + StreamingRun run = await InProcessExecution.StreamAsync(workflowBuilder.Build(), ""); + WorkflowEvent[] events = await run.WatchStreamAsync().ToArrayAsync(); } internal void VerifyModel(DialogAction model, WorkflowActionExecutor action) From b63e941139d95f8406688abf7787b6650a05863d Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Wed, 13 Aug 2025 17:41:32 -0700 Subject: [PATCH 146/232] Test coverage --- .../Execution/ForeachExecutor.cs | 16 +- .../PowerFx/WorkflowExpressionEngine.cs | 23 +- .../DeclarativeWorkflowTest.cs | 202 +++++++++ ...nts.Workflows.Declarative.UnitTests.csproj | 6 + .../PowerFx/FoundryExpressionEngineTests.cs | 29 -- .../PowerFx/WorkflowExpressionEngineTests.cs | 410 ++++++++++++++++++ .../{ => PowerFx}/WorkflowScopesTests.cs | 2 +- .../Workflows/Condition.yaml | 34 ++ .../Workflows/Goto.yaml | 25 ++ .../Workflows/LoopBreak.yaml | 29 ++ .../Workflows/LoopContinue.yaml | 29 ++ .../Workflows/LoopEach.yaml | 27 ++ .../Workflows/Single.yaml | 8 + 13 files changed, 797 insertions(+), 43 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs delete mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/FoundryExpressionEngineTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs rename dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/{ => PowerFx}/WorkflowScopesTests.cs (98%) create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Condition.yaml create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Goto.yaml create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopBreak.yaml create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopContinue.yaml create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopEach.yaml create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Single.yaml diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ForeachExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ForeachExecutor.cs index 55b006bf6e..51c309674f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ForeachExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ForeachExecutor.cs @@ -68,12 +68,12 @@ public void TakeNext(WorkflowExecutionContext context) } } - public void Reset(WorkflowExecutionContext context) - { - context.Engine.ClearScopedVariable(context.Scopes, Throw.IfNull(this.Model.Value)); - if (this.Model.Index is not null) - { - context.Engine.ClearScopedVariable(context.Scopes, this.Model.Index); - } - } + //public void Reset(WorkflowExecutionContext context) // %%% NEEDED + //{ + // context.Engine.ClearScopedVariable(context.Scopes, Throw.IfNull(this.Model.Value)); + // if (this.Model.Index is not null) + // { + // context.Engine.ClearScopedVariable(context.Scopes, this.Model.Index); + // } + //} } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs index a614b9f887..33116c3d86 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs @@ -3,8 +3,6 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; -using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; @@ -143,12 +141,17 @@ private EvaluationResult GetValue(IntExpression expression, TState EvaluationResult expressionResult = evaluator.Invoke(expression, state); - if (expressionResult.Value is not PrimitiveValue formulaValue) + if (expressionResult.Value is BlankValue) + { + return new EvaluationResult(default, expressionResult.Sensitivity); + } + + if (expressionResult.Value is not DecimalValue formulaValue) { throw new InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.Number); } - return new EvaluationResult(formulaValue.Value, expressionResult.Sensitivity); + return new EvaluationResult(Convert.ToInt64(formulaValue.Value), expressionResult.Sensitivity); } private EvaluationResult GetValue(NumberExpression expression, TState state, Func> evaluator) @@ -162,9 +165,19 @@ private EvaluationResult GetValue(NumberExpression expression, T EvaluationResult expressionResult = evaluator.Invoke(expression, state); + if (expressionResult.Value is BlankValue) + { + return new EvaluationResult(default, expressionResult.Sensitivity); + } + + if (expressionResult.Value is DecimalValue decimalValue) + { + return new EvaluationResult(Convert.ToDouble(decimalValue.Value), expressionResult.Sensitivity); + } + if (expressionResult.Value is not NumberValue formulaValue) { - throw new InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.Number); + throw new InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.Float); } return new EvaluationResult(formulaValue.Value, expressionResult.Sensitivity); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs new file mode 100644 index 0000000000..a16d3c8034 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Agents.Workflows.Reflection; +using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests; + +/// +/// Tests exeuction of workflow created by . +/// +public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : WorkflowTest(output) +{ + private ImmutableList WorkflowEvents { get; set; } = ImmutableList.Empty; + + private ImmutableDictionary WorkflowEventCounts { get; set; } = ImmutableDictionary.Empty; + + [Fact] + public async Task SingleAction() + { + await this.RunWorkflow("Single.yaml"); + this.AssertExecutionCount(expectedCount: 1); + this.AssertExecuted("end_all"); + } + + [Fact] + public async Task LoopEachAction() + { + await this.RunWorkflow("LoopEach.yaml"); + this.AssertExecutionCount(expectedCount: 35); + this.AssertExecuted("foreach_loop"); + this.AssertExecuted("end_all"); + } + + [Fact] + public async Task LoopBreakAction() + { + await this.RunWorkflow("LoopBreak.yaml"); + this.AssertExecutionCount(expectedCount: 7); + this.AssertExecuted("foreach_loop"); + this.AssertExecuted("breakLoop_now"); + this.AssertExecuted("end_all"); + this.AssertNotExecuted("setVariable_loop"); + this.AssertNotExecuted("sendActivity_loop"); + } + + [Fact] + public async Task LoopContinueAction() + { + await this.RunWorkflow("LoopContinue.yaml"); + this.AssertExecutionCount(expectedCount: 23); + this.AssertExecuted("foreach_loop"); + this.AssertExecuted("continueLoop_now"); + this.AssertExecuted("end_all"); + this.AssertNotExecuted("setVariable_loop"); + this.AssertNotExecuted("sendActivity_loop"); + } + + [Fact] + public async Task GotoAction() + { + await this.RunWorkflow("Goto.yaml"); + this.AssertExecutionCount(expectedCount: 2); + this.AssertExecuted("goto_end"); + this.AssertExecuted("end_all"); + this.AssertNotExecuted("sendActivity_1"); + this.AssertNotExecuted("sendActivity_2"); + this.AssertNotExecuted("sendActivity_3"); + } + + [Fact] + public async Task ConditionAction() + { + await this.RunWorkflow("Condition.yaml"); + this.AssertExecutionCount(expectedCount: 16); + this.AssertExecuted("setVariable_test"); + this.AssertExecuted("conditionGroup_test"); + this.AssertExecuted("conditionItem_even"); + this.AssertExecuted("sendActivity_even"); + this.AssertExecuted("end_all"); + this.AssertNotExecuted("conditionItem_odd"); + this.AssertNotExecuted("sendActivity_odd"); + } + + [Theory] + [InlineData(typeof(ActivateExternalTrigger.Builder))] + [InlineData(typeof(AdaptiveCardPrompt.Builder))] + [InlineData(typeof(BeginDialog.Builder))] + [InlineData(typeof(CSATQuestion.Builder))] + [InlineData(typeof(CancelAllDialogs.Builder))] + [InlineData(typeof(CancelDialog.Builder))] + [InlineData(typeof(CreateSearchQuery.Builder))] + [InlineData(typeof(DeleteActivity.Builder))] + [InlineData(typeof(DisableTrigger.Builder))] + [InlineData(typeof(DisconnectedNodeContainer.Builder))] + [InlineData(typeof(EmitEvent.Builder))] + [InlineData(typeof(EndDialog.Builder))] + [InlineData(typeof(GetActivityMembers.Builder))] + [InlineData(typeof(GetConversationMembers.Builder))] + [InlineData(typeof(HttpRequestAction.Builder))] + [InlineData(typeof(InvokeAIBuilderModelAction.Builder))] + [InlineData(typeof(InvokeConnectorAction.Builder))] + [InlineData(typeof(InvokeCustomModelAction.Builder))] + [InlineData(typeof(InvokeFlowAction.Builder))] + [InlineData(typeof(InvokeSkillAction.Builder))] + [InlineData(typeof(LogCustomTelemetryEvent.Builder))] + [InlineData(typeof(OAuthInput.Builder))] + [InlineData(typeof(Question.Builder))] + [InlineData(typeof(RecognizeIntent.Builder))] + [InlineData(typeof(RepeatDialog.Builder))] + [InlineData(typeof(ReplaceDialog.Builder))] + [InlineData(typeof(SearchAndSummarizeContent.Builder))] + [InlineData(typeof(SearchAndSummarizeWithCustomModel.Builder))] + [InlineData(typeof(SearchKnowledgeSources.Builder))] + [InlineData(typeof(SignOutUser.Builder))] + [InlineData(typeof(TransferConversation.Builder))] + [InlineData(typeof(TransferConversationV2.Builder))] + [InlineData(typeof(UnknownDialogAction.Builder))] + [InlineData(typeof(UpdateActivity.Builder))] + [InlineData(typeof(WaitForConnectorTrigger.Builder))] + public async Task UnsupportedAction(Type type) + { + DialogAction.Builder? unsupportedAction = (DialogAction.Builder?)Activator.CreateInstance(type); + Assert.NotNull(unsupportedAction); + unsupportedAction.Id = "action_bad"; + AdaptiveDialog.Builder dialogBuilder = + new() + { + BeginDialog = + new OnActivity.Builder() + { + Id = "workflow", + Actions = [unsupportedAction] + } + }; + + WorkflowScopes scopes = new(); + DeclarativeWorkflowContext workflowContext = + new() + { + LoggerFactory = NullLoggerFactory.Instance, + ActivityChannel = this.Output, + }; + WorkflowActionVisitor visitor = new(new RootExecutor(), workflowContext, scopes); + WorkflowElementWalker walker = new(dialogBuilder.Build(), visitor); + Assert.True(visitor.HasUnsupportedActions); + } + + private void AssertExecutionCount(int expectedCount) + { + Assert.Equal(expectedCount + 2, this.WorkflowEventCounts[typeof(ExecutorInvokeEvent)]); + Assert.Equal(expectedCount + 2, this.WorkflowEventCounts[typeof(ExecutorCompleteEvent)]); + } + + private void AssertNotExecuted(string executorId) + { + Assert.DoesNotContain(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); + Assert.DoesNotContain(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); + } + + private void AssertExecuted(string executorId) + { + Assert.Contains(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); + Assert.Contains(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); + } + + private async Task RunWorkflow(string workflowPath) + { + using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath)); + DeclarativeWorkflowContext workflowContext = + new() + { + LoggerFactory = NullLoggerFactory.Instance, + ActivityChannel = this.Output, + }; + + Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); + + StreamingRun run = await InProcessExecution.StreamAsync(workflow, ""); + + this.WorkflowEvents = run.WatchStreamAsync().ToEnumerable().ToImmutableList(); + this.WorkflowEventCounts = this.WorkflowEvents.GroupBy(e => e.GetType()).ToImmutableDictionary(e => e.Key, e => e.Count()); + } + + private sealed class RootExecutor() : + ReflectingExecutor("root_workflow"), + IMessageHandler + { + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + await context.SendMessageAsync($"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}").ConfigureAwait(false); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Microsoft.Agents.Workflows.Declarative.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Microsoft.Agents.Workflows.Declarative.UnitTests.csproj index 5bd84d8eb2..b13d3cba9f 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Microsoft.Agents.Workflows.Declarative.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Microsoft.Agents.Workflows.Declarative.UnitTests.csproj @@ -14,4 +14,10 @@ + + + Always + + + diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/FoundryExpressionEngineTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/FoundryExpressionEngineTests.cs deleted file mode 100644 index 45a1771bf4..0000000000 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/FoundryExpressionEngineTests.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.Workflows.Declarative.PowerFx; -using Microsoft.Bot.ObjectModel; -using Microsoft.Bot.ObjectModel.Abstractions; -using Microsoft.PowerFx; -using Microsoft.PowerFx.Types; -using Xunit.Abstractions; - -namespace Microsoft.Agents.Workflows.Declarative.UnitTests.PowerFx; - -public class FoundryExpressionEngineTests(ITestOutputHelper output) : RecalcEngineTest(output) -{ - [Fact] - public void DefaultNotNull() - { - // Act - RecalcEngine engine = this.CreateEngine(); - WorkflowExpressionEngine expressionEngine = new(engine); - this.Scopes.Set("test", FormulaValue.New("value")); - engine.SetScopedVariable(this.Scopes, PropertyPath.TopicVariable("test"), FormulaValue.New("value")); - - EvaluationResult valueResult = expressionEngine.GetValue(StringExpression.Variable(PropertyPath.TopicVariable("test")), this.Scopes.BuildState()); - - // Assert - Assert.Equal("value", valueResult.Value); - Assert.Equal(SensitivityLevel.None, valueResult.Sensitivity); - } -} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs new file mode 100644 index 0000000000..9206c4b199 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs @@ -0,0 +1,410 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.Bot.ObjectModel.Exceptions; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.PowerFx; + +public class WorkflowExpressionEngineTests : RecalcEngineTest +{ + private static class Variables + { + public const string GlobalValue = nameof(GlobalValue); + public const string BoolValue = nameof(BoolValue); + public const string StringValue = nameof(StringValue); + public const string IntValue = nameof(IntValue); + public const string NumberValue = nameof(NumberValue); + public const string BlankValue = nameof(BlankValue); + } + + public WorkflowExpressionEngineTests(ITestOutputHelper output) + : base(output) + { + this.Scopes.Set(Variables.GlobalValue, WorkflowScopeType.Global, FormulaValue.New(255)); + this.Scopes.Set(Variables.BoolValue, WorkflowScopeType.Topic, FormulaValue.New(true)); + this.Scopes.Set(Variables.StringValue, WorkflowScopeType.Topic, FormulaValue.New("Hello World")); + this.Scopes.Set(Variables.IntValue, WorkflowScopeType.Topic, FormulaValue.New(long.MaxValue)); + this.Scopes.Set(Variables.NumberValue, WorkflowScopeType.Topic, FormulaValue.New(33.3)); + this.Scopes.Set(Variables.BlankValue, WorkflowScopeType.Topic, FormulaValue.NewBlank()); + } + + #region BoolExpression Tests + + [Fact] + public void GetValueForNullBoolExpression() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression((BoolExpression)null!); + } + + [Fact] + public void GetValueForInvalidBoolExpression() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression(BoolExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue))); + } + + [Fact] + public void GetValueForBoolExpressionWithLiteral() + { + // Arrange, Act & Assert + this.EvaluateExpression( + BoolExpression.Literal(true), + expectedValue: true); + } + + [Fact] + public void GetValueForBoolExpressionBlank() + { + // Arrange, Act & Assert + this.EvaluateExpression( + BoolExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)), + expectedValue: false); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void GetValueForBoolExpressionWithVariable(bool useState) + { + // Arrange, Act & Assert + this.EvaluateExpression( + BoolExpression.Variable(PropertyPath.TopicVariable(Variables.BoolValue)), + expectedValue: true, + useState); + } + + [Fact] + public void GetValueForBoolExpressionWithFormula() + { + // Arrange, Act & Assert + this.EvaluateExpression( + BoolExpression.Expression("true || false"), + expectedValue: true); + } + + #endregion + + #region StringExpression Tests + + [Fact] + public void GetValueForNullStringExpression() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression((StringExpression)null!); + } + + [Fact] + public void GetValueForInvalidStringExpression() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression(StringExpression.Variable(PropertyPath.TopicVariable(Variables.BoolValue))); + } + + [Fact] + public void GetValueForStringExpressionBlank() + { + // Arrange, Act & Assert + this.EvaluateExpression( + StringExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)), + expectedValue: string.Empty); + } + + [Fact] + public void GetValueForStringExpressionWithLiteral() + { + // Arrange, Act & Assert + this.EvaluateExpression( + StringExpression.Literal("test"), + expectedValue: "test"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void GetValueForStringExpressionWithVariable(bool useState) + { + // Arrange, Act & Assert + this.EvaluateExpression( + StringExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue)), + expectedValue: "Hello World", + useState); + } + + [Fact] + public void GetValueForStringExpressionWithFormula() + { + // Arrange, Act & Assert + this.EvaluateExpression( + //StringExpression.Expression(@$"""{{{PropertyPath.TopicVariable(Variables.StringValue)}}}"""), + StringExpression.Expression(@"""AB"""), // %%% IMPROVE + expectedValue: "AB"); + } + + //[Fact] + //public void GetValueForStringExpressionWithRecordDataValue() + //{ + // // Arrange + // RecalcEngine engine = this.CreateEngine(); + // WorkflowExpressionEngine expressionEngine = new(engine); + // RecordDataValue state = new RecordDataValue(); + // RecordDataValue globalScope = new RecordDataValue(); + // globalScope.Properties["testValue"] = new StringDataValue("test"); + // state.Properties["Global"] = globalScope; + // StringExpression expression = StringExpression.Variable(PropertyPath.Create("Global.testValue")); + + // // Act + // EvaluationResult result = expressionEngine.GetValue(expression, state); + + // // Assert + // Assert.Equal("test", result.Value); + // Assert.Equal(SensitivityLevel.None, result.Sensitivity); + //} + + #endregion + + #region IntExpression Tests + + [Fact] + public void GetValueForNullIntExpression() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression((IntExpression)null!); + } + + [Fact] + public void GetValueForInvalidIntExpression() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression(IntExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue))); + } + + [Fact] + public void GetValueForIntExpressionBlank() + { + // Arrange, Act & Assert + this.EvaluateExpression( + IntExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)), + expectedValue: 0); + } + + [Fact] + public void GetValueForIntExpressionWithLiteral() + { + // Arrange, Act & Assert + this.EvaluateExpression( + IntExpression.Literal(7), + expectedValue: 7); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void GetValueForIntExpressionWithVariable(bool useState) + { + // Arrange, Act & Assert + this.EvaluateExpression( + IntExpression.Variable(PropertyPath.TopicVariable(Variables.IntValue)), + expectedValue: long.MaxValue, + useState); + } + + [Fact] + public void GetValueForIntExpressionWithFormula() + { + // Arrange, Act & Assert + this.EvaluateExpression( + IntExpression.Expression("1 + 6"), + expectedValue: 7); + } + + #endregion + + #region NumberExpression Tests + + [Fact] + public void GetValueForNullNumberExpression() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression((NumberExpression)null!); + } + + [Fact] + public void GetValueForInvalidNumberExpression() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression(NumberExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue))); + } + + [Fact] + public void GetValueForNumberExpressionBlank() + { + // Arrange, Act & Assert + this.EvaluateExpression( + NumberExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)), + expectedValue: 0); + } + + [Fact] + public void GetValueForNumberExpressionWithLiteral() + { + // Arrange, Act & Assert + this.EvaluateExpression( + NumberExpression.Literal(3.14), + expectedValue: 3.14); + } + + [Fact] + public void GetValueForNumberExpressionWithVariable() + { + // Arrange, Act & Assert + this.EvaluateExpression( + NumberExpression.Variable(PropertyPath.TopicVariable(Variables.NumberValue)), + expectedValue: 33.3); + } + + [Fact] + public void GetValueForNumberExpressionWithFormula() + { + // Arrange, Act & Assert + this.EvaluateExpression( + NumberExpression.Expression("31.1 + 2.2"), + expectedValue: 33.3); + } + + #endregion + + #region EnumExpression Tests + + //// Enum Expression Tests + //[Fact] + //public void GetValueForEnumExpressionWithLiteral() + //{ + // // Arrange + // RecalcEngine engine = this.CreateEngine(); + // WorkflowExpressionEngine expressionEngine = new(engine); + // WorkflowScopes scopes = new(); + + // TestEnum testEnum = TestEnum.Create(TestEnumValue.Value1); + // EnumExpression expression = new EnumExpression(testEnum); + + // // Act + // EvaluationResult result = expressionEngine.GetValue(expression, scopes); + + // // Assert + // Assert.Equal(TestEnumValue.Value1, result.Value.Value); + // Assert.Equal(SensitivityLevel.None, result.Sensitivity); + //} + + #endregion + + //// Object Expression Tests + //[Fact] + //public void GetValueForObjectExpressionWithLiteral() + //{ + // // Arrange + // RecalcEngine engine = this.CreateEngine(); + // WorkflowExpressionEngine expressionEngine = new(engine); + // WorkflowScopes scopes = new(); + // TestBotElement testElement = new TestBotElement { Name = "Test" }; + // ObjectExpression expression = new ObjectExpression(testElement); + + // // Act + // EvaluationResult result = expressionEngine.GetValue(expression, scopes); + + // // Assert + // Assert.NotNull(result.Value); + // Assert.Equal("Test", result.Value.Name); + // Assert.Equal(SensitivityLevel.None, result.Sensitivity); + //} + + //// Array Expression Tests + //[Fact] + //public void GetValueForArrayExpressionWithLiteral() + //{ + // // Arrange + // RecalcEngine engine = this.CreateEngine(); + // WorkflowExpressionEngine expressionEngine = new(engine); + // WorkflowScopes scopes = new(); + + // ImmutableArray array = ImmutableArray.Create("item1", "item2"); + // ArrayExpression expression = new ArrayExpression(array); + + // // Act + // ImmutableArray result = expressionEngine.GetValue(expression, scopes); + + // // Assert + // Assert.Equal(2, result.Length); + // Assert.Equal("item1", result[0]); + // Assert.Equal("item2", result[1]); + //} + + private EvaluationResult EvaluateExpression(BoolExpression expression, bool expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) + => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); + + private void EvaluateInvalidExpression(BoolExpression expression) where TException : Exception + => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); + + private EvaluationResult EvaluateExpression(StringExpression expression, string expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) + => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); + + private void EvaluateInvalidExpression(StringExpression expression) where TException : Exception + => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); + + private EvaluationResult EvaluateExpression(IntExpression expression, long expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) + => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); + + private void EvaluateInvalidExpression(IntExpression expression) where TException : Exception + => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); + + private EvaluationResult EvaluateExpression(NumberExpression expression, double expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) + => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); + + private void EvaluateInvalidExpression(NumberExpression expression) where TException : Exception + => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); + + private EvaluationResult EvaluateExpression(EnumExpression expression, TEnum expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) + where TEnum : EnumWrapper + => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); + + private void EvaluateInvalidExpression(EnumExpression expression) where TException : Exception where TEnum : EnumWrapper + => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); + + //private EvaluationResult EvaluateExpression(ObjectExpression expression, TValue expectedValue, SensitivityLevel expectedSensitivity = SensitivityLevel.None) + // => this.EvaluateExpression((evaluator) => evaluator.GetValue(expression, this.Scopes), expectedValue, expectedSensitivity); + + private EvaluationResult EvaluateExpression( + Func> evaluator, + TValue expectedValue, + SensitivityLevel expectedSensitivity = SensitivityLevel.None) + { + // Arrange + RecalcEngine engine = this.CreateEngine(); + WorkflowExpressionEngine expressionEngine = new(engine); + + // Act + EvaluationResult result = evaluator.Invoke(expressionEngine); + + // Assert + Assert.Equal(expectedValue, result.Value); + Assert.Equal(expectedSensitivity, result.Sensitivity); + + return result; + } + + private void EvaluateInvalidExpression(Action evaluator) where TException : Exception + { + // Arrange + RecalcEngine engine = this.CreateEngine(); + WorkflowExpressionEngine expressionEngine = new(engine); + + // Act + Assert.Throws(() => evaluator.Invoke(expressionEngine)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowScopesTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs similarity index 98% rename from dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowScopesTests.cs rename to dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs index 7b053abd81..bf7474ba38 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowScopesTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs @@ -4,7 +4,7 @@ using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.PowerFx.Types; -namespace Microsoft.Agents.Workflows.Declarative.UnitTests; +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.PowerFx; public class WorkflowScopesTests { diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Condition.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Condition.yaml new file mode 100644 index 0000000000..814bb2c8ec --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Condition.yaml @@ -0,0 +1,34 @@ +kind: AdaptiveDialog +beginDialog: + + kind: OnActivity + id: my_workflow + type: Message + actions: + + - kind: SetVariable + id: setVariable_test + variable: Topic.Count + value: =32 + + - kind: ConditionGroup + id: conditionGroup_test + conditions: + - id: conditionItem_odd + #condition: =Topic.Count % 1 == 1 + condition: false + actions: + - kind: SendActivity + id: sendActivity_odd + activity: ODD + + - id: conditionItem_even + #condition: =Topic.Count % 1 == 0 + condition: true + actions: + - kind: SendActivity + id: sendActivity_even + activity: EVEN + + - kind: EndConversation + id: end_all diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Goto.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Goto.yaml new file mode 100644 index 0000000000..1b300dbe1e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Goto.yaml @@ -0,0 +1,25 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + + - kind: GotoAction + id: goto_end + actionId: end_all + + - kind: SendActivity + id: sendActivity_1 + activity: NEVER 1! + + - kind: SendActivity + id: sendActivity_2 + activity: NEVER 2! + + - kind: SendActivity + id: sendActivity_3 + activity: NEVER 3! + + - kind: EndConversation + id: end_all diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopBreak.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopBreak.yaml new file mode 100644 index 0000000000..9d7d8db0f9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopBreak.yaml @@ -0,0 +1,29 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: SetVariable + id: setVariable_count + variable: Topic.Count + value: =0 + + - kind: Foreach + id: foreach_loop + items: =["a", "b", "c", "d", "e", "f"] + index: Topic.LoopIndex + value: Topic.LoopValue + actions: + - kind: BreakLoop + id: breakLoop_now + - kind: SetVariable + id: setVariable_loop + variable: Topic.Count + value: =Topic.Count + 1 + - kind: SendActivity + id: sendActivity_loop + activity: x{Topic.Count} - {Topic.LoopIndex}:{Topic.LoopValue} + + - kind: EndConversation + id: end_all diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopContinue.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopContinue.yaml new file mode 100644 index 0000000000..be39550cb8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopContinue.yaml @@ -0,0 +1,29 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: SetVariable + id: setVariable_count + variable: Topic.Count + value: =0 + + - kind: Foreach + id: foreach_loop + items: =["a", "b", "c", "d", "e", "f"] + index: Topic.LoopIndex + value: Topic.LoopValue + actions: + - kind: ContinueLoop + id: continueLoop_now + - kind: SetVariable + id: setVariable_loop + variable: Topic.Count + value: =Topic.Count + 1 + - kind: SendActivity + id: sendActivity_loop + activity: x{Topic.Count} - {Topic.LoopIndex}:{Topic.LoopValue} + + - kind: EndConversation + id: end_all diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopEach.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopEach.yaml new file mode 100644 index 0000000000..a18d9ee756 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/LoopEach.yaml @@ -0,0 +1,27 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: SetVariable + id: setVariable_count + variable: Topic.Count + value: =0 + + - kind: Foreach + id: foreach_loop + items: =["a", "b", "c", "d", "e", "f"] + index: Topic.LoopIndex + value: Topic.LoopValue + actions: + - kind: SetVariable + id: setVariable_loop + variable: Topic.Count + value: =Topic.Count + 1 + - kind: SendActivity + id: sendActivity_loop + activity: x{Topic.Count} - {Topic.LoopIndex}:{Topic.LoopValue} + + - kind: EndConversation + id: end_all diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Single.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Single.yaml new file mode 100644 index 0000000000..54097d9e88 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Single.yaml @@ -0,0 +1,8 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: EndConversation + id: end_all From 34b367db0964ed74fdd2fe343db2da9488b4dce9 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Wed, 13 Aug 2025 20:12:21 -0700 Subject: [PATCH 147/232] Message event --- dotnet/demos/DeclarativeWorkflow/Program.cs | 34 +++++- .../demos/DeclarativeWorkflow/demo250729.yaml | 5 - .../Workflows/Workflows_Declarative.cs | 20 +++- .../GettingStarted/Workflows/demo250729.yaml | 5 - .../DeclarativeWorkflowContext.cs | 7 -- .../DeclarativeWorkflowEvent.cs | 10 ++ .../DeclarativeWorkflowMessageEvent.cs | 21 ++++ .../DeclarativeWorkflowStreamEvent.cs | 16 +++ .../Execution/AnswerQuestionWithAIExecutor.cs | 11 +- .../Execution/ClearAllVariablesExecutor.cs | 2 +- .../Execution/ConditionGroupExecutor.cs | 7 +- .../Execution/EditTableV2Executor.cs | 2 +- .../Execution/EndConversationExecutor.cs | 2 +- .../Execution/ForeachExecutor.cs | 2 +- .../Execution/ParseValueExecutor.cs | 4 +- .../Execution/ResetVariableExecutor.cs | 2 +- .../Execution/SendActivityExecutor.cs | 16 +-- .../Execution/SetTextVariableExecutor.cs | 2 +- .../Execution/SetVariableExecutor.cs | 2 +- .../Execution/WorkflowActionExecutor.cs | 4 +- .../Interpreter/WorkflowActionVisitor.cs | 4 +- .../DeclarativeWorkflowTest.cs | 17 +-- .../Execution/ParseValueExecutorTest.cs | 108 ++++++++++++++++++ .../Execution/SendActivityExecutorTest.cs | 9 +- 24 files changed, 242 insertions(+), 70 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvent.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowMessageEvent.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ParseValueExecutorTest.cs diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index 1f72e3febb..2ad424deaa 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -8,6 +8,7 @@ using Azure.Identity; using Microsoft.Agents.Workflows; using Microsoft.Agents.Workflows.Declarative; +using Microsoft.Bot.ObjectModel; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.Diagnostics; @@ -37,7 +38,6 @@ public static async Task Main(string[] args) new() { LoggerFactory = NullLoggerFactory.Instance, - ActivityChannel = System.Console.Out, ProjectEndpoint = Throw.IfNull(config["AzureAI:Endpoint"]), ProjectCredentials = new AzureCliCredential(), }; @@ -65,6 +65,38 @@ public static async Task Main(string[] args) { Debug.WriteLine($"!!! EXIT #{executorComplete.ExecutorId}"); } + else if (evt is DeclarativeWorkflowStreamEvent streamEvent) + { + //Console.WriteLine($"#{messageEvent.Data.MessageId}:{Environment.NewLine}{messageEvent.Data}"); // %%% TODO + } + else if (evt is DeclarativeWorkflowMessageEvent messageEvent) + { + try + { + if (messageEvent.Data.MessageId is null) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(messageEvent.Data); + } + else + { + Console.ForegroundColor = ConsoleColor.Gray; + Console.WriteLine($"#{messageEvent.Data.MessageId}:"); + Console.ForegroundColor = ConsoleColor.White; + Console.WriteLine(messageEvent.Data); + if (messageEvent.Usage is not null) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($"[Tokens Total: {messageEvent.Usage.TotalTokenCount}, Input: {messageEvent.Usage.InputTokenCount}, Output: {messageEvent.Usage.OutputTokenCount}]"); + } + } + Console.WriteLine(); + } + finally + { + Console.ResetColor(); + } + } } ////////////////////////////////////////////// diff --git a/dotnet/demos/DeclarativeWorkflow/demo250729.yaml b/dotnet/demos/DeclarativeWorkflow/demo250729.yaml index 611ec7912b..b58463c089 100644 --- a/dotnet/demos/DeclarativeWorkflow/demo250729.yaml +++ b/dotnet/demos/DeclarativeWorkflow/demo250729.yaml @@ -42,11 +42,6 @@ beginDialog: userInput: =Topic.Question additionalInstructions: "{Topic.Instructions}" - # Display the AI's answer - - kind: SendActivity - id: sendActivity_zA3f0p - activity: "AI - {Topic.Answer}" - # After processing all questions, display a completion message - kind: SendActivity id: sendActivity_SVoNSV diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index 0706571adc..8c3a44147e 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -59,7 +59,6 @@ public async Task RunWorkflow(string fileName, bool enableApiIntercept = false) { HttpClient = customClient, LoggerFactory = this.LoggerFactory, - ActivityChannel = System.Console.Out, ProjectEndpoint = Throw.IfNull(TestConfiguration.AzureAI.Endpoint), ProjectCredentials = new AzureCliCredential(), }; @@ -83,8 +82,25 @@ public async Task RunWorkflow(string fileName, bool enableApiIntercept = false) { Debug.WriteLine($"!!! EXIT #{executorComplete.ExecutorId}"); } + else if (evt is DeclarativeWorkflowMessageEvent messageEvent) + { + if (messageEvent.Data.MessageId is null) + { + Console.WriteLine(messageEvent.Data); + } + else + { + Console.WriteLine($"#{messageEvent.Data.MessageId}:"); + Console.WriteLine(messageEvent.Data); + if (messageEvent.Usage is not null) + { + Console.WriteLine($"[Tokens Total: {messageEvent.Usage.TotalTokenCount}, Input: {messageEvent.Usage.InputTokenCount}, Output: {messageEvent.Usage.OutputTokenCount}]"); + } + Console.WriteLine(); + } + } + Debug.WriteLine("\nWORKFLOW DONE"); } - Debug.WriteLine("\nWORKFLOW DONE"); } finally { diff --git a/dotnet/samples/GettingStarted/Workflows/demo250729.yaml b/dotnet/samples/GettingStarted/Workflows/demo250729.yaml index 611ec7912b..b58463c089 100644 --- a/dotnet/samples/GettingStarted/Workflows/demo250729.yaml +++ b/dotnet/samples/GettingStarted/Workflows/demo250729.yaml @@ -42,11 +42,6 @@ beginDialog: userInput: =Topic.Question additionalInstructions: "{Topic.Instructions}" - # Display the AI's answer - - kind: SendActivity - id: sendActivity_zA3f0p - activity: "AI - {Topic.Answer}" - # After processing all questions, display a completion message - kind: SendActivity id: sendActivity_SVoNSV diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs index 5f31b6ba40..1e854b36eb 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs @@ -1,7 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; -using System.IO; using System.Net.Http; using Azure.AI.Agents.Persistent; using Azure.Core; @@ -51,11 +49,6 @@ public sealed class DeclarativeWorkflowContext /// public ILoggerFactory LoggerFactory { get; init; } = NullLoggerFactory.Instance; - /// - /// Gets the used for activity output and diagnostics. - /// - public TextWriter ActivityChannel { get; init; } = TextWriter.Null; - internal WorkflowExecutionContext CreateActionContext(string rootId, WorkflowScopes scopes) => new(RecalcEngineFactory.Create(scopes, this.MaximumExpressionLength), scopes, diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvent.cs new file mode 100644 index 0000000000..a965e22563 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvent.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows; + +/// +/// %%% COMMENT +/// +public class DeclarativeWorkflowEvent(object? data) : WorkflowEvent(data) +{ +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowMessageEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowMessageEvent.cs new file mode 100644 index 0000000000..48ff1849c0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowMessageEvent.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.Workflows; + +/// +/// %%% COMMENT +/// +public class DeclarativeWorkflowMessageEvent(ChatMessage message, UsageDetails? usage = null) : DeclarativeWorkflowEvent(message) +{ + /// + /// %%% COMMENT + /// + public new ChatMessage Data => message; + + /// + /// %%% COMMENT + /// + public UsageDetails? Usage => usage; +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs new file mode 100644 index 0000000000..88c85c7b81 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.Workflows; + +/// +/// %%% COMMENT +/// +public class DeclarativeWorkflowStreamEvent(ChatResponseUpdate update) : DeclarativeWorkflowEvent(update) +{ + /// + /// %%% COMMENT + /// + public new ChatResponseUpdate Data => update; +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs index ad1b73c61f..7939c87222 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs @@ -16,7 +16,7 @@ namespace Microsoft.Agents.Workflows.Declarative.Execution; internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model) : WorkflowActionExecutor(model) { - protected override async ValueTask ExecuteAsync(CancellationToken cancellationToken) + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(this.Model.Variable)}"); StringExpression userInputExpression = Throw.IfNull(this.Model.UserInput, $"{nameof(this.Model)}.{nameof(this.Model.UserInput)}"); @@ -38,11 +38,16 @@ protected override async ValueTask ExecuteAsync(CancellationToken cancellationTo { Instructions = this.Context.Engine.Format(this.Model.AdditionalInstructions) ?? string.Empty, }); - AgentRunResponse response = + AgentRunResponse agentResponse = userInput != null ? await agent.RunAsync(userInput, thread: null, options, cancellationToken).ConfigureAwait(false) : await agent.RunAsync(thread: null, options, cancellationToken).ConfigureAwait(false); - StringValue responseValue = FormulaValue.New(response.Messages.Last().ToString()); + + ChatMessage response = agentResponse.Messages.Last(); + + await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(response, agentResponse.Usage)).ConfigureAwait(false); + + StringValue responseValue = FormulaValue.New(response.ToString()); // %%% CPS - AgentMessageType this.AssignTarget(this.Context, variablePath, responseValue); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs index 6fe9ef6fcb..2a2e338376 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs @@ -10,7 +10,7 @@ namespace Microsoft.Agents.Workflows.Declarative.Execution; internal sealed class ClearAllVariablesExecutor(ClearAllVariables model) : WorkflowActionExecutor(model) { - protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) + protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { EvaluationResult result = this.Context.ExpressionEngine.GetValue(this.Model.Variables, this.Context.Scopes); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ConditionGroupExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ConditionGroupExecutor.cs index 4cc5cba7c0..4793cc7173 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ConditionGroupExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ConditionGroupExecutor.cs @@ -8,17 +8,12 @@ namespace Microsoft.Agents.Workflows.Declarative.Execution; internal sealed class ConditionGroupExecutor : WorkflowActionExecutor { - public static class Steps - { - public static string End(string id) => $"{id}_{nameof(End)}"; - } - public ConditionGroupExecutor(ConditionGroup model) : base(model) { } - protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) + protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { return new ValueTask(); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs index 516fa18523..3514650042 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs @@ -15,7 +15,7 @@ namespace Microsoft.Agents.Workflows.Declarative.Execution; internal sealed class EditTableV2Executor(EditTableV2 model) : WorkflowActionExecutor(model) { - protected async override ValueTask ExecuteAsync(CancellationToken cancellationToken) + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { PropertyPath variablePath = Throw.IfNull(this.Model.ItemsVariable?.Path, $"{nameof(this.Model)}.{nameof(this.Model.ItemsVariable)}"); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EndConversationExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EndConversationExecutor.cs index 3c7d58075b..b87cbf7b78 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EndConversationExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EndConversationExecutor.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.Workflows.Declarative.Execution; internal sealed class EndConversationExecutor(EndConversation model) : WorkflowActionExecutor(model) { - protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) + protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { // %%% DIAGNOSTICS / STATE MANAGEMENT ??? return new ValueTask(); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ForeachExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ForeachExecutor.cs index 51c309674f..bf428b2d61 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ForeachExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ForeachExecutor.cs @@ -32,7 +32,7 @@ public ForeachExecutor(Foreach model) public bool HasValue { get; private set; } - protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) + protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { this._index = 0; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs index e1f22067bf..3e01351089 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs @@ -15,7 +15,7 @@ namespace Microsoft.Agents.Workflows.Declarative.Execution; internal sealed class ParseValueExecutor(ParseValue model) : WorkflowActionExecutor(model) { - protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) + protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); ValueExpression valueExpression = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); @@ -59,6 +59,6 @@ private static RecordValue ParseRecord(RecordDataType recordType, string rawText string jsonText = rawText.TrimJsonDelimiter(); JsonDocument json = JsonDocument.Parse(jsonText); JsonElement currentElement = json.RootElement; - return recordType.ParseRecord(currentElement); + return recordType.ParseRecord(currentElement); // %%% FIX / REMOVE ??? } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ResetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ResetVariableExecutor.cs index 854eec3e49..65ea7a8379 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ResetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ResetVariableExecutor.cs @@ -13,7 +13,7 @@ namespace Microsoft.Agents.Workflows.Declarative.Execution; internal sealed class ResetVariableExecutor(ResetVariable model) : WorkflowActionExecutor(model) { - protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) + protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { PropertyPath variablePath = Throw.IfNull(this.Model.Variable, $"{nameof(this.Model)}.{nameof(model.Variable)}"); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs index 23e438fe83..316bd7896e 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs @@ -1,30 +1,32 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.IO; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.AI; namespace Microsoft.Agents.Workflows.Declarative.Execution; -internal sealed class SendActivityExecutor(SendActivity model, TextWriter activityWriter) : +internal sealed class SendActivityExecutor(SendActivity model) : WorkflowActionExecutor(model) { - protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { if (this.Model.Activity is MessageActivityTemplate messageActivity) { + StringBuilder templateBuilder = new(); if (!string.IsNullOrEmpty(messageActivity.Summary)) { - activityWriter.WriteLine($"\t{messageActivity.Summary}"); + templateBuilder.AppendLine($"\t{messageActivity.Summary}"); } string? activityText = this.Context.Engine.Format(messageActivity.Text)?.Trim(); - activityWriter.WriteLine(activityText + Environment.NewLine); - } + templateBuilder.AppendLine(activityText + Environment.NewLine); - return new ValueTask(); + await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(new ChatMessage(ChatRole.Assistant, templateBuilder.ToString()))).ConfigureAwait(false); + } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetTextVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetTextVariableExecutor.cs index 021f601fa8..3b70e50666 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetTextVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetTextVariableExecutor.cs @@ -11,7 +11,7 @@ namespace Microsoft.Agents.Workflows.Declarative.Execution; internal sealed class SetTextVariableExecutor(SetTextVariable model) : WorkflowActionExecutor(model) { - protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) + protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetVariableExecutor.cs index 729aa4adab..4119088eba 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetVariableExecutor.cs @@ -12,7 +12,7 @@ namespace Microsoft.Agents.Workflows.Declarative.Execution; internal sealed class SetVariableExecutor(SetVariable model) : WorkflowActionExecutor(model) { - protected override ValueTask ExecuteAsync(CancellationToken cancellationToken) + protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs index 518c2c3e8a..b5cb54377b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs @@ -64,7 +64,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext context) try { - await this.ExecuteAsync(cancellationToken: default).ConfigureAwait(false); + await this.ExecuteAsync(context, cancellationToken: default).ConfigureAwait(false); await context.SendMessageAsync($"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}").ConfigureAwait(false); } @@ -80,7 +80,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext context) } } - protected abstract ValueTask ExecuteAsync(CancellationToken cancellationToken = default); + protected abstract ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default); protected void AssignTarget(WorkflowExecutionContext context, PropertyPath targetPath, FormulaValue result) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 416b31815a..9cebea5f32 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -16,7 +16,6 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor { private readonly WorkflowBuilder _workflowBuilder; private readonly WorkflowModel _workflowModel; - private readonly DeclarativeWorkflowContext _workflowContext; private readonly WorkflowScopes _scopes; private readonly WorkflowExecutionContext _executionContext; @@ -27,7 +26,6 @@ public WorkflowActionVisitor( { this._workflowModel = new WorkflowModel(rootAction); this._workflowBuilder = new WorkflowBuilder(rootAction); - this._workflowContext = workflowContext; this._scopes = scopes; this._executionContext = workflowContext.CreateActionContext(rootAction.Id, scopes); @@ -250,7 +248,7 @@ protected override void Visit(SendActivity item) { this.Trace(item); - this.ContinueWith(new SendActivityExecutor(item, this._workflowContext.ActivityChannel)); + this.ContinueWith(new SendActivityExecutor(item)); } #region Not supported diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index a16d3c8034..a57762a6d5 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -9,7 +9,6 @@ using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Agents.Workflows.Reflection; using Microsoft.Bot.ObjectModel; -using Microsoft.Extensions.Logging.Abstractions; using Xunit.Abstractions; namespace Microsoft.Agents.Workflows.Declarative.UnitTests; @@ -126,7 +125,7 @@ public async Task ConditionAction() [InlineData(typeof(UnknownDialogAction.Builder))] [InlineData(typeof(UpdateActivity.Builder))] [InlineData(typeof(WaitForConnectorTrigger.Builder))] - public async Task UnsupportedAction(Type type) + public void UnsupportedAction(Type type) { DialogAction.Builder? unsupportedAction = (DialogAction.Builder?)Activator.CreateInstance(type); Assert.NotNull(unsupportedAction); @@ -143,12 +142,7 @@ public async Task UnsupportedAction(Type type) }; WorkflowScopes scopes = new(); - DeclarativeWorkflowContext workflowContext = - new() - { - LoggerFactory = NullLoggerFactory.Instance, - ActivityChannel = this.Output, - }; + DeclarativeWorkflowContext workflowContext = DeclarativeWorkflowContext.Default; WorkflowActionVisitor visitor = new(new RootExecutor(), workflowContext, scopes); WorkflowElementWalker walker = new(dialogBuilder.Build(), visitor); Assert.True(visitor.HasUnsupportedActions); @@ -175,12 +169,7 @@ private void AssertExecuted(string executorId) private async Task RunWorkflow(string workflowPath) { using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath)); - DeclarativeWorkflowContext workflowContext = - new() - { - LoggerFactory = NullLoggerFactory.Instance, - ActivityChannel = this.Output, - }; + DeclarativeWorkflowContext workflowContext = DeclarativeWorkflowContext.Default; Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ParseValueExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ParseValueExecutorTest.cs new file mode 100644 index 0000000000..4cdf6bd772 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ParseValueExecutorTest.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Execution; + +/// +/// Tests for . +/// +public sealed class ParseValueExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public async Task ParseTable() + { + // Arrange + ParseValue model = + this.CreateModel( + this.FormatDisplayName(nameof(ParseTable)), + new RecordDataType.Builder(), + @"{ ""key1"": ""val1"" }"); + + // Act + ParseValueExecutor action = new(model); + await this.Execute(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyState("Target", FormulaValue.NewRecordFromFields(new NamedValue("key1", FormulaValue.New("val1")))); + } + + [Fact] + public async Task ParseBoolean() + { + // Arrange + ParseValue model = + this.CreateModel( + this.FormatDisplayName(nameof(ParseTable)), + new BooleanDataType.Builder(), + "true"); + + // Act + ParseValueExecutor action = new(model); + await this.Execute(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyState("Target", FormulaValue.New(true)); + } + + [Fact] + public async Task ParseNumber() + { + // Arrange + ParseValue model = + this.CreateModel( + this.FormatDisplayName(nameof(ParseNumber)), + new NumberDataType.Builder(), + "42"); + + // Act + ParseValueExecutor action = new(model); + await this.Execute(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyState("Target", FormulaValue.New(42)); + } + + [Fact] + public async Task ParseString() + { + // Arrange + ParseValue model = + this.CreateModel( + this.FormatDisplayName(nameof(ParseString)), + new StringDataType.Builder(), + "Hello, World!"); + + // Act + ParseValueExecutor action = new(model); + await this.Execute(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyState("Target", FormulaValue.New("Hello, World!")); + } + + private ParseValue CreateModel(string displayName, DataType.Builder typeBuilder, string sourceText) + { + ParseValue.Builder actionBuilder = + new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + ValueType = typeBuilder, + Variable = PropertyPath.TopicVariable("Target"), + Value = new ValueExpression.Builder(ValueExpression.Literal(StringDataValue.Create(sourceText))), + }; + + ParseValue model = this.AssignParent(actionBuilder); + + return model; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs index aea76671af..d5ad16c882 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs @@ -1,9 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. -using System.IO; using System.Threading.Tasks; -using Microsoft.Bot.ObjectModel; using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Bot.ObjectModel; using Xunit.Abstractions; namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Execution; @@ -21,16 +20,14 @@ public async Task CaptureActivity() this.CreateModel( this.FormatDisplayName(nameof(CaptureActivity)), "Test activity message"); - using StringWriter activityWriter = new(); // Act - SendActivityExecutor action = new(model, activityWriter); + SendActivityExecutor action = new(model); await this.Execute(action); - activityWriter.Flush(); // Assert this.VerifyModel(model, action); - Assert.NotEmpty(activityWriter.ToString()); + // %%% VERIFY EVENT } private SendActivity CreateModel(string displayName, string activityMessage, string? summary = null) From 760d3ca7acbee448cca8614d29db5ac0b0d2cc3f Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Wed, 13 Aug 2025 20:22:12 -0700 Subject: [PATCH 148/232] Comments and clean-up --- dotnet/demos/DeclarativeWorkflow/Program.cs | 4 ++-- .../DeclarativeWorkflowBuilder.cs | 2 +- .../DeclarativeWorkflowEvent.cs | 2 +- .../DeclarativeWorkflowMessageEvent.cs | 6 +++--- .../DeclarativeWorkflowStreamEvent.cs | 4 ++-- .../PowerFx/WorkflowExpressionEngine.cs | 2 -- .../Execution/SendActivityExecutorTest.cs | 5 +++-- .../Execution/WorkflowActionExecutorTest.cs | 3 ++- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index 2ad424deaa..74bba182ac 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -67,7 +67,7 @@ public static async Task Main(string[] args) } else if (evt is DeclarativeWorkflowStreamEvent streamEvent) { - //Console.WriteLine($"#{messageEvent.Data.MessageId}:{Environment.NewLine}{messageEvent.Data}"); // %%% TODO + //Console.WriteLine($"#{messageEvent.Data.MessageId}:{Environment.NewLine}{messageEvent.Data}"); // %%% TODO: Streaming } else if (evt is DeclarativeWorkflowMessageEvent messageEvent) { @@ -89,8 +89,8 @@ public static async Task Main(string[] args) Console.ForegroundColor = ConsoleColor.DarkGray; Console.WriteLine($"[Tokens Total: {messageEvent.Usage.TotalTokenCount}, Input: {messageEvent.Usage.InputTokenCount}, Output: {messageEvent.Usage.OutputTokenCount}]"); } + Console.WriteLine(); } - Console.WriteLine(); } finally { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 9d29814fbb..064db048f5 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -39,7 +39,7 @@ public static Workflow Build(TextReader yamlReader, DeclarativeWorkflowC return walker.Workflow; } - private static string GetRootId(BotElement element) => // %%% WORKFLOW TYPE + private static string GetRootId(BotElement element) => // %%% CPS - WORKFLOW TYPE element switch { AdaptiveDialog adaptiveDialog => adaptiveDialog.BeginDialog?.Id.Value ?? throw new UnknownActionException("Undefined dialog"), diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvent.cs index a965e22563..03f7bed43e 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvent.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvent.cs @@ -3,7 +3,7 @@ namespace Microsoft.Agents.Workflows; /// -/// %%% COMMENT +/// Base class for events that occur during the execution of a declarative workflow. /// public class DeclarativeWorkflowEvent(object? data) : WorkflowEvent(data) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowMessageEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowMessageEvent.cs index 48ff1849c0..b9fe9fd7c4 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowMessageEvent.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowMessageEvent.cs @@ -5,17 +5,17 @@ namespace Microsoft.Agents.Workflows; /// -/// %%% COMMENT +/// Event that represents a message produced by a declarative workflow. /// public class DeclarativeWorkflowMessageEvent(ChatMessage message, UsageDetails? usage = null) : DeclarativeWorkflowEvent(message) { /// - /// %%% COMMENT + /// The message data produced by the workflow, which is a . /// public new ChatMessage Data => message; /// - /// %%% COMMENT + /// The usage details associated with the message, if any. /// public UsageDetails? Usage => usage; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs index 88c85c7b81..ab2687bb79 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs @@ -5,12 +5,12 @@ namespace Microsoft.Agents.Workflows; /// -/// %%% COMMENT +/// Event that represents a streamed message produced by a declarative workflow. /// public class DeclarativeWorkflowStreamEvent(ChatResponseUpdate update) : DeclarativeWorkflowEvent(update) { /// - /// %%% COMMENT + /// The streamed response data produced by the workflow, which is a . /// public new ChatResponseUpdate Data => update; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs index 33116c3d86..cec87bd1b2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs @@ -15,8 +15,6 @@ namespace Microsoft.Agents.Workflows.Declarative.PowerFx; internal class WorkflowExpressionEngine : IExpressionEngine { - //private static readonly JsonSerializerOptions s_options = new(); // %%% INVESTIGATE: ElementSerializer.CreateOptions(); - private readonly RecalcEngine _engine; public WorkflowExpressionEngine(RecalcEngine engine) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs index d5ad16c882..6009b28242 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Bot.ObjectModel; @@ -23,11 +24,11 @@ public async Task CaptureActivity() // Act SendActivityExecutor action = new(model); - await this.Execute(action); + WorkflowEvent[] events = await this.Execute(action); // Assert this.VerifyModel(model, action); - // %%% VERIFY EVENT + Assert.Contains(events, e => e is DeclarativeWorkflowMessageEvent); } private SendActivity CreateModel(string displayName, string activityMessage, string? summary = null) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs index 858a4ff180..2ad90cc9c1 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs @@ -23,13 +23,14 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor protected string FormatDisplayName(string name) => $"{this.GetType().Name}_{name}"; - internal async Task Execute(WorkflowActionExecutor executor) + internal async Task Execute(WorkflowActionExecutor executor) { WorkflowExecutionContext context = new(RecalcEngineFactory.Create(this.Scopes), this.Scopes, () => null!, NullLogger.Instance); executor.Attach(context); WorkflowBuilder workflowBuilder = new(executor); StreamingRun run = await InProcessExecution.StreamAsync(workflowBuilder.Build(), ""); WorkflowEvent[] events = await run.WatchStreamAsync().ToArrayAsync(); + return events; } internal void VerifyModel(DialogAction model, WorkflowActionExecutor action) From fcf69babc3dd39baa70737478f95e3723ab0fcf0 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 14 Aug 2025 08:57:43 -0700 Subject: [PATCH 149/232] Format --- .../Execution/SendActivityExecutor.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs index 316bd7896e..9044a72a92 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -24,7 +23,7 @@ protected override async ValueTask ExecuteAsync(IWorkflowContext context, Cancel } string? activityText = this.Context.Engine.Format(messageActivity.Text)?.Trim(); - templateBuilder.AppendLine(activityText + Environment.NewLine); + templateBuilder.AppendLine(activityText); await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(new ChatMessage(ChatRole.Assistant, templateBuilder.ToString()))).ConfigureAwait(false); } From 3209066b41440abecffbb3e289039d65183d5581 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 14 Aug 2025 09:39:01 -0700 Subject: [PATCH 150/232] Cleanup --- .../Execution/ClearAllVariablesExecutor.cs | 2 +- .../Execution/ForeachExecutor.cs | 34 ++++++++++++------- .../Extensions/DataValueExtensions.cs | 6 ++-- .../Interpreter/WorkflowActionVisitor.cs | 6 ++-- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs index 2a2e338376..45ca783460 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs @@ -43,7 +43,7 @@ public void HandleUnknownValue() public void HandleUserScopedVariables() { - context.Engine.ClearScope(context.Scopes, WorkflowScopeType.Env); + context.Engine.ClearScope(context.Scopes, WorkflowScopeType.Env); // %%% CORRECT ??? } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ForeachExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ForeachExecutor.cs index bf428b2d61..9b9fd2228b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ForeachExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ForeachExecutor.cs @@ -44,36 +44,44 @@ protected override ValueTask ExecuteAsync(IWorkflowContext context, Cancellation else { EvaluationResult result = this.Context.ExpressionEngine.GetValue(this.Model.Items, this.Context.Scopes); - TableDataValue tableValue = (TableDataValue)result.Value; // %%% CAST - TYPE ASSUMPTION (TableDataValue) - this._values = [.. tableValue.Values.Select(value => value.Properties.Values.First().ToFormulaValue())]; + if (result.Value is TableDataValue tableValue) + { + this._values = [.. tableValue.Values.Select(value => value.Properties.Values.First().ToFormulaValue())]; + } + else + { + this._values = [result.Value.ToFormulaValue()]; + } } + this.Reset(); + return new ValueTask(); } - public void TakeNext(WorkflowExecutionContext context) + public void TakeNext() { if (this.HasValue = this._index < this._values.Length) { FormulaValue value = this._values[this._index]; - context.Engine.SetScopedVariable(context.Scopes, Throw.IfNull(this.Model.Value), value); + this.Context.Engine.SetScopedVariable(this.Context.Scopes, Throw.IfNull(this.Model.Value), value); if (this.Model.Index is not null) { - context.Engine.SetScopedVariable(context.Scopes, this.Model.Index.Path, FormulaValue.New(this._index)); + this.Context.Engine.SetScopedVariable(this.Context.Scopes, this.Model.Index.Path, FormulaValue.New(this._index)); } this._index++; } } - //public void Reset(WorkflowExecutionContext context) // %%% NEEDED - //{ - // context.Engine.ClearScopedVariable(context.Scopes, Throw.IfNull(this.Model.Value)); - // if (this.Model.Index is not null) - // { - // context.Engine.ClearScopedVariable(context.Scopes, this.Model.Index); - // } - //} + public void Reset() + { + this.Context.Engine.ClearScopedVariable(this.Context.Scopes, Throw.IfNull(this.Model.Value)); + if (this.Model.Index is not null) + { + this.Context.Engine.ClearScopedVariable(this.Context.Scopes, this.Model.Index); + } + } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs index 8d144e65d6..c1c87bd8be 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs @@ -23,8 +23,8 @@ public static FormulaValue ToFormulaValue(this DataValue? value) => TimeDataValue timeValue => FormulaValue.New(timeValue.Value), TableDataValue tableValue => FormulaValue.NewTable(tableValue.Values.First().ParseRecordType(), tableValue.Values.Select(value => value.ToRecordValue())), RecordDataValue recordValue => recordValue.ToRecordValue(), + OptionDataValue optionValue => FormulaValue.New(optionValue.Value.Value), //FileDataValue // %%% SUPPORT ??? - //OptionDataValue // %%% SUPPORT - Enum ??? _ => FormulaValue.NewError(new Microsoft.PowerFx.ExpressionError { Message = $"Unknown literal type: {value.GetType().Name}" }), }; @@ -39,10 +39,10 @@ public static FormulaType ToFormulaType(this DataType? type) => DateTimeDataType => FormulaType.DateTime, DateDataType => FormulaType.Date, TimeDataType => FormulaType.Time, - //TableDataType => new TableType(), %%% ELEMENT TYPE RecordDataType => RecordType.Empty(), + //TableDataType => new TableType(), // %%% SUPPORT ??? NEED ELEMENT TYPE //FileDataType // %%% SUPPORT ??? - //OptionDataType // %%% SUPPORT - Enum ??? + OptionSetDataType => FormulaType.String, DataType dataType => FormulaType.Blank, }; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 9cebea5f32..464ddbe229 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -148,7 +148,7 @@ protected override void Visit(Foreach item) void CompletionHandler() { string completionId = ForeachExecutor.Steps.End(action.Id); - this.ContinueWith(this.CreateStep(completionId, $"{nameof(ForeachExecutor)}_End"), action.Id); + this.ContinueWith(this.CreateStep(completionId, $"{nameof(ForeachExecutor)}_End", action.Reset), action.Id); this._workflowModel.AddLink(completionId, loopId); } } @@ -461,13 +461,13 @@ private string RestartFrom(string actionId, string name, string parentId) return restartId; } - private WorkflowDelegateExecutor CreateStep(string actionId, string name, Action? stepAction = null) + private WorkflowDelegateExecutor CreateStep(string actionId, string name, Action? stepAction = null) { WorkflowDelegateExecutor stepExecutor = new(actionId, () => { - stepAction?.Invoke(this._executionContext); + stepAction?.Invoke(); return new ValueTask(); }); From 856fd7d335afe7e0da04febc6e0a79c9df66ea40 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 14 Aug 2025 14:45:16 -0700 Subject: [PATCH 151/232] Test checkpoint --- .../Workflows/Workflows_Declarative.cs | 1 + .../Workflows/testCondition2.yaml | 32 ++ .../PowerFx/WorkflowScopeTypeTests.cs | 104 ++++ .../DeclarativeWorkflowBuilder.cs | 5 +- .../DeclarativeWorkflowEvent.cs | 2 +- .../DeclarativeWorkflowMessageEvent.cs | 2 +- .../DeclarativeWorkflowStreamEvent.cs | 2 +- .../Extensions/DataValueExtensions.cs | 2 +- .../Interpreter/WorkflowModel.cs | 4 +- .../PowerFx/WorkflowExpressionEngine.cs | 6 +- .../DeclarativeWorkflowContextTests.cs | 118 +++++ .../DeclarativeWorkflowContextTest.cs | 126 +++++ .../DeclarativeWorkflowEventTest.cs | 35 ++ .../DeclarativeWorkflowExceptionTest.cs | 84 ++++ .../DeclarativeWorkflowTest.cs | 30 +- .../Interpreter/WorkflowModelTest.cs | 73 +++ .../PowerFx/RecalcEngineFactoryTests.cs | 7 +- .../PowerFx/WorkflowExpressionEngineTests.cs | 454 +++++++++++++++--- .../PowerFx/WorkflowScopeTypeTest.cs | 102 ++++ .../PowerFx/WorkflowScopesTests.cs | 22 + .../Workflows/BadEmpty.yaml | 4 + .../Workflows/BadId.yaml | 7 + .../Workflows/BadKind.yaml | 8 + .../Workflows/ClearAllVariables.yaml | 10 + .../Workflows/Condition.yaml | 6 +- .../Workflows/EditTable.yaml | 16 + .../Workflows/ParseValue.yaml | 11 + .../Workflows/ResetVariable.yaml | 14 + .../Workflows/SetTextVariable.yaml | 10 + 29 files changed, 1207 insertions(+), 90 deletions(-) create mode 100644 dotnet/samples/GettingStarted/Workflows/testCondition2.yaml create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative.Tests/PowerFx/WorkflowScopeTypeTests.cs create mode 100644 dotnet/test/Microsoft.Agents.Workflows.Declarative.Tests/DeclarativeWorkflowContextTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowEventTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowExceptionTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Interpreter/WorkflowModelTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopeTypeTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadEmpty.yaml create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadId.yaml create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadKind.yaml create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ClearAllVariables.yaml create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/EditTable.yaml create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ParseValue.yaml create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ResetVariable.yaml create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/SetTextVariable.yaml diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index 8c3a44147e..eed235321f 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -27,6 +27,7 @@ public class Workflows_Declarative(ITestOutputHelper output) : OrchestrationSamp [InlineData("testChat", true)] [InlineData("testCondition0")] [InlineData("testCondition1")] + [InlineData("testCondition2")] [InlineData("testEnd")] [InlineData("testExpression")] [InlineData("testGoto")] diff --git a/dotnet/samples/GettingStarted/Workflows/testCondition2.yaml b/dotnet/samples/GettingStarted/Workflows/testCondition2.yaml new file mode 100644 index 0000000000..b5b201c635 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/testCondition2.yaml @@ -0,0 +1,32 @@ +kind: AdaptiveDialog +beginDialog: + + kind: OnActivity + id: my_workflow + type: Message + actions: + + - kind: SetVariable + id: setVariable_test + variable: Topic.Count + value: =32 + + - kind: ConditionGroup + id: conditionGroup_test + conditions: + - id: conditionItem_odd + condition: =Mod(Topic.Count, 1) = 1 + actions: + - kind: SendActivity + id: sendActivity_odd + activity: ODD + + - id: conditionItem_even + condition: =Mod(Topic.Count, 1) = 0 + actions: + - kind: SendActivity + id: sendActivity_even + activity: EVEN + + - kind: EndConversation + id: end_all diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative.Tests/PowerFx/WorkflowScopeTypeTests.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative.Tests/PowerFx/WorkflowScopeTypeTests.cs new file mode 100644 index 0000000000..2a0c56d878 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative.Tests/PowerFx/WorkflowScopeTypeTests.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Xunit; + +namespace Microsoft.Agents.Workflows.Declarative.Tests.PowerFx; + +public class WorkflowScopeTypeTests +{ + [Fact] + public void StaticFieldsHaveCorrectNames() + { + Assert.Equal(VariableScopeNames.Environment, WorkflowScopeType.Env.Name); + Assert.Equal(VariableScopeNames.Topic, WorkflowScopeType.Topic.Name); + Assert.Equal(VariableScopeNames.Global, WorkflowScopeType.Global.Name); + Assert.Equal(VariableScopeNames.System, WorkflowScopeType.System.Name); + } + + [Fact] + public void ParseReturnsCorrectScopeType() + { + WorkflowScopeType envScope = WorkflowScopeType.Parse("Env"); + WorkflowScopeType topicScope = WorkflowScopeType.Parse("Topic"); + WorkflowScopeType globalScope = WorkflowScopeType.Parse("Global"); + WorkflowScopeType systemScope = WorkflowScopeType.Parse("System"); + + Assert.Same(WorkflowScopeType.Env, envScope); + Assert.Same(WorkflowScopeType.Topic, topicScope); + Assert.Same(WorkflowScopeType.Global, globalScope); + Assert.Same(WorkflowScopeType.System, systemScope); + } + + [Fact] + public void ParseThrowsForNullScope() + { + InvalidScopeException exception = Assert.Throws(() => WorkflowScopeType.Parse(null)); + Assert.Equal("Undefined action scope type.", exception.Message); + } + + [Fact] + public void ParseThrowsForUnknownScope() + { + string unknownScope = "Unknown"; + InvalidScopeException exception = Assert.Throws(() => WorkflowScopeType.Parse(unknownScope)); + Assert.Equal($"Unknown action scope type: {unknownScope}.", exception.Message); + } + + [Fact] + public void FormatReturnsScopedName() + { + string variableName = "myVariable"; + + string formattedEnv = WorkflowScopeType.Env.Format(variableName); + string formattedTopic = WorkflowScopeType.Topic.Format(variableName); + string formattedGlobal = WorkflowScopeType.Global.Format(variableName); + string formattedSystem = WorkflowScopeType.System.Format(variableName); + + Assert.Equal($"{VariableScopeNames.Environment}.{variableName}", formattedEnv); + Assert.Equal($"{VariableScopeNames.Topic}.{variableName}", formattedTopic); + Assert.Equal($"{VariableScopeNames.Global}.{variableName}", formattedGlobal); + Assert.Equal($"{VariableScopeNames.System}.{variableName}", formattedSystem); + } + + [Fact] + public void ToStringReturnsName() + { + Assert.Equal(VariableScopeNames.Environment, WorkflowScopeType.Env.ToString()); + Assert.Equal(VariableScopeNames.Topic, WorkflowScopeType.Topic.ToString()); + Assert.Equal(VariableScopeNames.Global, WorkflowScopeType.Global.ToString()); + Assert.Equal(VariableScopeNames.System, WorkflowScopeType.System.ToString()); + } + + [Fact] + public void GetHashCodeReturnsNameHashCode() + { + Assert.Equal(VariableScopeNames.Environment.GetHashCode(), WorkflowScopeType.Env.GetHashCode()); + Assert.Equal(VariableScopeNames.Topic.GetHashCode(), WorkflowScopeType.Topic.GetHashCode()); + Assert.Equal(VariableScopeNames.Global.GetHashCode(), WorkflowScopeType.Global.GetHashCode()); + Assert.Equal(VariableScopeNames.System.GetHashCode(), WorkflowScopeType.System.GetHashCode()); + } + + [Fact] + public void EqualsReturnsTrueForSameType() + { + Assert.True(WorkflowScopeType.Env.Equals(WorkflowScopeType.Env)); + Assert.False(WorkflowScopeType.Env.Equals(WorkflowScopeType.Topic)); + } + + [Fact] + public void EqualsReturnsTrueForMatchingString() + { + Assert.True(WorkflowScopeType.Env.Equals(VariableScopeNames.Environment)); + Assert.False(WorkflowScopeType.Env.Equals(VariableScopeNames.Topic)); + } + + [Fact] + public void EqualsReturnsFalseForNonMatchingTypes() + { + Assert.False(WorkflowScopeType.Env.Equals(42)); + Assert.False(WorkflowScopeType.Env.Equals(null)); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 064db048f5..bec246fe57 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -19,16 +19,15 @@ public static class DeclarativeWorkflowBuilder /// Builds a process from the provided YAML definition of a CPS Topic ObjectModel. /// /// The reader that provides the workflow object model YAML. - /// The hosting context for the workflow. + /// The execution context for the workflow. /// The that corresponds with the YAML object model. - public static Workflow Build(TextReader yamlReader, DeclarativeWorkflowContext? context = null) + public static Workflow Build(TextReader yamlReader, DeclarativeWorkflowContext context) { Debug.WriteLine("@ PARSING YAML"); BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new UnknownActionException("Unable to parse workflow."); string rootId = $"root_{GetRootId(rootElement)}"; Debug.WriteLine("@ INITIALIZING BUILDER"); - context ??= DeclarativeWorkflowContext.Default; WorkflowScopes scopes = new(); DeclarativeWorkflowExecutor rootExecutor = new(rootId, scopes); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvent.cs index 03f7bed43e..14532b9dd1 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvent.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvent.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -namespace Microsoft.Agents.Workflows; +namespace Microsoft.Agents.Workflows.Declarative; /// /// Base class for events that occur during the execution of a declarative workflow. diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowMessageEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowMessageEvent.cs index b9fe9fd7c4..e3227927aa 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowMessageEvent.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowMessageEvent.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.AI; -namespace Microsoft.Agents.Workflows; +namespace Microsoft.Agents.Workflows.Declarative; /// /// Event that represents a message produced by a declarative workflow. diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs index ab2687bb79..0f9f4de508 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.AI; -namespace Microsoft.Agents.Workflows; +namespace Microsoft.Agents.Workflows.Declarative; /// /// Event that represents a streamed message produced by a declarative workflow. diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs index c1c87bd8be..a19cf9ce42 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs @@ -21,7 +21,7 @@ public static FormulaValue ToFormulaValue(this DataValue? value) => DateTimeDataValue dateTimeValue => FormulaValue.New(dateTimeValue.Value.DateTime), DateDataValue dateValue => FormulaValue.NewDateOnly(dateValue.Value), TimeDataValue timeValue => FormulaValue.New(timeValue.Value), - TableDataValue tableValue => FormulaValue.NewTable(tableValue.Values.First().ParseRecordType(), tableValue.Values.Select(value => value.ToRecordValue())), + TableDataValue tableValue => FormulaValue.NewTable(tableValue.Values.First().ParseRecordType(), tableValue.Values.Select(value => value.ToRecordValue())), // %%% FAILS FOR EMPTY TABLE RecordDataValue recordValue => recordValue.ToRecordValue(), OptionDataValue optionValue => FormulaValue.New(optionValue.Value.Value), //FileDataValue // %%% SUPPORT ??? diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs index 9c3f552f66..54223bbd4d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs @@ -14,11 +14,9 @@ internal sealed class WorkflowModel { public WorkflowModel(ExecutorIsh rootStep) { - this.RootNode = this.DefineNode(rootStep); + this.DefineNode(rootStep); } - private ModelNode RootNode { get; } - private Dictionary Nodes { get; } = []; private List Links { get; } = []; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs index cec87bd1b2..910519ffc5 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs @@ -234,7 +234,7 @@ private EvaluationResult GetValue(EnumExpression if (expressionResult.Value is not RecordValue formulaValue) { - throw new CannotParseObjectExpressionOutputException(typeof(TValue), expressionResult.Value.GetDataType()); + throw new InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.TableFromEnumerable()); } try @@ -274,12 +274,12 @@ private static ImmutableArray ParseArrayResults(FormulaValue val { if (value is BlankValue) { - return ImmutableArray.Create(); + return ImmutableArray.Empty; } if (value is not TableValue tableValue) { - throw new CannotParseObjectExpressionOutputException(typeof(ImmutableArray), value.GetDataType()); + throw new InvalidExpressionOutputTypeException(value.GetDataType(), DataType.TableFromEnumerable()); } TableDataValue tableDataValue = tableValue.ToDataValue(); diff --git a/dotnet/test/Microsoft.Agents.Workflows.Declarative.Tests/DeclarativeWorkflowContextTests.cs b/dotnet/test/Microsoft.Agents.Workflows.Declarative.Tests/DeclarativeWorkflowContextTests.cs new file mode 100644 index 0000000000..fb54f4fd9a --- /dev/null +++ b/dotnet/test/Microsoft.Agents.Workflows.Declarative.Tests/DeclarativeWorkflowContextTests.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using Azure.Core; +using Azure.Identity; +using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Microsoft.Agents.Workflows.Declarative.Tests; + +public class DeclarativeWorkflowContextTests +{ + [Fact] + public void Default_ShouldHaveExpectedValues() + { + // Assert + DeclarativeWorkflowContext defaultContext = DeclarativeWorkflowContext.Default; + Assert.Equal(string.Empty, defaultContext.ProjectEndpoint); + Assert.IsAssignableFrom(defaultContext.ProjectCredentials); + Assert.Null(defaultContext.MaximumCallDepth); + Assert.Null(defaultContext.MaximumExpressionLength); + Assert.Null(defaultContext.HttpClient); + Assert.Same(NullLoggerFactory.Instance, defaultContext.LoggerFactory); + } + + [Fact] + public void Constructor_WithNoParameters_ShouldInitializeDefaultValues() + { + // Act + DeclarativeWorkflowContext context = new(); + + // Assert + Assert.Equal(string.Empty, context.ProjectEndpoint); + Assert.IsAssignableFrom(context.ProjectCredentials); + Assert.Null(context.MaximumCallDepth); + Assert.Null(context.MaximumExpressionLength); + Assert.Null(context.HttpClient); + Assert.Same(NullLoggerFactory.Instance, context.LoggerFactory); + } + + [Fact] + public void Constructor_WithInitializers_ShouldSetProperties() + { + // Arrange + string projectEndpoint = "https://test-endpoint.com"; + TokenCredential credentials = new DefaultAzureCredential(); + int maxCallDepth = 10; + int maxExpressionLength = 100; + HttpClient httpClient = new(); + ILoggerFactory loggerFactory = LoggerFactory.Create(builder => { }); + + // Act + DeclarativeWorkflowContext context = new() + { + ProjectEndpoint = projectEndpoint, + ProjectCredentials = credentials, + MaximumCallDepth = maxCallDepth, + MaximumExpressionLength = maxExpressionLength, + HttpClient = httpClient, + LoggerFactory = loggerFactory + }; + + // Assert + Assert.Equal(projectEndpoint, context.ProjectEndpoint); + Assert.Same(credentials, context.ProjectCredentials); + Assert.Equal(maxCallDepth, context.MaximumCallDepth); + Assert.Equal(maxExpressionLength, context.MaximumExpressionLength); + Assert.Same(httpClient, context.HttpClient); + Assert.Same(loggerFactory, context.LoggerFactory); + } + + [Fact] + public void CreateActionContext_ShouldCreateContextWithExpectedProperties() + { + // Arrange + DeclarativeWorkflowContext context = new() + { + MaximumExpressionLength = 200 + }; + string rootId = "test-root-id"; + WorkflowScopes scopes = new(); + + // Act + WorkflowExecutionContext executionContext = context.CreateActionContext(rootId, scopes); + + // Assert + Assert.NotNull(executionContext); + Assert.NotNull(executionContext.Engine); + Assert.Same(scopes, executionContext.Scopes); + Assert.NotNull(executionContext.ClientFactory); + Assert.NotNull(executionContext.Logger); + Assert.Equal(rootId, executionContext.Logger.ToString()); + } + + [Fact] + public void CreateActionContext_WithCustomLoggerFactory_ShouldUseCustomLogger() + { + // Arrange + ILoggerFactory customLoggerFactory = LoggerFactory.Create(builder => { }); + DeclarativeWorkflowContext context = new() + { + LoggerFactory = customLoggerFactory + }; + string rootId = "test-root-id"; + WorkflowScopes scopes = new(); + + // Act + WorkflowExecutionContext executionContext = context.CreateActionContext(rootId, scopes); + + // Assert + Assert.NotNull(executionContext); + Assert.NotNull(executionContext.Logger); + Assert.NotSame(NullLogger.Instance, executionContext.Logger); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs new file mode 100644 index 0000000000..1abecf5032 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net.Http; +using Azure.Core; +using Azure.Identity; +using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests; + +public class DeclarativeWorkflowContextTests +{ + [Fact] + public void DefaultHasExpectedValues() + { + // Assert + DeclarativeWorkflowContext context = DeclarativeWorkflowContext.Default; + Assert.Equal(string.Empty, context.ProjectEndpoint); + Assert.IsType(context.ProjectCredentials); + Assert.Null(context.MaximumCallDepth); + Assert.Null(context.MaximumExpressionLength); + Assert.Null(context.HttpClient); + Assert.Same(NullLoggerFactory.Instance, context.LoggerFactory); + } + + [Fact] + public void InitializeDefaultValues() + { + // Act + DeclarativeWorkflowContext context = new(); + + // Assert + Assert.Equal(string.Empty, context.ProjectEndpoint); + Assert.IsType(context.ProjectCredentials); + Assert.Null(context.MaximumCallDepth); + Assert.Null(context.MaximumExpressionLength); + Assert.Null(context.HttpClient); + Assert.Same(NullLoggerFactory.Instance, context.LoggerFactory); + } + + [Fact] + public void InitializeExplicitValues() + { + // Arrange + string projectEndpoint = "https://test-endpoint.com"; + TokenCredential credentials = new DefaultAzureCredential(); + int maxCallDepth = 10; + int maxExpressionLength = 100; + using HttpClient httpClient = new(); + ILoggerFactory loggerFactory = LoggerFactory.Create(builder => { }); + + // Act + DeclarativeWorkflowContext context = new() + { + ProjectEndpoint = projectEndpoint, + ProjectCredentials = credentials, + MaximumCallDepth = maxCallDepth, + MaximumExpressionLength = maxExpressionLength, + HttpClient = httpClient, + LoggerFactory = loggerFactory + }; + + // Assert + Assert.Equal(projectEndpoint, context.ProjectEndpoint); + Assert.Same(credentials, context.ProjectCredentials); + Assert.Equal(maxCallDepth, context.MaximumCallDepth); + Assert.Equal(maxExpressionLength, context.MaximumExpressionLength); + Assert.Same(httpClient, context.HttpClient); + Assert.Same(loggerFactory, context.LoggerFactory); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CreateActionContext(bool useHttpClient) + { + // Arrange + HttpClient? httpClient = useHttpClient ? new() : null; + try + { + DeclarativeWorkflowContext context = new() + { + ProjectEndpoint = "https://test.ai.azure.com/myproject", + HttpClient = httpClient, + }; + WorkflowScopes scopes = new(); + + // Act + WorkflowExecutionContext executionContext = context.CreateActionContext("workflow-id", scopes); + + // Assert + Assert.NotNull(executionContext); + Assert.NotNull(executionContext.Engine); + Assert.Same(scopes, executionContext.Scopes); + Assert.NotNull(executionContext.ClientFactory); + Assert.NotNull(executionContext.Logger); + Assert.NotNull(executionContext.ClientFactory.Invoke()); + } + finally + { + httpClient?.Dispose(); + } + } + + [Fact] + public void CreateActionContextWithCustomLogger() + { + // Arrange + WorkflowScopes scopes = new(); + ILoggerFactory customLoggerFactory = LoggerFactory.Create(builder => { }); + DeclarativeWorkflowContext context = new() + { + LoggerFactory = customLoggerFactory + }; + + // Act + WorkflowExecutionContext executionContext = context.CreateActionContext("workflow-id", scopes); + + // Assert + Assert.NotNull(executionContext); + Assert.NotNull(executionContext.Logger); + Assert.NotSame(NullLogger.Instance, executionContext.Logger); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowEventTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowEventTest.cs new file mode 100644 index 0000000000..4d07d911fc --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowEventTest.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests; + +/// +/// Tests and subclasses. +/// +public sealed class DeclarativeWorkflowEventTest(ITestOutputHelper output) : WorkflowTest(output) +{ + /// + /// Tests the class. + /// + [Fact] + public void DeclarativeWorkflowMessageEvent() + { + ChatMessage testMessage = new(ChatRole.Assistant, "test message"); + DeclarativeWorkflowMessageEvent workflowEvent = new(testMessage); + Assert.Equal(testMessage, workflowEvent.Data); + Assert.Null(workflowEvent.Usage); + } + + /// + /// Tests the class. + /// + [Fact] + public void DeclarativeWorkflowStreamEvent() + { + ChatResponseUpdate testUpdate = new(ChatRole.Assistant, "test message"); + DeclarativeWorkflowStreamEvent workflowEvent = new(testUpdate); + Assert.Equal(testUpdate, workflowEvent.Data); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowExceptionTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowExceptionTest.cs new file mode 100644 index 0000000000..6442e60923 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowExceptionTest.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests; + +/// +/// Tests declarative workflow exceptions. +/// +public sealed class DeclarativeWorkflowExceptionTest(ITestOutputHelper output) : WorkflowTest(output) +{ + [Fact] + public void InvalidScopeException() + { + AssertDefault(() => throw new InvalidScopeException()); + AssertMessage((message) => throw new InvalidScopeException(message)); + AssertInner((message, inner) => throw new InvalidScopeException(message, inner)); + } + + [Fact] + public void InvalidSegmentException() + { + AssertDefault(() => throw new InvalidSegmentException()); + AssertMessage((message) => throw new InvalidSegmentException(message)); + AssertInner((message, inner) => throw new InvalidSegmentException(message, inner)); + } + + [Fact] + public void UnknownActionException() + { + AssertDefault(() => throw new UnknownActionException()); + AssertMessage((message) => throw new UnknownActionException(message)); + AssertInner((message, inner) => throw new UnknownActionException(message, inner)); + } + + [Fact] + public void UnknownDataTypeException() + { + AssertDefault(() => throw new UnknownDataTypeException()); + AssertMessage((message) => throw new UnknownDataTypeException(message)); + AssertInner((message, inner) => throw new UnknownDataTypeException(message, inner)); + } + + [Fact] + public void WorkflowExecutionException() + { + AssertDefault(() => throw new WorkflowExecutionException()); + AssertMessage((message) => throw new WorkflowExecutionException(message)); + AssertInner((message, inner) => throw new WorkflowExecutionException(message, inner)); + } + + [Fact] + public void WorkflowModelException() + { + AssertDefault(() => throw new WorkflowModelException()); + AssertMessage((message) => throw new WorkflowModelException(message)); + AssertInner((message, inner) => throw new WorkflowModelException(message, inner)); + } + + private static void AssertDefault(Action throwAction) where TException : Exception + { + TException exception = Assert.Throws(() => throwAction.Invoke()); + Assert.NotEmpty(exception.Message); + Assert.Null(exception.InnerException); + } + + private static void AssertMessage(Action throwAction) where TException : Exception + { + const string message = "Test exception message"; + TException exception = Assert.Throws(() => throwAction.Invoke(message)); + Assert.Equal(message, exception.Message); + Assert.Null(exception.InnerException); + } + + private static void AssertInner(Action throwAction) where TException : Exception + { + const string message = "Test exception message"; + NotSupportedException innerException = new("Inner exception message"); + TException exception = Assert.Throws(() => throwAction.Invoke(message, innerException)); + Assert.Equal(message, exception.Message); + Assert.Equal(innerException, exception.InnerException); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index a57762a6d5..a2498a3a33 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -14,7 +14,7 @@ namespace Microsoft.Agents.Workflows.Declarative.UnitTests; /// -/// Tests exeuction of workflow created by . +/// Tests execution of workflow created by . /// public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : WorkflowTest(output) { @@ -22,12 +22,14 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow private ImmutableDictionary WorkflowEventCounts { get; set; } = ImmutableDictionary.Empty; - [Fact] - public async Task SingleAction() + [Theory] + [InlineData("BadEmpty.yaml")] + [InlineData("BadId.yaml")] + [InlineData("BadKind.yaml")] + public async Task InvalidWorkflow(string workflowFile) { - await this.RunWorkflow("Single.yaml"); - this.AssertExecutionCount(expectedCount: 1); - this.AssertExecuted("end_all"); + await Assert.ThrowsAsync(() => this.RunWorkflow(workflowFile)); + this.AssertNotExecuted("end_all"); } [Fact] @@ -79,7 +81,7 @@ public async Task GotoAction() public async Task ConditionAction() { await this.RunWorkflow("Condition.yaml"); - this.AssertExecutionCount(expectedCount: 16); + this.AssertExecutionCount(expectedCount: 9); this.AssertExecuted("setVariable_test"); this.AssertExecuted("conditionGroup_test"); this.AssertExecuted("conditionItem_even"); @@ -89,6 +91,20 @@ public async Task ConditionAction() this.AssertNotExecuted("sendActivity_odd"); } + [Theory] + [InlineData("Single.yaml", 1, "end_all")] + [InlineData("EditTable.yaml", 2, "edit_var")] + [InlineData("ParseValue.yaml", 1, "parse_var")] + [InlineData("SetTextVariable.yaml", 1, "set_text")] + [InlineData("ClearAllVariables.yaml", 1, "clear_all")] + [InlineData("ResetVariable.yaml", 2, "clear_var")] + public async Task ExecuteAction(string workflowFile, int expectedCount, string expectedId) + { + await this.RunWorkflow(workflowFile); + this.AssertExecutionCount(expectedCount); + this.AssertExecuted(expectedId); + } + [Theory] [InlineData(typeof(ActivateExternalTrigger.Builder))] [InlineData(typeof(AdaptiveCardPrompt.Builder))] diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Interpreter/WorkflowModelTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Interpreter/WorkflowModelTest.cs new file mode 100644 index 0000000000..5aab294ab7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Interpreter/WorkflowModelTest.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Agents.Workflows.Reflection; +using Xunit.Abstractions; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Interpreter; + +/// +/// Tests execution of workflow created by . +/// +public sealed class DeclarativeWorkflowModelTest(ITestOutputHelper output) : WorkflowTest(output) +{ + [Fact] + public async Task GetDepthForDefault() + { + WorkflowModel model = new(this.CreateExecutor("root")); + Assert.Equal(0, model.GetDepth(null)); + } + + [Fact] + public async Task GetDepthForMissingNode() + { + WorkflowModel model = new(this.CreateExecutor("root")); + Assert.Throws(() => model.GetDepth("missing")); + } + + [Fact] + public async Task ConnectMissingNode() + { + TestExecutor rootExecutor = this.CreateExecutor("root"); + WorkflowModel model = new(rootExecutor); + model.AddLink("root", "missing"); + WorkflowBuilder workflowBuilder = new(rootExecutor); + Assert.Throws(() => model.ConnectNodes(workflowBuilder)); + } + + [Fact] + public async Task AddToMissingParent() + { + WorkflowModel model = new(this.CreateExecutor("root")); + Assert.Throws(() => model.AddNode(this.CreateExecutor("next"), "missing")); + } + + [Fact] + public async Task LinkFromMissingSource() + { + WorkflowModel model = new(this.CreateExecutor("root")); + Assert.Throws(() => model.AddLink("missing", "anything")); + } + + [Fact] + public async Task LocateMissingParent() + { + WorkflowModel model = new(this.CreateExecutor("root")); + Assert.Null(model.LocateParent(null)); + Assert.Throws(() => model.LocateParent("missing")); + } + + private TestExecutor CreateExecutor(string id) => new(id); + + internal sealed class TestExecutor(string actionId) : + ReflectingExecutor(actionId), + IMessageHandler + { + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + await context.SendMessageAsync($"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}").ConfigureAwait(false); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs index 31512370f1..b274142ccb 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.PowerFx; using Xunit.Abstractions; @@ -48,7 +49,11 @@ public void HasSetFunctionEnabled() public void HasCorrectMaximumExpressionLength() { // Arrange - RecalcEngine engine = this.CreateEngine(2000); + RecalcEngine engine = RecalcEngineFactory.Create(this.Scopes, 2000, 3); + + // Assert + Assert.Equal(2000, engine.Config.MaximumExpressionLength); + Assert.Equal(3, engine.Config.MaxCallDepth); // Act: Create a long expression that is within the limit string goodExpression = string.Concat(GenerateExpression(999)); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs index 9206c4b199..74cf3416cc 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Immutable; +using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; @@ -20,9 +22,15 @@ private static class Variables public const string StringValue = nameof(StringValue); public const string IntValue = nameof(IntValue); public const string NumberValue = nameof(NumberValue); + public const string EnumValue = nameof(EnumValue); + public const string ObjectValue = nameof(ObjectValue); + public const string ArrayValue = nameof(ArrayValue); public const string BlankValue = nameof(BlankValue); } + public static readonly RecordValue ObjectData = FormulaValue.NewRecordFromFields(new NamedValue(nameof(EnvironmentVariableReference.SchemaName), FormulaValue.New("test"))); + public static readonly TableValue TableData = FormulaValue.NewSingleColumnTable(FormulaValue.New("a"), FormulaValue.New("b")); + public WorkflowExpressionEngineTests(ITestOutputHelper output) : base(output) { @@ -31,27 +39,52 @@ public WorkflowExpressionEngineTests(ITestOutputHelper output) this.Scopes.Set(Variables.StringValue, WorkflowScopeType.Topic, FormulaValue.New("Hello World")); this.Scopes.Set(Variables.IntValue, WorkflowScopeType.Topic, FormulaValue.New(long.MaxValue)); this.Scopes.Set(Variables.NumberValue, WorkflowScopeType.Topic, FormulaValue.New(33.3)); + this.Scopes.Set(Variables.EnumValue, WorkflowScopeType.Topic, FormulaValue.New(nameof(VariablesToClear.ConversationScopedVariables))); + this.Scopes.Set(Variables.ObjectValue, WorkflowScopeType.Topic, ObjectData); + this.Scopes.Set(Variables.ArrayValue, WorkflowScopeType.Topic, TableData); this.Scopes.Set(Variables.BlankValue, WorkflowScopeType.Topic, FormulaValue.NewBlank()); } + #region Unsupported Expression Tests + + [Fact] + public void AdaptiveCardExpressionGetValueUnsupported() + { + this.EvaluateUnsupportedExpression(expressionEngine => expressionEngine.GetValue(AdaptiveCardExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue)), this.Scopes.BuildState())); + } + + [Fact] + public void DialogExpressionGetValueUnsupported() + { + this.EvaluateUnsupportedExpression(expressionEngine => expressionEngine.GetValue(DialogExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue)), this.Scopes.BuildState())); + } + + [Fact] + public void FileExpressionGetValueUnsupported() + { + this.EvaluateUnsupportedExpression(expressionEngine => expressionEngine.GetValue(FileExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue)), this.Scopes.BuildState())); + } + + #endregion + #region BoolExpression Tests [Fact] - public void GetValueForNullBoolExpression() + public void BoolExpressionGetValueForNull() { // Arrange, Act & Assert this.EvaluateInvalidExpression((BoolExpression)null!); } [Fact] - public void GetValueForInvalidBoolExpression() + public void BoolExpressionGetValueForInvalid() { // Arrange, Act & Assert this.EvaluateInvalidExpression(BoolExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue))); } [Fact] - public void GetValueForBoolExpressionWithLiteral() + public void BoolExpressionGetValueForLiteral() { // Arrange, Act & Assert this.EvaluateExpression( @@ -60,7 +93,7 @@ public void GetValueForBoolExpressionWithLiteral() } [Fact] - public void GetValueForBoolExpressionBlank() + public void BoolExpressionGetValueForBlank() { // Arrange, Act & Assert this.EvaluateExpression( @@ -71,7 +104,7 @@ public void GetValueForBoolExpressionBlank() [Theory] [InlineData(false)] [InlineData(true)] - public void GetValueForBoolExpressionWithVariable(bool useState) + public void BoolExpressionGetValueForVariable(bool useState) { // Arrange, Act & Assert this.EvaluateExpression( @@ -81,7 +114,7 @@ public void GetValueForBoolExpressionWithVariable(bool useState) } [Fact] - public void GetValueForBoolExpressionWithFormula() + public void BoolExpressionGetValueForFormula() { // Arrange, Act & Assert this.EvaluateExpression( @@ -94,21 +127,21 @@ public void GetValueForBoolExpressionWithFormula() #region StringExpression Tests [Fact] - public void GetValueForNullStringExpression() + public void StringExpressionGetValueForNull() { // Arrange, Act & Assert this.EvaluateInvalidExpression((StringExpression)null!); } [Fact] - public void GetValueForInvalidStringExpression() + public void StringExpressionGetValueForInvalid() { // Arrange, Act & Assert this.EvaluateInvalidExpression(StringExpression.Variable(PropertyPath.TopicVariable(Variables.BoolValue))); } [Fact] - public void GetValueForStringExpressionBlank() + public void StringExpressionGetValueForStringExpressionBlank() { // Arrange, Act & Assert this.EvaluateExpression( @@ -117,7 +150,7 @@ public void GetValueForStringExpressionBlank() } [Fact] - public void GetValueForStringExpressionWithLiteral() + public void StringExpressionGetValueForLiteral() { // Arrange, Act & Assert this.EvaluateExpression( @@ -128,7 +161,7 @@ public void GetValueForStringExpressionWithLiteral() [Theory] [InlineData(false)] [InlineData(true)] - public void GetValueForStringExpressionWithVariable(bool useState) + public void StringExpressionGetValueForVariable(bool useState) { // Arrange, Act & Assert this.EvaluateExpression( @@ -138,7 +171,7 @@ public void GetValueForStringExpressionWithVariable(bool useState) } [Fact] - public void GetValueForStringExpressionWithFormula() + public void StringExpressionGetValueForFormula() { // Arrange, Act & Assert this.EvaluateExpression( @@ -172,21 +205,21 @@ public void GetValueForStringExpressionWithFormula() #region IntExpression Tests [Fact] - public void GetValueForNullIntExpression() + public void IntExpressionGetValueForNull() { // Arrange, Act & Assert this.EvaluateInvalidExpression((IntExpression)null!); } [Fact] - public void GetValueForInvalidIntExpression() + public void IntExpressionGetValueForInvalid() { // Arrange, Act & Assert this.EvaluateInvalidExpression(IntExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue))); } [Fact] - public void GetValueForIntExpressionBlank() + public void IntExpressionGetValueForIntExpressionBlank() { // Arrange, Act & Assert this.EvaluateExpression( @@ -195,7 +228,7 @@ public void GetValueForIntExpressionBlank() } [Fact] - public void GetValueForIntExpressionWithLiteral() + public void IntExpressionGetValueForLiteral() { // Arrange, Act & Assert this.EvaluateExpression( @@ -206,7 +239,7 @@ public void GetValueForIntExpressionWithLiteral() [Theory] [InlineData(false)] [InlineData(true)] - public void GetValueForIntExpressionWithVariable(bool useState) + public void IntExpressionGetValueForVariable(bool useState) { // Arrange, Act & Assert this.EvaluateExpression( @@ -216,7 +249,7 @@ public void GetValueForIntExpressionWithVariable(bool useState) } [Fact] - public void GetValueForIntExpressionWithFormula() + public void IntExpressionGetValueForFormula() { // Arrange, Act & Assert this.EvaluateExpression( @@ -229,21 +262,21 @@ public void GetValueForIntExpressionWithFormula() #region NumberExpression Tests [Fact] - public void GetValueForNullNumberExpression() + public void NumberExpressionGetValueForNull() { // Arrange, Act & Assert this.EvaluateInvalidExpression((NumberExpression)null!); } [Fact] - public void GetValueForInvalidNumberExpression() + public void NumberExpressionGetValueForInvalid() { // Arrange, Act & Assert this.EvaluateInvalidExpression(NumberExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue))); } [Fact] - public void GetValueForNumberExpressionBlank() + public void NumberExpressionGetValueForBlank() { // Arrange, Act & Assert this.EvaluateExpression( @@ -252,7 +285,7 @@ public void GetValueForNumberExpressionBlank() } [Fact] - public void GetValueForNumberExpressionWithLiteral() + public void NumberExpressionGetValueForLiteral() { // Arrange, Act & Assert this.EvaluateExpression( @@ -261,7 +294,7 @@ public void GetValueForNumberExpressionWithLiteral() } [Fact] - public void GetValueForNumberExpressionWithVariable() + public void NumberExpressionGetValueForVariable() { // Arrange, Act & Assert this.EvaluateExpression( @@ -270,7 +303,7 @@ public void GetValueForNumberExpressionWithVariable() } [Fact] - public void GetValueForNumberExpressionWithFormula() + public void NumberExpressionGetValueForFormula() { // Arrange, Act & Assert this.EvaluateExpression( @@ -280,8 +313,112 @@ public void GetValueForNumberExpressionWithFormula() #endregion + #region DataValueExpression Tests + + [Fact] + public void DataValueExpressionGetValueForNull() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression((ValueExpression)null!); + } + + [Fact] + public void DataValueExpressionGetValueForDataValueExpressionBlank() + { + // Arrange, Act & Assert + this.EvaluateExpression( + ValueExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)), + expectedValue: DataValue.Blank()); + } + + [Fact] + public void DataValueExpressionGetValueForLiteral() + { + // Arrange, Act & Assert + this.EvaluateExpression( + ValueExpression.Literal(DataValue.Create("test")), + expectedValue: DataValue.Create("test")); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DataValueExpressionGetValueForVariable(bool useState) + { + // Arrange, Act & Assert + this.EvaluateExpression( + ValueExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue)), + expectedValue: DataValue.Create("Hello World"), + useState); + } + + [Fact] + public void DataValueExpressionGetValueForFormula() + { + // Arrange, Act & Assert + this.EvaluateExpression( + //DataValueExpression.Expression(@$"""{{{PropertyPath.TopicVariable(Variables.DataValueValue)}}}"""), + ValueExpression.Expression(@"""AB"""), // %%% IMPROVE + expectedValue: DataValue.Create("AB")); + } + + #endregion + #region EnumExpression Tests + [Fact] + public void EnumExpressionGetValueForNull() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression((EnumExpression)null!); + } + + [Fact] + public void EnumExpressionGetValueForInvalid() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression(EnumExpression.Variable(PropertyPath.TopicVariable(Variables.BoolValue))); + } + + [Fact] + public void EnumExpressionGetValueForLiteral() + { + // Arrange, Act & Assert + this.EvaluateExpression( + EnumExpression.Literal(VariablesToClearWrapper.Get(VariablesToClear.ConversationScopedVariables)), + expectedValue: VariablesToClear.ConversationScopedVariables); + } + + [Fact] + public void EnumExpressionGetValueForBlank() + { + // Arrange, Act & Assert + this.EvaluateExpression( + EnumExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)), + expectedValue: VariablesToClear.ConversationScopedVariables); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void EnumExpressionGetValueForVariable(bool useState) + { + // Arrange, Act & Assert + this.EvaluateExpression( + EnumExpression.Variable(PropertyPath.TopicVariable(Variables.EnumValue)), + expectedValue: VariablesToClear.ConversationScopedVariables, + useState); + } + + [Fact] + public void EnumExpressionGetValueForFormula() + { + // Arrange, Act & Assert + this.EvaluateExpression( + EnumExpression.Expression(@"""ConversationScoped"" & ""Variables"""), + expectedValue: VariablesToClear.ConversationScopedVariables); + } + //// Enum Expression Tests //[Fact] //public void GetValueForEnumExpressionWithLiteral() @@ -304,84 +441,243 @@ public void GetValueForNumberExpressionWithFormula() #endregion - //// Object Expression Tests - //[Fact] - //public void GetValueForObjectExpressionWithLiteral() - //{ - // // Arrange - // RecalcEngine engine = this.CreateEngine(); - // WorkflowExpressionEngine expressionEngine = new(engine); - // WorkflowScopes scopes = new(); - // TestBotElement testElement = new TestBotElement { Name = "Test" }; - // ObjectExpression expression = new ObjectExpression(testElement); + #region ObjectExpression Tests - // // Act - // EvaluationResult result = expressionEngine.GetValue(expression, scopes); + [Fact] + public void ObjectExpressionGetValueForNull() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression((ObjectExpression)null!); + } - // // Assert - // Assert.NotNull(result.Value); - // Assert.Equal("Test", result.Value.Name); - // Assert.Equal(SensitivityLevel.None, result.Sensitivity); - //} + [Fact] + public void ObjectExpressionGetValueForInvalid() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression(ObjectExpression.Variable(PropertyPath.TopicVariable(Variables.BoolValue))); + } - //// Array Expression Tests - //[Fact] - //public void GetValueForArrayExpressionWithLiteral() - //{ - // // Arrange - // RecalcEngine engine = this.CreateEngine(); - // WorkflowExpressionEngine expressionEngine = new(engine); - // WorkflowScopes scopes = new(); + [Fact] + public void ObjectExpressionGetValueForLiteral() + { + // Arrange, Act & Assert + RecordDataValue.Builder recordBuilder = new(); + recordBuilder.Properties.Add(nameof(EnvironmentVariableReference.SchemaName), new StringDataValue("test")); + RecordDataValue objectRecord = recordBuilder.Build(); + EnvironmentVariableReference element = new EnvironmentVariableReference.Builder() { SchemaName = "test" }.Build(); + this.EvaluateExpression( + ObjectExpression.Literal(objectRecord), + expectedValue: objectRecord); + } - // ImmutableArray array = ImmutableArray.Create("item1", "item2"); - // ArrayExpression expression = new ArrayExpression(array); + [Fact] + public void ObjectExpressionGetValueForBlank() + { + // Arrange, Act & Assert + this.EvaluateExpression( + ObjectExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)), + expectedValue: null); + } - // // Act - // ImmutableArray result = expressionEngine.GetValue(expression, scopes); + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ObjectExpressionGetValueForVariable(bool useState) + { + // Arrange, Act & Assert + this.EvaluateExpression( + ObjectExpression.Variable(PropertyPath.TopicVariable(Variables.ObjectValue)), + expectedValue: ObjectData.ToDataValue(), + useState); + } - // // Assert - // Assert.Equal(2, result.Length); - // Assert.Equal("item1", result[0]); - // Assert.Equal("item2", result[1]); + //[Fact] // %%% TODO: TEST COVERAGE + //public void ObjectExpressionGetValueForFormula() + //{ + // // Arrange, Act & Assert + // this.EvaluateExpression( + // ObjectExpression.Expression(@"""{\""schemaName\"": "" & "" \""test\""}"""), + // expectedValue: ObjectData.ToDataValue()); //} + #endregion + + #region ArrayExpression Tests + + [Fact] + public void ArrayExpressionGetValueForNull() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression((ArrayExpression)null!); + } + + [Fact] + public void ArrayExpressionGetValueForInvalid() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression(ArrayExpression.Variable(PropertyPath.TopicVariable(Variables.BoolValue))); + } + + [Fact] + public void ArrayExpressionGetValueForLiteral() + { + // Arrange, Act & Assert + string[] input = ["a", "b"]; + this.EvaluateExpression( + ArrayExpression.Literal(input.ToImmutableArray()), + expectedValue: input); + } + + [Fact] + public void ArrayExpressionGetValueForBlank() + { + // Arrange, Act & Assert + this.EvaluateExpression( + ArrayExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)), + expectedValue: []); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ArrayExpressionGetValueForVariable(bool useState) + { + // Arrange, Act & Assert + this.EvaluateExpression( + ArrayExpression.Variable(PropertyPath.TopicVariable(Variables.ArrayValue)), + expectedValue: ["a", "b"], + useState); + } + + [Fact] + public void ArrayExpressionGetValueForFormula() + { + // Arrange, Act & Assert + this.EvaluateExpression( + ArrayExpression.Expression(@"[""a"", ""b""]"), + expectedValue: ["a", "b"]); + } + + #endregion + + #region ArrayExpressionOnly Tests + + [Fact] + public void ArrayExpressionOnlyGetValueForNull() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression((ArrayExpressionOnly)null!); + } + + [Fact] + public void ArrayExpressionOnlyGetValueForInvalid() + { + // Arrange, Act & Assert + this.EvaluateInvalidExpression(ArrayExpressionOnly.Variable(PropertyPath.TopicVariable(Variables.BoolValue))); + } + + [Fact] + public void ArrayExpressionOnlyGetValueForBlank() + { + // Arrange, Act & Assert + this.EvaluateExpression( + ArrayExpressionOnly.Variable(PropertyPath.TopicVariable(Variables.BlankValue)), + expectedValue: []); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ArrayExpressionOnlyGetValueForVariable(bool useState) + { + // Arrange, Act & Assert + this.EvaluateExpression( + ArrayExpressionOnly.Variable(PropertyPath.TopicVariable(Variables.ArrayValue)), + expectedValue: ["a", "b"], + useState); + } + + [Fact] + public void ArrayExpressionOnlyGetValueForFormula() + { + // Arrange, Act & Assert + this.EvaluateExpression( + ArrayExpressionOnly.Expression(@"[""a"", ""b""]"), + expectedValue: ["a", "b"]); + } + + #endregion + private EvaluationResult EvaluateExpression(BoolExpression expression, bool expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); - private void EvaluateInvalidExpression(BoolExpression expression) where TException : Exception + private void EvaluateInvalidExpression(BoolExpression expression) + where TException : Exception => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); private EvaluationResult EvaluateExpression(StringExpression expression, string expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); - private void EvaluateInvalidExpression(StringExpression expression) where TException : Exception + private void EvaluateInvalidExpression(StringExpression expression) + where TException : Exception => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); private EvaluationResult EvaluateExpression(IntExpression expression, long expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); - private void EvaluateInvalidExpression(IntExpression expression) where TException : Exception + private void EvaluateInvalidExpression(IntExpression expression) + where TException : Exception => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); private EvaluationResult EvaluateExpression(NumberExpression expression, double expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); - private void EvaluateInvalidExpression(NumberExpression expression) where TException : Exception + private void EvaluateInvalidExpression(NumberExpression expression) + where TException : Exception + => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); + + private EvaluationResult EvaluateExpression(ValueExpression expression, DataValue expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) + => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); + + private void EvaluateInvalidExpression(ValueExpression expression) + where TException : Exception => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); private EvaluationResult EvaluateExpression(EnumExpression expression, TEnum expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) where TEnum : EnumWrapper => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); - private void EvaluateInvalidExpression(EnumExpression expression) where TException : Exception where TEnum : EnumWrapper + private void EvaluateInvalidExpression(EnumExpression expression) + where TException : Exception + where TEnum : EnumWrapper => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); - //private EvaluationResult EvaluateExpression(ObjectExpression expression, TValue expectedValue, SensitivityLevel expectedSensitivity = SensitivityLevel.None) - // => this.EvaluateExpression((evaluator) => evaluator.GetValue(expression, this.Scopes), expectedValue, expectedSensitivity); + private EvaluationResult EvaluateExpression(ObjectExpression expression, TValue? expectedValue, bool useState = false, SensitivityLevel expectedSensitivity = SensitivityLevel.None) + where TValue : BotElement + => this.EvaluateExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue, expectedSensitivity); + + private void EvaluateInvalidExpression(ObjectExpression expression) + where TException : Exception + where TValue : BotElement + => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); + + private ImmutableArray EvaluateExpression(ArrayExpression expression, TValue[] expectedValue, bool useState = false) + => this.EvaluateArrayExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue); + + private void EvaluateInvalidExpression(ArrayExpression expression) + where TException : Exception + => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); + + private ImmutableArray EvaluateExpression(ArrayExpressionOnly expression, TValue[] expectedValue, bool useState = false) + => this.EvaluateArrayExpression((evaluator) => useState ? evaluator.GetValue(expression, this.Scopes) : evaluator.GetValue(expression, this.Scopes.BuildState()), expectedValue); + + private void EvaluateInvalidExpression(ArrayExpressionOnly expression) + where TException : Exception + => this.EvaluateInvalidExpression((evaluator) => evaluator.GetValue(expression, this.Scopes)); private EvaluationResult EvaluateExpression( Func> evaluator, - TValue expectedValue, + TValue? expectedValue, SensitivityLevel expectedSensitivity = SensitivityLevel.None) { // Arrange @@ -398,6 +694,24 @@ private EvaluationResult EvaluateExpression( return result; } + private ImmutableArray EvaluateArrayExpression( + Func> evaluator, + TValue[] expectedValue) + { + // Arrange + RecalcEngine engine = this.CreateEngine(); + WorkflowExpressionEngine expressionEngine = new(engine); + + // Act + ImmutableArray result = evaluator.Invoke(expressionEngine); + + // Assert + Assert.Equal(expectedValue.Length, result.Length); + Assert.Equivalent(expectedValue, result); + + return result; + } + private void EvaluateInvalidExpression(Action evaluator) where TException : Exception { // Arrange @@ -407,4 +721,14 @@ private void EvaluateInvalidExpression(Action(() => evaluator.Invoke(expressionEngine)); } + + private void EvaluateUnsupportedExpression(Action evaluator) + { + // Arrange + RecalcEngine engine = this.CreateEngine(); + WorkflowExpressionEngine expressionEngine = new(engine); + + // Act + Assert.Throws(() => evaluator.Invoke(expressionEngine)); + } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopeTypeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopeTypeTest.cs new file mode 100644 index 0000000000..2639a103cb --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopeTypeTest.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.PowerFx; + +public class WorkflowScopeTypeTests +{ + [Fact] + public void StaticFieldsHaveCorrectNames() + { + Assert.Equal(VariableScopeNames.Environment, WorkflowScopeType.Env.Name); + Assert.Equal(VariableScopeNames.Topic, WorkflowScopeType.Topic.Name); + Assert.Equal(VariableScopeNames.Global, WorkflowScopeType.Global.Name); + Assert.Equal(VariableScopeNames.System, WorkflowScopeType.System.Name); + } + + [Fact] + public void ParseReturnsCorrectScopeType() + { + WorkflowScopeType envScope = WorkflowScopeType.Parse("Env"); + WorkflowScopeType topicScope = WorkflowScopeType.Parse("Topic"); + WorkflowScopeType globalScope = WorkflowScopeType.Parse("Global"); + WorkflowScopeType systemScope = WorkflowScopeType.Parse("System"); + + Assert.Same(WorkflowScopeType.Env, envScope); + Assert.Same(WorkflowScopeType.Topic, topicScope); + Assert.Same(WorkflowScopeType.Global, globalScope); + Assert.Same(WorkflowScopeType.System, systemScope); + } + + [Fact] + public void ParseThrowsForNullScope() + { + InvalidScopeException exception = Assert.Throws(() => WorkflowScopeType.Parse(null)); + Assert.Equal("Undefined action scope type.", exception.Message); + } + + [Fact] + public void ParseThrowsForUnknownScope() + { + string unknownScope = "Unknown"; + InvalidScopeException exception = Assert.Throws(() => WorkflowScopeType.Parse(unknownScope)); + Assert.Equal($"Unknown action scope type: {unknownScope}.", exception.Message); + } + + [Fact] + public void FormatReturnsScopedName() + { + string variableName = "myVariable"; + + string formattedEnv = WorkflowScopeType.Env.Format(variableName); + string formattedTopic = WorkflowScopeType.Topic.Format(variableName); + string formattedGlobal = WorkflowScopeType.Global.Format(variableName); + string formattedSystem = WorkflowScopeType.System.Format(variableName); + + Assert.Equal($"{VariableScopeNames.Environment}.{variableName}", formattedEnv); + Assert.Equal($"{VariableScopeNames.Topic}.{variableName}", formattedTopic); + Assert.Equal($"{VariableScopeNames.Global}.{variableName}", formattedGlobal); + Assert.Equal($"{VariableScopeNames.System}.{variableName}", formattedSystem); + } + + [Fact] + public void ToStringReturnsName() + { + Assert.Equal(VariableScopeNames.Environment, WorkflowScopeType.Env.ToString()); + Assert.Equal(VariableScopeNames.Topic, WorkflowScopeType.Topic.ToString()); + Assert.Equal(VariableScopeNames.Global, WorkflowScopeType.Global.ToString()); + Assert.Equal(VariableScopeNames.System, WorkflowScopeType.System.ToString()); + } + + [Fact] + public void GetHashCodeReturnsNameHashCode() + { + Assert.Equal(VariableScopeNames.Environment.GetHashCode(), WorkflowScopeType.Env.GetHashCode()); + Assert.Equal(VariableScopeNames.Topic.GetHashCode(), WorkflowScopeType.Topic.GetHashCode()); + Assert.Equal(VariableScopeNames.Global.GetHashCode(), WorkflowScopeType.Global.GetHashCode()); + Assert.Equal(VariableScopeNames.System.GetHashCode(), WorkflowScopeType.System.GetHashCode()); + } + + [Fact] + public void EqualsReturnsTrueForSameType() + { + Assert.True(WorkflowScopeType.Env.Equals(WorkflowScopeType.Env)); + Assert.False(WorkflowScopeType.Env.Equals(WorkflowScopeType.Topic)); + } + + [Fact] + public void EqualsReturnsTrueForMatchingString() + { + Assert.True(WorkflowScopeType.Env.Equals(VariableScopeNames.Environment)); + Assert.False(WorkflowScopeType.Env.Equals(VariableScopeNames.Topic)); + } + + [Fact] + public void EqualsReturnsFalseForNonMatchingTypes() + { + Assert.False(WorkflowScopeType.Env.Equals(42)); + Assert.False(WorkflowScopeType.Env.Equals(null)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs index bf7474ba38..a31d4afb21 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs @@ -159,4 +159,26 @@ public void SetOverwritesExistingValue() FormulaValue result = scopes.Get("key1", WorkflowScopeType.Topic); Assert.Equal(newValue, result); } + + [Fact] + public void RemoveSpecifiedScope() + { + // Arrange + WorkflowScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + + // Act + scopes.Set("key1", testValue); + + // Assert + FormulaValue result = scopes.Get("key1"); + Assert.Equal(testValue, result); + + // Act + scopes.Remove("key1"); + + // Assert + FormulaValue resultBlank = scopes.Get("key1"); + Assert.IsType(resultBlank); + } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadEmpty.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadEmpty.yaml new file mode 100644 index 0000000000..5152afdf5f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadEmpty.yaml @@ -0,0 +1,4 @@ +# empty yaml +- id: 1 +- id: 2 +- id: 3 diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadId.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadId.yaml new file mode 100644 index 0000000000..1bc2063698 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadId.yaml @@ -0,0 +1,7 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + type: Message + actions: + - kind: EndConversation + id: end_all diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadKind.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadKind.yaml new file mode 100644 index 0000000000..006944db3c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/BadKind.yaml @@ -0,0 +1,8 @@ +kind: ToolDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: EndConversation + id: end_all diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ClearAllVariables.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ClearAllVariables.yaml new file mode 100644 index 0000000000..0af98b8991 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ClearAllVariables.yaml @@ -0,0 +1,10 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: ClearAllVariables + id: clear_all + variables: [ConversationScopedVariables] + diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Condition.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Condition.yaml index 814bb2c8ec..b5b201c635 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Condition.yaml +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Condition.yaml @@ -15,16 +15,14 @@ beginDialog: id: conditionGroup_test conditions: - id: conditionItem_odd - #condition: =Topic.Count % 1 == 1 - condition: false + condition: =Mod(Topic.Count, 1) = 1 actions: - kind: SendActivity id: sendActivity_odd activity: ODD - id: conditionItem_even - #condition: =Topic.Count % 1 == 0 - condition: true + condition: =Mod(Topic.Count, 1) = 0 actions: - kind: SendActivity id: sendActivity_even diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/EditTable.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/EditTable.yaml new file mode 100644 index 0000000000..7ecda64328 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/EditTable.yaml @@ -0,0 +1,16 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: SetVariable + id: set_var + variable: Topic.MyTable + value: =[{id: 3}] + - kind: EditTableV2 + id: edit_var + itemsVariable: Topic.MyTable + changeType: + kind: AddItemOperation + value: ={id: 7} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ParseValue.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ParseValue.yaml new file mode 100644 index 0000000000..e7270ce48c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ParseValue.yaml @@ -0,0 +1,11 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: ParseValue + id: parse_var + variable: Topic.MyVar + value: "42" + valueType: Number diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ResetVariable.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ResetVariable.yaml new file mode 100644 index 0000000000..b9fe367c83 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ResetVariable.yaml @@ -0,0 +1,14 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: SetVariable + id: set_var + variable: Topic.MyVar + value: 42 + - kind: ResetVariable + id: clear_var + variable: Topic.MyVar + diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/SetTextVariable.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/SetTextVariable.yaml new file mode 100644 index 0000000000..75d8b99239 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/SetTextVariable.yaml @@ -0,0 +1,10 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: SetTextVariable + id: set_text + variable: Topic.TestVar + value: "Test content" From 7cce25248e0c53e8cfefc8ea158e163db7447399 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 14 Aug 2025 15:13:06 -0700 Subject: [PATCH 152/232] Clean-up - comments / test --- .../Execution/ClearAllVariablesExecutor.cs | 4 ++-- .../Execution/DeclarativeWorkflowExecutor.cs | 2 +- .../Execution/EndConversationExecutor.cs | 16 ---------------- .../Execution/ParseValueExecutor.cs | 2 +- .../Interpreter/WorkflowActionVisitor.cs | 6 +++--- .../PowerFx/WorkflowExpressionEngineTests.cs | 6 ++---- 6 files changed, 9 insertions(+), 27 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EndConversationExecutor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs index 45ca783460..c09e808af9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs @@ -28,7 +28,7 @@ public void HandleAllGlobalVariables() public void HandleConversationHistory() { - throw new System.NotImplementedException(); // %%% LOG / NO EXCEPTION - Is this to be supported ??? + throw new System.NotImplementedException(); // %%% DECISION: Is this to be supported ??? } public void HandleConversationScopedVariables() @@ -43,7 +43,7 @@ public void HandleUnknownValue() public void HandleUserScopedVariables() { - context.Engine.ClearScope(context.Scopes, WorkflowScopeType.Env); // %%% CORRECT ??? + context.Engine.ClearScope(context.Scopes, WorkflowScopeType.Env); // %%% DECISION: Is this correct? If not, what? } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs index e95927e575..25495bc40b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs @@ -19,7 +19,7 @@ internal sealed class DeclarativeWorkflowExecutor(string workflowId, WorkflowSco { public async ValueTask HandleAsync(string message, IWorkflowContext context) { - scopes.Set("LastMessage", WorkflowScopeType.System, FormulaValue.New(message)); // %%% MAGIC CONST "LastMessage" / SYSTEM scope + scopes.Set("LastMessage", WorkflowScopeType.System, FormulaValue.New(message)); // %%% INIT: SYSTEM VARIABLE //await context.AddEventAsync(new ExecutorInvokeEvent(this.Id, $"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}")).ConfigureAwait(false); await context.SendMessageAsync($"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}").ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EndConversationExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EndConversationExecutor.cs deleted file mode 100644 index b87cbf7b78..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EndConversationExecutor.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Bot.ObjectModel; - -namespace Microsoft.Agents.Workflows.Declarative.Execution; - -internal sealed class EndConversationExecutor(EndConversation model) : WorkflowActionExecutor(model) -{ - protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - // %%% DIAGNOSTICS / STATE MANAGEMENT ??? - return new ValueTask(); - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs index 3e01351089..90602ac5d5 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs @@ -59,6 +59,6 @@ private static RecordValue ParseRecord(RecordDataType recordType, string rawText string jsonText = rawText.TrimJsonDelimiter(); JsonDocument json = JsonDocument.Parse(jsonText); JsonElement currentElement = json.RootElement; - return recordType.ParseRecord(currentElement); // %%% FIX / REMOVE ??? + return recordType.ParseRecord(currentElement); // %%% FIX / REIMAGINE & REMOVE } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 464ddbe229..07de56ab16 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -185,9 +185,9 @@ protected override void Visit(EndConversation item) { this.Trace(item); - EndConversationExecutor action = new(item); - this.ContinueWith(action); - this.RestartFrom(action); + string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); + this.ContinueWith(this.CreateStep(item.Id.Value, nameof(EndConversation)), parentId); + this.RestartFrom(item.Id.Value, nameof(EndConversation), parentId); } protected override void Visit(AnswerQuestionWithAI item) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs index 74cf3416cc..5b89b958cc 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs @@ -175,8 +175,7 @@ public void StringExpressionGetValueForFormula() { // Arrange, Act & Assert this.EvaluateExpression( - //StringExpression.Expression(@$"""{{{PropertyPath.TopicVariable(Variables.StringValue)}}}"""), - StringExpression.Expression(@"""AB"""), // %%% IMPROVE + StringExpression.Expression(@"""A"" & ""B"""), expectedValue: "AB"); } @@ -357,8 +356,7 @@ public void DataValueExpressionGetValueForFormula() { // Arrange, Act & Assert this.EvaluateExpression( - //DataValueExpression.Expression(@$"""{{{PropertyPath.TopicVariable(Variables.DataValueValue)}}}"""), - ValueExpression.Expression(@"""AB"""), // %%% IMPROVE + ValueExpression.Expression(@"""A"" & ""B"""), expectedValue: DataValue.Create("AB")); } From 69cafffdc7289e4ec00130937ded7b0dc460e20d Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 14 Aug 2025 15:40:21 -0700 Subject: [PATCH 153/232] Test baseline - 100% --- .../Execution/ParseValueExecutorTest.cs | 12 ++++++++++-- .../Execution/WorkflowActionExecutorTest.cs | 3 ++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ParseValueExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ParseValueExecutorTest.cs index 4cdf6bd772..e2417479e4 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ParseValueExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ParseValueExecutorTest.cs @@ -17,10 +17,18 @@ public sealed class ParseValueExecutorTest(ITestOutputHelper output) : WorkflowA public async Task ParseTable() { // Arrange + RecordDataType.Builder recordBuilder = + new() + { + Properties = + { + {"key1", new PropertyInfo.Builder() { Type = DataType.String } }, + } + }; ParseValue model = this.CreateModel( this.FormatDisplayName(nameof(ParseTable)), - new RecordDataType.Builder(), + recordBuilder, @"{ ""key1"": ""val1"" }"); // Act @@ -40,7 +48,7 @@ public async Task ParseBoolean() this.CreateModel( this.FormatDisplayName(nameof(ParseTable)), new BooleanDataType.Builder(), - "true"); + "True"); // Act ParseValueExecutor action = new(model); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs index 2ad90cc9c1..c6fc734626 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Extensions.Logging.Abstractions; @@ -44,7 +45,7 @@ internal void VerifyModel(DialogAction model, WorkflowActionExecutor action) internal void VerifyState(string variableName, WorkflowScopeType scope, FormulaValue expectedValue) { FormulaValue actualValue = this.Scopes.Get(variableName, scope); - Assert.Equivalent(expectedValue, actualValue); + Assert.Equal(expectedValue.Format(), actualValue.Format()); } protected void VerifyUndefined(string variableName) => this.VerifyUndefined(variableName, WorkflowScopeType.Topic); From f740c996711dcea33c3d06197e8e9f44c23bfa1f Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 14 Aug 2025 15:59:15 -0700 Subject: [PATCH 154/232] More clean-up --- .../Extensions/BotElementExtensions.cs | 2 +- .../Extensions/DataValueExtensions.cs | 14 ++++++++------ .../Extensions/FormulaValueExtensions.cs | 4 ++-- .../Extensions/RecordDataTypeExtensions.cs | 2 +- .../PowerFx/WorkflowExpressionEngine.cs | 2 +- .../PowerFx/WorkflowScope.cs | 2 +- .../Extensions/FormulaValueExtensionsTests.cs | 4 ++-- 7 files changed, 16 insertions(+), 14 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs index c513b00864..ba1f8322c5 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs @@ -4,7 +4,7 @@ namespace Microsoft.Agents.Workflows.Declarative.Extensions; -internal static class DataValueExtensions +internal static class GotElementExtensions { public static string? GetParentId(this BotElement element) => element.Parent?.GetId(); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs index a19cf9ce42..0bcf709be4 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.Workflows.Declarative.Extensions; -internal static class BotElementExtensions +internal static class DataValueExtensions { public static FormulaValue ToFormulaValue(this DataValue? value) => value switch @@ -21,10 +21,13 @@ public static FormulaValue ToFormulaValue(this DataValue? value) => DateTimeDataValue dateTimeValue => FormulaValue.New(dateTimeValue.Value.DateTime), DateDataValue dateValue => FormulaValue.NewDateOnly(dateValue.Value), TimeDataValue timeValue => FormulaValue.New(timeValue.Value), - TableDataValue tableValue => FormulaValue.NewTable(tableValue.Values.First().ParseRecordType(), tableValue.Values.Select(value => value.ToRecordValue())), // %%% FAILS FOR EMPTY TABLE + TableDataValue tableValue => + FormulaValue.NewTable( + tableValue.Values.FirstOrDefault()?.ParseRecordType() ?? RecordType.Empty(), + tableValue.Values.Select(value => value.ToRecordValue())), RecordDataValue recordValue => recordValue.ToRecordValue(), OptionDataValue optionValue => FormulaValue.New(optionValue.Value.Value), - //FileDataValue // %%% SUPPORT ??? + FileDataValue => FormulaValue.NewError(new Microsoft.PowerFx.ExpressionError { Message = $"Unsupported literal type: {nameof(FileDataValue)}" }), _ => FormulaValue.NewError(new Microsoft.PowerFx.ExpressionError { Message = $"Unknown literal type: {value.GetType().Name}" }), }; @@ -40,10 +43,9 @@ public static FormulaType ToFormulaType(this DataType? type) => DateDataType => FormulaType.Date, TimeDataType => FormulaType.Time, RecordDataType => RecordType.Empty(), - //TableDataType => new TableType(), // %%% SUPPORT ??? NEED ELEMENT TYPE - //FileDataType // %%% SUPPORT ??? + TableDataType => TableType.Empty(), OptionSetDataType => FormulaType.String, - DataType dataType => FormulaType.Blank, + _ => FormulaType.Unknown, }; public static RecordValue ToRecordValue(this RecordDataValue recordDataValue) => diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs index a057982271..f06c64dcb7 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -16,7 +16,7 @@ internal static class FormulaValueExtensions { private static readonly JsonSerializerOptions s_options = new() { WriteIndented = true }; - public static DataValue GetDataValue(this FormulaValue value) => + public static DataValue ToDataValue(this FormulaValue value) => value switch { BooleanValue booleanValue => booleanValue.ToDataValue(), @@ -94,7 +94,7 @@ public static TableDataValue ToDataValue(this TableValue value) => public static RecordDataValue ToDataValue(this RecordValue value) => RecordDataValue.RecordFromFields(value.OriginalFields.Select(field => field.GetKeyValuePair()).ToImmutableArray()); - private static KeyValuePair GetKeyValuePair(this NamedValue value) => new(value.Name, value.Value.GetDataValue()); + private static KeyValuePair GetKeyValuePair(this NamedValue value) => new(value.Name, value.Value.ToDataValue()); public static JsonNode ToJson(this FormulaValue value) => value switch diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs index f01666b01b..4a3abaaac8 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs @@ -28,7 +28,7 @@ IEnumerable ParseValues() DateDataType => DateValue.New(propertyElement.GetDateTime()), TimeDataType => TimeValue.New(propertyElement.GetDateTimeOffset().TimeOfDay), RecordDataType recordType => recordType.ParseRecord(propertyElement), - //TableDataType tableType => FormulaValue.NewSingleColumnTable(propertyElement.EnumerateArray().Select(item => // %%% SUPPORT: Table ))) + //TableDataType tableType => FormulaValue.NewTable(propertyElement.EnumerateArray().Select(item => // %%% SUPPORT: Table ))) _ => throw new UnknownDataTypeException($"Unsupported data type '{property.Value.Type}' for property '{property.Key}'") }; yield return new NamedValue(property.Key, parsedValue); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs index 910519ffc5..9363b9b119 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs @@ -192,7 +192,7 @@ private EvaluationResult GetValue(ValueExpression expression, EvaluationResult expressionResult = evaluator.Invoke(expression, state); - return new EvaluationResult(expressionResult.Value.GetDataValue(), expressionResult.Sensitivity); + return new EvaluationResult(expressionResult.Value.ToDataValue(), expressionResult.Sensitivity); } private EvaluationResult GetValue(EnumExpression expression, TState state, Func> evaluator) where TValue : EnumWrapper diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs index 675b4ff64b..bcf1a2e40a 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs @@ -31,7 +31,7 @@ public RecordDataValue BuildState() foreach (KeyValuePair kvp in this) { - recordBuilder.Properties.Add(kvp.Key, kvp.Value.GetDataValue()); + recordBuilder.Properties.Add(kvp.Key, kvp.Value.ToDataValue()); } return recordBuilder.Build(); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs index 6075ee5432..a1149df147 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs @@ -68,7 +68,7 @@ public void BlankValues() { BlankValue formulaValue = FormulaValue.NewBlank(); - BlankDataValue dataCopy = Assert.IsType(formulaValue.GetDataValue()); + BlankDataValue dataCopy = Assert.IsType(formulaValue.ToDataValue()); Assert.Equal(string.Empty, formulaValue.Format()); } @@ -77,7 +77,7 @@ public void BlankValues() public void VoidValues() { VoidValue formulaValue = FormulaValue.NewVoid(); - BlankDataValue dataCopy = Assert.IsType(formulaValue.GetDataValue()); + BlankDataValue dataCopy = Assert.IsType(formulaValue.ToDataValue()); } [Fact] From dcd4c290f2d21bbdbcc1bab614a92a999612c2b9 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 14 Aug 2025 16:04:34 -0700 Subject: [PATCH 155/232] Comments --- dotnet/demos/DeclarativeWorkflow/Program.cs | 2 +- .../Execution/DeclarativeWorkflowExecutor.cs | 2 +- .../Execution/ParseValueExecutor.cs | 2 +- .../Extensions/FormulaValueExtensions.cs | 20 +++++++++++-------- .../Interpreter/WorkflowActionVisitor.cs | 2 +- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index 74bba182ac..44a2f9211e 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -67,7 +67,7 @@ public static async Task Main(string[] args) } else if (evt is DeclarativeWorkflowStreamEvent streamEvent) { - //Console.WriteLine($"#{messageEvent.Data.MessageId}:{Environment.NewLine}{messageEvent.Data}"); // %%% TODO: Streaming + //Console.WriteLine($"#{messageEvent.Data.MessageId}:{Environment.NewLine}{messageEvent.Data}"); // %%% FEATURE: Streaming } else if (evt is DeclarativeWorkflowMessageEvent messageEvent) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs index 25495bc40b..2db30e015b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs @@ -19,7 +19,7 @@ internal sealed class DeclarativeWorkflowExecutor(string workflowId, WorkflowSco { public async ValueTask HandleAsync(string message, IWorkflowContext context) { - scopes.Set("LastMessage", WorkflowScopeType.System, FormulaValue.New(message)); // %%% INIT: SYSTEM VARIABLE + scopes.Set("LastMessage", WorkflowScopeType.System, FormulaValue.New(message)); // %%% FEATURE: SYSTEM VARIABLE //await context.AddEventAsync(new ExecutorInvokeEvent(this.Id, $"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}")).ConfigureAwait(false); await context.SendMessageAsync($"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}").ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs index 90602ac5d5..17c6a29953 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs @@ -59,6 +59,6 @@ private static RecordValue ParseRecord(RecordDataType recordType, string rawText string jsonText = rawText.TrimJsonDelimiter(); JsonDocument json = JsonDocument.Parse(jsonText); JsonElement currentElement = json.RootElement; - return recordType.ParseRecord(currentElement); // %%% FIX / REIMAGINE & REMOVE + return recordType.ParseRecord(currentElement); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs index f06c64dcb7..39308f6b16 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -30,9 +30,11 @@ public static DataValue ToDataValue(this FormulaValue value) => VoidValue voidValue => voidValue.ToDataValue(), TableValue tableValue => tableValue.ToDataValue(), RecordValue recordValue => recordValue.ToDataValue(), - //GuidValue guidValue => guidValue.ToDataValue(), // %%% SUPPORT: DataValue ??? - //BlobValue => // %%% SUPPORT: DataValue ??? - //ErrorValue => // %%% SUPPORT: DataValue ??? + // %%% SUPPORT: DataType ??? + //ColorValue + //GuidValue guidValue => guidValue.ToDataValue(), + //BlobValue => + //ErrorValue => _ => throw new NotSupportedException($"Unsupported FormulaValue type: {value.GetType().Name}"), }; @@ -49,9 +51,11 @@ public static DataType GetDataType(this FormulaValue value) => StringType => DataType.String, BlankType => DataType.String, RecordType => DataType.EmptyRecord, - //GuidType => DataType.String, // %%% SUPPORT: DataType ??? - //BlobValue => // %%% SUPPORT: DataType ??? - //ErrorValue => // %%% SUPPORT: DataType ??? + // %%% SUPPORT: DataType ??? + //ColorValue + //GuidType => DataType.String, + //BlobValue => + //ErrorValue => UnknownType => DataType.Unspecified, _ => DataType.Unspecified, }; @@ -85,8 +89,8 @@ public static string Format(this FormulaValue value) => public static DataValue ToDataValue(this BlankValue _) => BlankDataValue.Blank(); public static DataValue ToDataValue(this VoidValue _) => BlankDataValue.Blank(); public static StringDataValue ToDataValue(this StringValue value) => StringDataValue.Create(value.Value); - //public static StringDataValue ToDataValue(this GuidValue value) => StringDataValue.Create(value.Value.ToString("N")); // %%% SUPPORT: DataValue ??? - //public static StringDataValue ToDataValue(this ColorValue value) => StringDataValue.Create(Enum.GetName(typeof(Color), value.Value)!); // %%% SUPPORT: DataValue ??? + //public static StringDataValue ToDataValue(this GuidValue value) => StringDataValue.Create(value.Value.ToString("N")); + //public static StringDataValue ToDataValue(this ColorValue value) => StringDataValue.Create(Enum.GetName(typeof(Color), value.Value)!); public static TableDataValue ToDataValue(this TableValue value) => TableDataValue.TableFromRecords(value.Rows.Select(row => row.Value.ToDataValue()).ToImmutableArray()); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 07de56ab16..5f8a138372 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -111,7 +111,7 @@ protected override void Visit(ConditionGroup item) this.ContinueWith(action); this.RestartFrom(action.Id, nameof(ConditionGroupExecutor), action.ParentId); - // %%% SUPPORT: item.ElseActions + // %%% BUG: item.ElseActions int index = 1; foreach (ConditionItem conditionItem in item.Conditions) From 1467b1a9b6cee6e161043f43e62f0b5ef92288a6 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 14 Aug 2025 17:23:09 -0700 Subject: [PATCH 156/232] Streaming...sort've... --- dotnet/demos/DeclarativeWorkflow/Program.cs | 38 ++++++++++++--- .../DeclarativeWorkflowStreamEvent.cs | 8 ++-- .../Execution/AnswerQuestionWithAIExecutor.cs | 46 +++++++++++++++---- .../Extensions/ChatMessageExtensions.cs | 16 +++++++ 4 files changed, 90 insertions(+), 18 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index 44a2f9211e..fce3b626d3 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -8,7 +8,6 @@ using Azure.Identity; using Microsoft.Agents.Workflows; using Microsoft.Agents.Workflows.Declarative; -using Microsoft.Bot.ObjectModel; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.Diagnostics; @@ -50,10 +49,11 @@ public static async Task Main(string[] args) Notify($"PROCESS DEFINED: {timer.Elapsed}\n"); - Notify("PROCESS INVOKE\n"); + Notify("PROCESS INVOKE"); ////////////////////////////////////////////// // Run the workflow, just like any other workflow + string? messageId = null; StreamingRun run = await InProcessExecution.StreamAsync(workflow, ""); await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { @@ -67,12 +67,39 @@ public static async Task Main(string[] args) } else if (evt is DeclarativeWorkflowStreamEvent streamEvent) { - //Console.WriteLine($"#{messageEvent.Data.MessageId}:{Environment.NewLine}{messageEvent.Data}"); // %%% FEATURE: Streaming + if (!string.Equals(messageId, streamEvent.Data.MessageId, StringComparison.Ordinal)) + { + messageId = streamEvent.Data.MessageId; + + Console.WriteLine(); + + if (messageId is not null) + { + Console.ForegroundColor = ConsoleColor.White; + Console.WriteLine($"#{messageId}:"); + } + } + try + { + Console.ForegroundColor = ConsoleColor.Gray; + Console.Write(streamEvent.Data); + //if (streamEvent.Usage is not null) + //{ + // Console.ForegroundColor = ConsoleColor.DarkGray; + // Console.WriteLine($"[Tokens Total: {streamEvent.Usage.TotalTokenCount}, Input: {streamEvent.Usage.InputTokenCount}, Output: {streamEvent.Usage.OutputTokenCount}]"); + //} + } + finally + { + Console.ResetColor(); + } } else if (evt is DeclarativeWorkflowMessageEvent messageEvent) { try { + Console.WriteLine(Environment.NewLine); + if (messageEvent.Data.MessageId is null) { Console.ForegroundColor = ConsoleColor.Yellow; @@ -80,16 +107,15 @@ public static async Task Main(string[] args) } else { - Console.ForegroundColor = ConsoleColor.Gray; - Console.WriteLine($"#{messageEvent.Data.MessageId}:"); Console.ForegroundColor = ConsoleColor.White; + Console.WriteLine($"#{messageEvent.Data.MessageId}:"); + Console.ForegroundColor = ConsoleColor.DarkGreen; Console.WriteLine(messageEvent.Data); if (messageEvent.Usage is not null) { Console.ForegroundColor = ConsoleColor.DarkGray; Console.WriteLine($"[Tokens Total: {messageEvent.Usage.TotalTokenCount}, Input: {messageEvent.Usage.InputTokenCount}, Output: {messageEvent.Usage.OutputTokenCount}]"); } - Console.WriteLine(); } } finally diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs index 0f9f4de508..dfc299fe69 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs @@ -1,16 +1,16 @@ // Copyright (c) Microsoft. All rights reserved. -using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; namespace Microsoft.Agents.Workflows.Declarative; /// /// Event that represents a streamed message produced by a declarative workflow. /// -public class DeclarativeWorkflowStreamEvent(ChatResponseUpdate update) : DeclarativeWorkflowEvent(update) +public class DeclarativeWorkflowStreamEvent(AgentRunResponseUpdate update) : DeclarativeWorkflowEvent(update) { /// - /// The streamed response data produced by the workflow, which is a . + /// The streamed response data produced by the workflow, which is a . /// - public new ChatResponseUpdate Data => update; + public new AgentRunResponseUpdate Data => update; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs index 7939c87222..607eec7ab5 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -9,7 +11,6 @@ using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.Extensions.AI; using Microsoft.Extensions.AI.Agents; -using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Declarative.Execution; @@ -38,17 +39,46 @@ protected override async ValueTask ExecuteAsync(IWorkflowContext context, Cancel { Instructions = this.Context.Engine.Format(this.Model.AdditionalInstructions) ?? string.Empty, }); - AgentRunResponse agentResponse = + + //AgentRunResponse agentResponse = + // userInput != null ? + // await agent.RunAsync(userInput, thread: null, options, cancellationToken).ConfigureAwait(false) : + // await agent.RunAsync(thread: null, options, cancellationToken).ConfigureAwait(false); + + IAsyncEnumerable agentUpdates = userInput != null ? - await agent.RunAsync(userInput, thread: null, options, cancellationToken).ConfigureAwait(false) : - await agent.RunAsync(thread: null, options, cancellationToken).ConfigureAwait(false); + agent.RunStreamingAsync(userInput, thread: null, options, cancellationToken) : + agent.RunStreamingAsync(thread: null, options, cancellationToken); - ChatMessage response = agentResponse.Messages.Last(); + await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(new ChatMessage(ChatRole.Assistant, "TESTING"))).ConfigureAwait(false); - await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(response, agentResponse.Usage)).ConfigureAwait(false); + string? conversationId = null; + string? messageId = null; + List agentResponseUpdates = []; + await foreach (AgentRunResponseUpdate update in agentUpdates.ConfigureAwait(false)) + { + if (messageId is null) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("STREAM: BEGIN"); + Console.ResetColor(); + } - StringValue responseValue = FormulaValue.New(response.ToString()); // %%% CPS - AgentMessageType + agentResponseUpdates.Add(update); + conversationId ??= update.ResponseId; + messageId ??= update.MessageId; + await context.AddEventAsync(new DeclarativeWorkflowStreamEvent(update)).ConfigureAwait(false); + } + + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("STREAM: COMPLETE"); + Console.ResetColor(); + + AgentRunResponse agentResponse = agentResponseUpdates.ToAgentRunResponse(); + + ChatMessage response = agentResponse.Messages.Last(); // %%% DECISION: Is last sufficient? (probably not) + await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(response, agentResponse.Usage)).ConfigureAwait(false); - this.AssignTarget(this.Context, variablePath, responseValue); + this.AssignTarget(this.Context, variablePath, response.ToRecord()); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs new file mode 100644 index 0000000000..09aa5cd9d8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.Extensions; + +internal static class ChatMessageExtensions +{ + public static RecordValue ToRecord(this ChatMessage message) => + RecordValue.NewRecordFromFields( + new NamedValue(nameof(ChatMessage.MessageId), FormulaValue.New(message.MessageId)), + new NamedValue(nameof(ChatMessage.Role), FormulaValue.New(message.Role.Value)), + new NamedValue(nameof(ChatMessage.AuthorName), FormulaValue.New(message.AuthorName)), + new NamedValue(nameof(ChatMessage.Text), FormulaValue.New(message.Text))); +} From ea3590db4c6e9f1455477820441c304416f6e9e6 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 14 Aug 2025 17:24:21 -0700 Subject: [PATCH 157/232] Fix build / test --- .../DeclarativeWorkflowEventTest.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowEventTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowEventTest.cs index 4d07d911fc..55bb7facea 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowEventTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowEventTest.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; using Xunit.Abstractions; namespace Microsoft.Agents.Workflows.Declarative.UnitTests; @@ -28,7 +29,7 @@ public void DeclarativeWorkflowMessageEvent() [Fact] public void DeclarativeWorkflowStreamEvent() { - ChatResponseUpdate testUpdate = new(ChatRole.Assistant, "test message"); + AgentRunResponseUpdate testUpdate = new(ChatRole.Assistant, "test message"); DeclarativeWorkflowStreamEvent workflowEvent = new(testUpdate); Assert.Equal(testUpdate, workflowEvent.Data); } From d682390478f26ba91f92c484036df2098c0b198a Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 14 Aug 2025 17:50:57 -0700 Subject: [PATCH 158/232] Stable --- .../DeclarativeWorkflowContext.cs | 1 - .../Execution/WorkflowActionExecutor.cs | 2 ++ .../Extensions/BotElementExtensions.cs | 2 +- .../Extensions/FormulaValueExtensions.cs | 5 +++-- .../Extensions/FormulaValueExtensionsTests.cs | 17 +++++++++++++++++ 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs index 1e854b36eb..522f5d8b7f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs @@ -62,7 +62,6 @@ private PersistentAgentsClient CreateClient() if (this.HttpClient is not null) { clientOptions.Transport = new HttpClientTransport(this.HttpClient); - // %%% CONSIDER: clientOptions.RetryPolicy = new RetryPolicy(maxRetries: 0); } return new PersistentAgentsClient(this.ProjectEndpoint, this.ProjectCredentials, clientOptions); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs index b5cb54377b..41f4cbe9a2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs @@ -85,6 +85,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext context) protected void AssignTarget(WorkflowExecutionContext context, PropertyPath targetPath, FormulaValue result) { context.Engine.SetScopedVariable(context.Scopes, targetPath, result); +#if DEBUG string? resultValue = result.Format(); string valuePosition = (resultValue?.IndexOf('\n') ?? -1) >= 0 ? Environment.NewLine : " "; Debug.WriteLine( @@ -93,5 +94,6 @@ protected void AssignTarget(WorkflowExecutionContext context, PropertyPath targe NAME: {targetPath.Format()} VALUE:{valuePosition}{result.Format()} ({result.GetType().Name}) """); +#endif } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs index ba1f8322c5..fe6587bdaa 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs @@ -4,7 +4,7 @@ namespace Microsoft.Agents.Workflows.Declarative.Extensions; -internal static class GotElementExtensions +internal static class BotElementExtensions { public static string? GetParentId(this BotElement element) => element.Parent?.GetId(); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs index 39308f6b16..6c9f5cc0f0 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -49,9 +49,10 @@ public static DataType GetDataType(this FormulaValue value) => DateTimeType => DataType.DateTime, TimeType => DataType.Time, StringType => DataType.String, - BlankType => DataType.String, + BlankType => DataType.Blank, RecordType => DataType.EmptyRecord, // %%% SUPPORT: DataType ??? + //TableType //ColorValue //GuidType => DataType.String, //BlobValue => @@ -69,7 +70,7 @@ public static string Format(this FormulaValue value) => DateValue dateValue => $"{dateValue.GetConvertedValue(TimeZoneInfo.Utc)}", DateTimeValue datetimeValue => $"{datetimeValue.GetConvertedValue(TimeZoneInfo.Utc)}", TimeValue timeValue => $"{timeValue.Value}", - StringValue stringValue => $"{stringValue.Value}", + StringValue stringValue => stringValue.Value, GuidValue guidValue => $"{guidValue.Value}", BlankValue blankValue => string.Empty, VoidValue voidValue => string.Empty, diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs index a1149df147..6b93898c16 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs @@ -28,6 +28,8 @@ public void BooleanValue() public void StringValues() { StringValue formulaValue = FormulaValue.New("test value"); + Assert.Equal(StringDataType.Instance, formulaValue.GetDataType()); + StringDataValue dataValue = formulaValue.ToDataValue(); Assert.Equal(formulaValue.Value, dataValue.Value); @@ -41,6 +43,8 @@ public void StringValues() public void DecimalValues() { DecimalValue formulaValue = FormulaValue.New(45.3m); + Assert.Equal(NumberDataType.Instance, formulaValue.GetDataType()); + NumberDataValue dataValue = formulaValue.ToDataValue(); Assert.Equal(formulaValue.Value, dataValue.Value); @@ -54,6 +58,8 @@ public void DecimalValues() public void NumberValues() { NumberValue formulaValue = FormulaValue.New(3.1415926535897); + Assert.Equal(FloatDataType.Instance, formulaValue.GetDataType()); + FloatDataValue dataValue = formulaValue.ToDataValue(); Assert.Equal(formulaValue.Value, dataValue.Value); @@ -67,6 +73,7 @@ public void NumberValues() public void BlankValues() { BlankValue formulaValue = FormulaValue.NewBlank(); + Assert.Equal(DataType.Blank, formulaValue.GetDataType()); BlankDataValue dataCopy = Assert.IsType(formulaValue.ToDataValue()); @@ -77,6 +84,7 @@ public void BlankValues() public void VoidValues() { VoidValue formulaValue = FormulaValue.NewVoid(); + Assert.Equal(DataType.Unspecified, formulaValue.GetDataType()); BlankDataValue dataCopy = Assert.IsType(formulaValue.ToDataValue()); } @@ -85,6 +93,8 @@ public void DateValues() { DateTime timestamp = DateTime.UtcNow.Date; DateValue formulaValue = FormulaValue.NewDateOnly(timestamp); + Assert.Equal(DataType.Date, formulaValue.GetDataType()); + DateDataValue dataValue = formulaValue.ToDataValue(); Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), dataValue.Value); @@ -99,6 +109,8 @@ public void DateTimeValues() { DateTime timestamp = DateTime.UtcNow; DateTimeValue formulaValue = FormulaValue.New(timestamp); + Assert.Equal(DataType.DateTime, formulaValue.GetDataType()); + DateTimeDataValue dataValue = formulaValue.ToDataValue(); Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), dataValue.Value); @@ -112,6 +124,8 @@ public void DateTimeValues() public void TimeValues() { TimeValue formulaValue = FormulaValue.New(TimeSpan.Parse("10:35")); + Assert.Equal(DataType.Time, formulaValue.GetDataType()); + TimeDataValue dataValue = formulaValue.ToDataValue(); Assert.Equal(formulaValue.Value, dataValue.Value); @@ -128,6 +142,8 @@ public void RecordValues() new NamedValue("FieldA", FormulaValue.New("Value1")), new NamedValue("FieldB", FormulaValue.New("Value2")), new NamedValue("FieldC", FormulaValue.New("Value3"))); + Assert.Equal(DataType.EmptyRecord, formulaValue.GetDataType()); + RecordDataValue dataValue = formulaValue.ToDataValue(); Assert.Equal(formulaValue.Fields.Count(), dataValue.Properties.Count); foreach (KeyValuePair property in dataValue.Properties) @@ -161,6 +177,7 @@ public void TableValues() new NamedValue("FieldB", FormulaValue.New("Value2")), new NamedValue("FieldC", FormulaValue.New("Value3"))); TableValue formulaValue = TableValue.NewTable(recordValue.Type, [recordValue]); + TableDataValue dataValue = formulaValue.ToDataValue(); Assert.Equal(formulaValue.Rows.Count(), dataValue.Values.Length); From 4859e1c61405e2c0d51e8a172a8f108015557b9a Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Sun, 17 Aug 2025 10:50:04 -0700 Subject: [PATCH 159/232] Checkpoint --- dotnet/agent-framework-dotnet.slnx | 7 +- dotnet/demos/DeclarativeWorkflow/Program.cs | 7 +- .../Properties/launchSettings.json | 11 ++ .../Workflows/Workflows_Declarative.cs | 42 +++-- .../GettingStarted/Workflows/demo250729.yaml | 52 ------ .../GettingStarted/Workflows/testEnd.yaml | 20 --- .../Workflows/testExpression.yaml | 22 +-- .../GettingStarted/Workflows/testGoto.yaml | 45 ------ .../GettingStarted/Workflows/testLoop.yaml | 34 ---- .../Workflows/testLoopBreak.yaml | 36 ----- .../Workflows/testLoopContinue.yaml | 36 ----- .../DeclarativeWorkflowBuilder.cs | 15 +- .../DeclarativeWorkflowContext.cs | 22 --- .../Execution/ConditionGroupExecutor.cs | 20 --- .../Execution/DeclarativeWorkflowExecutor.cs | 27 ---- .../Execution/WorkflowDelegateExecutor.cs | 19 --- .../Extensions/ChatMessageExtensions.cs | 9 +- .../DeclarativeWorkflowContextExtensions.cs | 26 +++ .../Extensions/IWorkflowContextExtensions.cs | 19 +++ .../Extensions/StringExtensions.cs | 4 + .../DeclarativeActionExecutor.cs} | 27 +++- .../DeclarativeWorkflowExecutor.cs | 30 ++++ .../Interpreter/DelegateActionExecutor.cs | 39 +++++ .../Interpreter/WorkflowActionVisitor.cs | 149 ++++++++---------- .../Interpreter/WorkflowElementWalker.cs | 3 +- .../Interpreter/WorkflowExecutionContext.cs | 9 +- .../Interpreter/WorkflowModel.cs | 12 +- .../AnswerQuestionWithAIExecutor.cs | 36 +++-- .../ClearAllVariablesExecutor.cs | 7 +- .../ObjectModel/ConditionGroupExecutor.cs | 70 ++++++++ .../EditTableV2Executor.cs | 5 +- .../ForeachExecutor.cs | 7 +- .../ParseValueExecutor.cs | 7 +- .../ResetVariableExecutor.cs | 7 +- .../SendActivityExecutor.cs | 5 +- .../SetTextVariableExecutor.cs | 7 +- .../SetVariableExecutor.cs | 7 +- .../PowerFx/RecalcEngineExtensions.cs | 4 +- .../PowerFx/WorkflowExpressionEngine.cs | 10 +- .../IWorkflowContext.cs | 30 +++- .../InProc/InProcessRunner.cs | 8 +- .../InProc/InProcessRunnerContext.cs | 14 +- .../SwitchBuilder.cs | 2 +- .../AgentProxy.cs | 26 ++- .../DeclarativeWorkflowContextTest.cs | 55 ------- .../DeclarativeWorkflowTest.cs | 74 +++++++-- .../ClearAllVariablesExecutorTest.cs | 2 +- .../Execution/ParseValueExecutorTest.cs | 2 +- .../Execution/ResetVariableExecutorTest.cs | 4 +- .../Execution/SendActivityExecutorTest.cs | 2 +- .../Execution/SetTextVariableExecutorTest.cs | 2 +- .../Execution/SetVariableExecutorTest.cs | 2 +- .../Execution/WorkflowActionExecutorTest.cs | 25 ++- .../Workflows/Condition.yaml | 8 +- .../Workflows/ConditionElse.yaml} | 17 +- .../Sample/06_Simple_Workflow_Switch.cs | 99 ++++++++++++ .../SampleSmokeTest.cs | 11 ++ workflows/MathChat.yaml | 46 ++++++ 58 files changed, 711 insertions(+), 632 deletions(-) create mode 100644 dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json delete mode 100644 dotnet/samples/GettingStarted/Workflows/demo250729.yaml delete mode 100644 dotnet/samples/GettingStarted/Workflows/testEnd.yaml delete mode 100644 dotnet/samples/GettingStarted/Workflows/testGoto.yaml delete mode 100644 dotnet/samples/GettingStarted/Workflows/testLoop.yaml delete mode 100644 dotnet/samples/GettingStarted/Workflows/testLoopBreak.yaml delete mode 100644 dotnet/samples/GettingStarted/Workflows/testLoopContinue.yaml delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ConditionGroupExecutor.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowDelegateExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowContextExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{Execution/WorkflowActionExecutor.cs => Interpreter/DeclarativeActionExecutor.cs} (69%) create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{Execution => ObjectModel}/AnswerQuestionWithAIExecutor.cs (67%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{Execution => ObjectModel}/ClearAllVariablesExecutor.cs (88%) create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{Execution => ObjectModel}/EditTableV2Executor.cs (93%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{Execution => ObjectModel}/ForeachExecutor.cs (91%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{Execution => ObjectModel}/ParseValueExecutor.cs (92%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{Execution => ObjectModel}/ResetVariableExecutor.cs (81%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{Execution => ObjectModel}/SendActivityExecutor.cs (86%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{Execution => ObjectModel}/SetTextVariableExecutor.cs (82%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{Execution => ObjectModel}/SetVariableExecutor.cs (79%) rename dotnet/{samples/GettingStarted/Workflows/testCondition2.yaml => tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ConditionElse.yaml} (59%) create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_Simple_Workflow_Switch.cs create mode 100644 workflows/MathChat.yaml diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index b7ea46cd12..c82a70582c 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -25,6 +25,9 @@ + + + @@ -130,6 +133,8 @@ + + @@ -137,7 +142,5 @@ - - diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index fce3b626d3..bfd6c894dc 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics; using System.IO; +using System.Linq; using System.Reflection; using System.Threading.Tasks; using Azure.Identity; @@ -29,7 +30,7 @@ public static async Task Main(string[] args) // // HOW TO: Create a workflow from a YAML file. // - using StreamReader yamlReader = File.OpenText("demo250729.yaml"); + using StreamReader yamlReader = File.OpenText(args.FirstOrDefault() ?? "demo250729.yaml"); // // DeclarativeWorkflowContext provides the components for workflow execution. // @@ -49,12 +50,12 @@ public static async Task Main(string[] args) Notify($"PROCESS DEFINED: {timer.Elapsed}\n"); - Notify("PROCESS INVOKE"); + Notify("PROCESS INVOKE\n"); ////////////////////////////////////////////// // Run the workflow, just like any other workflow string? messageId = null; - StreamingRun run = await InProcessExecution.StreamAsync(workflow, ""); + StreamingRun run = await InProcessExecution.StreamAsync(workflow, "What is the formula for fibbinocci sequence"); await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { if (evt is ExecutorInvokeEvent executorInvoked) diff --git a/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json b/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json new file mode 100644 index 0000000000..b729c44105 --- /dev/null +++ b/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Default": { + "commandName": "Project" + }, + "MathChat": { + "commandName": "Project", + "commandLineArgs": "\"C:\\Users\\crickman\\source\\repos\\af3\\workflows\\MathChat.yaml\"" + } + } +} \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index eed235321f..08b594540d 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -3,7 +3,6 @@ #if NET using System.Diagnostics; -using System.Text.Json; using Azure.Identity; using Microsoft.Agents.Orchestration; using Microsoft.Agents.Workflows; @@ -23,18 +22,12 @@ public class Workflows_Declarative(ITestOutputHelper output) : OrchestrationSamp [Theory] [InlineData("deepResearch")] - [InlineData("demo250729")] [InlineData("testChat", true)] [InlineData("testCondition0")] [InlineData("testCondition1")] [InlineData("testCondition2")] - [InlineData("testEnd")] [InlineData("testExpression")] - [InlineData("testGoto")] - [InlineData("testLoop")] - [InlineData("testLoopBreak")] - [InlineData("testLoopContinue")] - [InlineData("testTopic")] + //[InlineData("testTopic")] public async Task RunWorkflow(string fileName, bool enableApiIntercept = false) { HttpClient? customClient = null; @@ -66,7 +59,7 @@ public async Task RunWorkflow(string fileName, bool enableApiIntercept = false) // // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. // - Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); + Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); // ////////////////////////////////////////////////////// @@ -100,8 +93,9 @@ public async Task RunWorkflow(string fileName, bool enableApiIntercept = false) Console.WriteLine(); } } - Debug.WriteLine("\nWORKFLOW DONE"); } + + Debug.WriteLine("\nWORKFLOW DONE"); } finally { @@ -112,7 +106,7 @@ public async Task RunWorkflow(string fileName, bool enableApiIntercept = false) internal sealed class InterceptHandler : HttpClientHandler { - private static readonly JsonSerializerOptions s_options = new() { WriteIndented = true }; + //private static readonly JsonSerializerOptions s_options = new() { WriteIndented = true }; protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { @@ -124,19 +118,19 @@ protected override async Task SendAsync(HttpRequestMessage if (response.Content != null) { string responseContent; - try - { - JsonDocument responseDocument = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); - responseContent = JsonSerializer.Serialize(responseDocument, s_options); - } - catch (ArgumentException) - { - responseContent = await response.Content.ReadAsStringAsync(cancellationToken); - } - catch (JsonException) - { - responseContent = await response.Content.ReadAsStringAsync(cancellationToken); - } + //try + //{ + // JsonDocument responseDocument = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); + // responseContent = JsonSerializer.Serialize(responseDocument, s_options); + //} + //catch (ArgumentException) + //{ + // responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + //} + //catch (JsonException) + //{ + responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + //} response.Content = new StringContent(responseContent); Debug.WriteLine($"API:{Environment.NewLine}" + responseContent); diff --git a/dotnet/samples/GettingStarted/Workflows/demo250729.yaml b/dotnet/samples/GettingStarted/Workflows/demo250729.yaml deleted file mode 100644 index b58463c089..0000000000 --- a/dotnet/samples/GettingStarted/Workflows/demo250729.yaml +++ /dev/null @@ -1,52 +0,0 @@ -kind: AdaptiveDialog -beginDialog: - kind: OnActivity - id: activity_xyz123 - actions: - - # Capture optional agent instructions - - kind: SetVariable - id: setVariable_NZ2u0l - variable: Topic.Instructions - value: =System.LastMessage.Text - - # Assign a list of inputs in JSON format to a variable - - kind: SetVariable - id: setVariable_aASlmF - displayName: List all of questions for LLM - variable: Topic.Questions - value: |- - =[ - "Why is the sky blue?", - "What is the capital of France?", - "Where do rainbows come from?", - ] - - # Loop over each question in the list - - kind: Foreach - id: foreach_mVIecC - items: =Topic.Questions - index: Topic.LoopIndex - value: Topic.Question - actions: - - # Display the current question - - kind: SendActivity - id: sendActivity_lMn07p - activity: "Question {Topic.LoopIndex + 1} - {Topic.Question}" - - # Use AI to answer the question - - kind: AnswerQuestionWithAI - id: question_wEJ456 - variable: Topic.Answer - userInput: =Topic.Question - additionalInstructions: "{Topic.Instructions}" - - # After processing all questions, display a completion message - - kind: SendActivity - id: sendActivity_SVoNSV - activity: Complete! - - # End the conversation - - kind: EndConversation - id: end_8nXE8H diff --git a/dotnet/samples/GettingStarted/Workflows/testEnd.yaml b/dotnet/samples/GettingStarted/Workflows/testEnd.yaml deleted file mode 100644 index 11f789cf94..0000000000 --- a/dotnet/samples/GettingStarted/Workflows/testEnd.yaml +++ /dev/null @@ -1,20 +0,0 @@ -kind: AdaptiveDialog -beginDialog: - kind: OnActivity - id: activity_xyz123 - type: Message - actions: - - kind: SendActivity - id: sendActivity_Start - activity: Starting - - - kind: SendActivity - id: sendActivity_Done - activity: Done! - - - kind: EndConversation - id: end_skJ8u - - - kind: SendActivity - id: sendActivity_Nevah - activity: NEVER! diff --git a/dotnet/samples/GettingStarted/Workflows/testExpression.yaml b/dotnet/samples/GettingStarted/Workflows/testExpression.yaml index 2db7bc7a19..fe4a309fb4 100644 --- a/dotnet/samples/GettingStarted/Workflows/testExpression.yaml +++ b/dotnet/samples/GettingStarted/Workflows/testExpression.yaml @@ -9,12 +9,12 @@ beginDialog: variable: Topic.TestList value: =["zaz", "zbz", "zcz", "zdz", "zez", "zfz"] - - kind: SetVariable - id: setVariable2 - variable: Topic.TestResult - value: |- - =CountIf(Topic.TestList, !IsBlank(Find("e", Value))) > 0 && - !IsBlank(3) + # - kind: SetVariable + # id: setVariable2 + # variable: Topic.TestResult + # value: |- + # =CountIf(Topic.TestList, !IsBlank(Find("e", Value))) > 0 && + # !IsBlank(3) # value: =CountIf(Topic.TestList, !IsBlank(Find("e", Value))) > 0 #value: =CountIf(Topic.TestList, 1) @@ -22,16 +22,16 @@ beginDialog: - kind: SetVariable id: setVariable3 - variable: Topic.TestFind + variable: Topic.Result3 value: =Find("e", "abcdefg") #value: =CountIf(Topic.TestList, 1) # value: =!IsBlank(Topic.TestList) - - kind: SendActivity - id: sendActivity2 - activity: "Result (CountIf): {Topic.TestResult}" + # - kind: SendActivity + # id: sendActivity2 + # activity: "Result (CountIf): {Topic.TestResult}" - kind: SendActivity id: sendActivity3 - activity: "Result (Find): {Topic.TestFind}" + activity: "Result (Find): {Topic.Result3}" diff --git a/dotnet/samples/GettingStarted/Workflows/testGoto.yaml b/dotnet/samples/GettingStarted/Workflows/testGoto.yaml deleted file mode 100644 index 99caa8e7f2..0000000000 --- a/dotnet/samples/GettingStarted/Workflows/testGoto.yaml +++ /dev/null @@ -1,45 +0,0 @@ -kind: AdaptiveDialog -beginDialog: - kind: OnActivity - id: activity_xyz123 - type: Message - actions: - - - kind: SendActivity - id: sendActivity_aGsbRo - activity: First - - - kind: GotoAction - id: goto_skJ8u - actionId: sendActivity_fJsbRz - - - kind: SendActivity - id: sendActivity_nev1 - activity: NEVER! - - - kind: SendActivity - id: sendActivity_SVoNSV - activity: Last - - - kind: GotoAction - id: goto_SVoNSV - actionId: end_SVoNSV - - - kind: SendActivity - id: sendActivity_nev2 - activity: NEVER! - - - kind: SendActivity - id: sendActivity_fJsbRz - activity: Next - - - kind: GotoAction - id: goto_ajd01z - actionId: sendActivity_SVoNSV - - - kind: SendActivity - id: sendActivity_nev3 - activity: NEVER! - - - kind: EndConversation - id: end_SVoNSV diff --git a/dotnet/samples/GettingStarted/Workflows/testLoop.yaml b/dotnet/samples/GettingStarted/Workflows/testLoop.yaml deleted file mode 100644 index 6be5bba164..0000000000 --- a/dotnet/samples/GettingStarted/Workflows/testLoop.yaml +++ /dev/null @@ -1,34 +0,0 @@ -kind: AdaptiveDialog -beginDialog: - kind: OnActivity - id: activity_xyz123 - type: Message - actions: - - kind: SetVariable - id: setVariable_u4cBtN - displayName: Invocation count - variable: Topic.Count - value: =0 - - - kind: SendActivity - id: sendActivity_aGsbRo - activity: Starting - - - kind: Foreach - id: foreach_mVIecC - items: =["a", "b", "c", "d", "e", "f"] - index: Topic.LoopIndex - value: Topic.LoopValue - actions: - - kind: SetVariable - id: setVariable_A4iBtN - displayName: Invocation count - variable: Topic.Count - value: =Topic.Count + 1 - - kind: SendActivity - id: sendActivity_Pkkmpq - activity: Looping (x{Topic.Count}) - {Topic.LoopValue} [{Topic.LoopIndex}] - - - kind: SendActivity - id: sendActivity_fJsbRz - activity: Complete! (x{Topic.Count}) diff --git a/dotnet/samples/GettingStarted/Workflows/testLoopBreak.yaml b/dotnet/samples/GettingStarted/Workflows/testLoopBreak.yaml deleted file mode 100644 index 072f1c58e6..0000000000 --- a/dotnet/samples/GettingStarted/Workflows/testLoopBreak.yaml +++ /dev/null @@ -1,36 +0,0 @@ -kind: AdaptiveDialog -beginDialog: - kind: OnActivity - id: activity_xyz123 - type: Message - actions: - - kind: SetVariable - id: setVariable_u4cBtN - displayName: Invocation count - variable: Topic.Count - value: =0 - - - kind: SendActivity - id: sendActivity_aGsbRo - activity: Starting - - - kind: Foreach - id: foreach_mVIecC - items: =["a", "b", "c", "d", "e", "f"] - index: Topic.LoopIndex - value: Topic.LoopValue - actions: - - kind: SetVariable - id: setVariable_A4iBtN - displayName: Invocation count - variable: Topic.Count - value: =Topic.Count + 1 - - kind: BreakLoop - id: breakLoop_9JsbRz - - kind: SendActivity - id: sendActivity_nev1 - activity: NEVER! - - - kind: SendActivity - id: sendActivity_fJsbRz - activity: Complete! (x{Topic.Count}) diff --git a/dotnet/samples/GettingStarted/Workflows/testLoopContinue.yaml b/dotnet/samples/GettingStarted/Workflows/testLoopContinue.yaml deleted file mode 100644 index fdb800187a..0000000000 --- a/dotnet/samples/GettingStarted/Workflows/testLoopContinue.yaml +++ /dev/null @@ -1,36 +0,0 @@ -kind: AdaptiveDialog -beginDialog: - kind: OnActivity - id: activity_xyz123 - type: Message - actions: - - kind: SetVariable - id: setVariable_u4cBtN - displayName: Invocation count - variable: Topic.Count - value: =0 - - - kind: SendActivity - id: sendActivity_aGsbRo - activity: Starting - - - kind: Foreach - id: foreach_mVIecC - items: =["a", "b", "c", "d", "e", "f"] - index: Topic.LoopIndex - value: Topic.LoopValue - actions: - - kind: SetVariable - id: setVariable_A4iBtN - displayName: Invocation count - variable: Topic.Count - value: =Topic.Count + 1 - - kind: ContinueLoop - id: continueLoop_9JsbRz - - kind: SendActivity - id: sendActivity_nev1 - activity: NEVER! - - - kind: SendActivity - id: sendActivity_fJsbRz - activity: Complete! (x{Topic.Count}) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index bec246fe57..19ac92e906 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -1,10 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Diagnostics; using System.IO; -using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Interpreter; -using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Yaml; @@ -21,21 +18,17 @@ public static class DeclarativeWorkflowBuilder /// The reader that provides the workflow object model YAML. /// The execution context for the workflow. /// The that corresponds with the YAML object model. - public static Workflow Build(TextReader yamlReader, DeclarativeWorkflowContext context) + public static Workflow Build(TextReader yamlReader, DeclarativeWorkflowContext context) where TInput : notnull { - Debug.WriteLine("@ PARSING YAML"); BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new UnknownActionException("Unable to parse workflow."); string rootId = $"root_{GetRootId(rootElement)}"; - Debug.WriteLine("@ INITIALIZING BUILDER"); - WorkflowScopes scopes = new(); - DeclarativeWorkflowExecutor rootExecutor = new(rootId, scopes); + DeclarativeWorkflowExecutor rootExecutor = new(rootId); - Debug.WriteLine("@ INTERPRETING WORKFLOW"); - WorkflowActionVisitor visitor = new(rootExecutor, context, scopes); + WorkflowActionVisitor visitor = new(rootExecutor, context); WorkflowElementWalker walker = new(rootElement, visitor); - return walker.Workflow; + return walker.GetWorkflow(); } private static string GetRootId(BotElement element) => // %%% CPS - WORKFLOW TYPE diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs index 522f5d8b7f..890e76db14 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs @@ -1,12 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System.Net.Http; -using Azure.AI.Agents.Persistent; using Azure.Core; -using Azure.Core.Pipeline; using Azure.Identity; -using Microsoft.Agents.Workflows.Declarative.Execution; -using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -48,22 +44,4 @@ public sealed class DeclarativeWorkflowContext /// Gets the used to create loggers for workflow components. /// public ILoggerFactory LoggerFactory { get; init; } = NullLoggerFactory.Instance; - - internal WorkflowExecutionContext CreateActionContext(string rootId, WorkflowScopes scopes) => - new(RecalcEngineFactory.Create(scopes, this.MaximumExpressionLength), - scopes, - this.CreateClient, - this.LoggerFactory.CreateLogger(rootId)); - - private PersistentAgentsClient CreateClient() - { - PersistentAgentsAdministrationClientOptions clientOptions = new(); - - if (this.HttpClient is not null) - { - clientOptions.Transport = new HttpClientTransport(this.HttpClient); - } - - return new PersistentAgentsClient(this.ProjectEndpoint, this.ProjectCredentials, clientOptions); - } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ConditionGroupExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ConditionGroupExecutor.cs deleted file mode 100644 index 4793cc7173..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ConditionGroupExecutor.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Bot.ObjectModel; - -namespace Microsoft.Agents.Workflows.Declarative.Execution; - -internal sealed class ConditionGroupExecutor : WorkflowActionExecutor -{ - public ConditionGroupExecutor(ConditionGroup model) - : base(model) - { - } - - protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) - { - return new ValueTask(); - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs deleted file mode 100644 index 2db30e015b..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/DeclarativeWorkflowExecutor.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.PowerFx; -using Microsoft.Agents.Workflows.Reflection; -using Microsoft.PowerFx.Types; - -namespace Microsoft.Agents.Workflows.Declarative.Execution; - -/// -/// The root executor for a declarative workflow. -/// -/// The unique identifier for the workflow. -/// Scoped variable state for workflow execution. -internal sealed class DeclarativeWorkflowExecutor(string workflowId, WorkflowScopes scopes) : - ReflectingExecutor(workflowId), - IMessageHandler -{ - public async ValueTask HandleAsync(string message, IWorkflowContext context) - { - scopes.Set("LastMessage", WorkflowScopeType.System, FormulaValue.New(message)); // %%% FEATURE: SYSTEM VARIABLE - - //await context.AddEventAsync(new ExecutorInvokeEvent(this.Id, $"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}")).ConfigureAwait(false); - await context.SendMessageAsync($"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}").ConfigureAwait(false); - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowDelegateExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowDelegateExecutor.cs deleted file mode 100644 index 26f5764f23..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowDelegateExecutor.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Reflection; - -namespace Microsoft.Agents.Workflows.Declarative.Execution; - -internal sealed class WorkflowDelegateExecutor(string actionId, Func action) : - ReflectingExecutor(actionId), - IMessageHandler -{ - public async ValueTask HandleAsync(string message, IWorkflowContext context) - { - await action.Invoke().ConfigureAwait(false); - - await context.SendMessageAsync($"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}").ConfigureAwait(false); - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs index 09aa5cd9d8..c28535ce0f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs @@ -7,10 +7,11 @@ namespace Microsoft.Agents.Workflows.Declarative.Extensions; internal static class ChatMessageExtensions { - public static RecordValue ToRecord(this ChatMessage message) => + public static RecordValue ToRecordValue(this ChatMessage message) => // %%% ALIGN: MessageType RecordValue.NewRecordFromFields( - new NamedValue(nameof(ChatMessage.MessageId), FormulaValue.New(message.MessageId)), + new NamedValue(nameof(ChatMessage.MessageId), message.MessageId.ToFormulaValue()), new NamedValue(nameof(ChatMessage.Role), FormulaValue.New(message.Role.Value)), - new NamedValue(nameof(ChatMessage.AuthorName), FormulaValue.New(message.AuthorName)), - new NamedValue(nameof(ChatMessage.Text), FormulaValue.New(message.Text))); + new NamedValue(nameof(ChatMessage.AuthorName), message.AuthorName.ToFormulaValue()), + new NamedValue(nameof(ChatMessage.Text), message.Text.ToFormulaValue())); + ////new NamedValue(nameof(ChatMessage.AdditionalProperties), message.AdditionalProperties?.ToRecordValue())); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowContextExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowContextExtensions.cs new file mode 100644 index 0000000000..43fe061efb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowContextExtensions.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Agents.Persistent; +using Azure.Core.Pipeline; +using Microsoft.Agents.Workflows.Declarative.ObjectModel; +using Microsoft.Agents.Workflows.Declarative.PowerFx; + +namespace Microsoft.Agents.Workflows.Declarative.Extensions; + +internal static class DeclarativeWorkflowContextExtensions +{ + public static WorkflowExecutionContext CreateActionContext(this DeclarativeWorkflowContext context, string rootId, WorkflowScopes scopes) => + new(RecalcEngineFactory.Create(scopes, context.MaximumExpressionLength, context.MaximumCallDepth), scopes); + + public static PersistentAgentsClient CreateClient(this DeclarativeWorkflowContext context) + { + PersistentAgentsAdministrationClientOptions clientOptions = new(); + + if (context.HttpClient is not null) + { + clientOptions.Transport = new HttpClientTransport(context.HttpClient); + } + + return new PersistentAgentsClient(context.ProjectEndpoint, context.ProjectCredentials, clientOptions); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs new file mode 100644 index 0000000000..abf48e8ea8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.PowerFx; + +namespace Microsoft.Agents.Workflows.Declarative.Extensions; + +internal static class IWorkflowContextExtensions +{ + private const string ScopesKey = "__scopes__"; + + public static async Task GetScopesAsync(this IWorkflowContext context, CancellationToken cancellationToken) => + await context.ReadWorkflowStateAsync(ScopesKey).ConfigureAwait(false) ?? // %%% DEEPER INTEGRATION + new(); + + public static async Task SetScopesAsync(this IWorkflowContext context, WorkflowScopes scopes, CancellationToken cancellationToken) => + await context.QueueWorkflowStateUpdateAsync(ScopesKey, scopes).ConfigureAwait(false); // %%% DEEPER INTEGRATION +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/StringExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/StringExtensions.cs index dba1c54ac6..0f3a078d2c 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/StringExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/StringExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Text.RegularExpressions; +using Microsoft.PowerFx.Types; namespace Microsoft.Agents.Workflows.Declarative.Extensions; @@ -18,4 +19,7 @@ public static string TrimJsonDelimiter(this string value) return value.Trim(); } + + public static FormulaValue ToFormulaValue(this string? value) => + string.IsNullOrWhiteSpace(value) ? FormulaValue.NewBlank() : FormulaValue.New(value); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs similarity index 69% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs index 41f4cbe9a2..90f885043e 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/WorkflowActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -4,16 +4,20 @@ using System.Diagnostics; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.ObjectModel; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Agents.Workflows.Reflection; using Microsoft.Bot.ObjectModel; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.PowerFx.Types; -namespace Microsoft.Agents.Workflows.Declarative.Execution; +namespace Microsoft.Agents.Workflows.Declarative.Interpreter; -internal abstract class WorkflowActionExecutor(TAction model) : +internal sealed record class ExecutionResultMessage(string ExecutorId, object? Result = null); + +internal abstract class DeclarativeActionExecutor(TAction model) : WorkflowActionExecutor(model) where TAction : DialogAction { @@ -22,7 +26,7 @@ internal abstract class WorkflowActionExecutor(TAction model) : internal abstract class WorkflowActionExecutor : ReflectingExecutor, - IMessageHandler + IMessageHandler { public const string RootActionId = "(root)"; @@ -40,21 +44,25 @@ protected WorkflowActionExecutor(DialogAction model) this.Model = model; } + public DialogAction Model { get; } + public string ParentId => this._parentId ??= this.Model.GetParentId() ?? RootActionId; - public DialogAction Model { get; } + internal ILogger Logger { get; set; } = NullLogger.Instance; + + internal DeclarativeWorkflowContext WorkflowContext { get; set; } = DeclarativeWorkflowContext.Default; // %%% HAXX: Initial state protected WorkflowExecutionContext Context => this._context ?? throw new WorkflowExecutionException("Context not assigned"); - internal void Attach(WorkflowExecutionContext executionContext) + private void Attach(WorkflowExecutionContext executionContext) // %%% IMPROVE ??? { this._context = executionContext; } /// - public async ValueTask HandleAsync(string message, IWorkflowContext context) + public async ValueTask HandleAsync(ExecutionResultMessage message, IWorkflowContext context) { if (this.Model.Disabled) { @@ -62,11 +70,16 @@ public async ValueTask HandleAsync(string message, IWorkflowContext context) return; } + WorkflowScopes scopes = await context.GetScopesAsync(default).ConfigureAwait(false); + WorkflowExecutionContext executionContext = this.WorkflowContext.CreateActionContext(this.Id, scopes); // %%% IMPROVE ??? + this.Attach(executionContext); // %%% REMOVE + try { await this.ExecuteAsync(context, cancellationToken: default).ConfigureAwait(false); - await context.SendMessageAsync($"{this.Id}: {DateTime.UtcNow.ToShortTimeString()}").ConfigureAwait(false); + await context.SetScopesAsync(scopes, default).ConfigureAwait(false); + await context.SendMessageAsync(new ExecutionResultMessage(this.Id, executionContext.Result)).ConfigureAwait(false); } catch (WorkflowExecutionException) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs new file mode 100644 index 0000000000..afd8931aa6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Agents.Workflows.Reflection; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.Workflows.Declarative.Interpreter; + +/// +/// The root executor for a declarative workflow. +/// +/// The unique identifier for the workflow. +internal sealed class DeclarativeWorkflowExecutor(string workflowId) : + ReflectingExecutor>(workflowId), + IMessageHandler + where TInput : notnull +{ + public async ValueTask HandleAsync(TInput message, IWorkflowContext context) + { + ChatMessage input = new(ChatRole.User, $"{message}"); // %%% HAXX: Convert to ChatMessage + WorkflowScopes scopes = await context.GetScopesAsync(default).ConfigureAwait(false); + + scopes.Set("LastMessage", WorkflowScopeType.System, input.ToRecordValue()); + + await context.SetScopesAsync(scopes, default).ConfigureAwait(false); + await context.SendMessageAsync(new ExecutionResultMessage(this.Id)).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs new file mode 100644 index 0000000000..5a557bc649 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Reflection; + +namespace Microsoft.Agents.Workflows.Declarative.Interpreter; + +internal sealed class DelegateActionExecutor : ReflectingExecutor, IMessageHandler +{ + private readonly Func? _action; + + public DelegateActionExecutor(string actionId, Action? action = null) + : this(actionId, + action is null ? + null : + () => + { + action?.Invoke(); + return default; + }) + { } + + public DelegateActionExecutor(string actionId, Func? action = null) + : base(actionId) + { + this._action = action; + } + + public async ValueTask HandleAsync(ExecutionResultMessage message, IWorkflowContext context) + { + if (this._action is not null) + { + await this._action.Invoke().ConfigureAwait(false); + } + + await context.SendMessageAsync(new ExecutionResultMessage(this.Id)).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 5f8a138372..63d8d88ee9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -3,12 +3,11 @@ using System; using System.Diagnostics; using System.Linq; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Execution; using Microsoft.Agents.Workflows.Declarative.Extensions; -using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Agents.Workflows.Declarative.ObjectModel; using Microsoft.Bot.ObjectModel; using Microsoft.Shared.Diagnostics; +using static System.Collections.Specialized.BitVector32; namespace Microsoft.Agents.Workflows.Declarative.Interpreter; @@ -16,30 +15,26 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor { private readonly WorkflowBuilder _workflowBuilder; private readonly WorkflowModel _workflowModel; - private readonly WorkflowScopes _scopes; - private readonly WorkflowExecutionContext _executionContext; + private readonly DeclarativeWorkflowContext _workflowContext; public WorkflowActionVisitor( - ExecutorIsh rootAction, - DeclarativeWorkflowContext workflowContext, - WorkflowScopes scopes) + Executor rootAction, + DeclarativeWorkflowContext workflowContext) { this._workflowModel = new WorkflowModel(rootAction); this._workflowBuilder = new WorkflowBuilder(rootAction); - this._scopes = scopes; - - this._executionContext = workflowContext.CreateActionContext(rootAction.Id, scopes); + this._workflowContext = workflowContext; } public bool HasUnsupportedActions { get; private set; } - public Workflow Complete() + public Workflow Complete() { // Process the cached links this._workflowModel.ConnectNodes(this._workflowBuilder); // Build final workflow - return this._workflowBuilder.Build(); + return this._workflowBuilder.Build(); } protected override void Visit(ActionScope item) @@ -47,19 +42,23 @@ protected override void Visit(ActionScope item) this.Trace(item); string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); + + // %%% COMMENTS if (item.Id.Equals(parentId)) { parentId = $"root_{parentId}"; } - this.ContinueWith(this.CreateStep(item.Id.Value, nameof(ActionScope)), parentId, condition: null, CompletionHandler); + this.ContinueWith(this.CreateStep(item.Id.Value), parentId, condition: null, CompletionHandler); + + // %%% COMMENTS void CompletionHandler() { if (this._workflowModel.GetDepth(item.Id.Value) > 1) { - string completionId = RestartId(item.Id.Value); - this.ContinueWith(this.CreateStep(completionId, $"{nameof(ActionScope)}_Post"), item.Id.Value); - this._workflowModel.AddLink(completionId, RestartId(parentId)); + string completionId = RestartId(item.Id.Value); // %%% RESTART: FALSE + this.ContinueWith(this.CreateStep(completionId), item.Id.Value); + this._workflowModel.AddLink(completionId, RestartId(parentId)); // %%% RESTART: FALSE } } } @@ -68,37 +67,25 @@ public override void VisitConditionItem(ConditionItem item) { this.Trace(item); - Func? condition = null; - - if (item.Condition is not null) + ConditionGroupExecutor? conditionGroup = this._workflowModel.LocateParent(item.GetParentId()); + if (conditionGroup is not null) { - // %%% BUG: ONLY ONE (FIRST) CONDITION - condition = - new((_) => - { - bool result = this._executionContext.Engine.Eval(item.Condition.ExpressionText ?? "true").AsBoolean(); - Debug.WriteLine($"!!! CONDITION: {item.Condition.ExpressionText ?? "true"}={result}"); - return result; - }); - } - - string stepId = item.Id ?? $"{nameof(ConditionItem)}_{Guid.NewGuid():N}"; - string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); - - WorkflowDelegateExecutor executor = this.CreateStep(stepId, nameof(ConditionItem)); - this._workflowModel.AddNode(executor, parentId, CompletionHandler); - this._workflowModel.AddLink(parentId, stepId, condition); - - base.VisitConditionItem(item); + string stepId = ConditionGroupExecutor.Steps.Item(conditionGroup.Model, item); + string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); + this._workflowModel.AddNode(this.CreateStep(stepId), parentId, CompletionHandler); - void CompletionHandler() - { - string completionId = this.RestartFrom(stepId, nameof(ConditionItem), parentId); - this._workflowModel.AddLink(completionId, RestartId(parentId)); + base.VisitConditionItem(item); - if (!item.Actions.Any()) + // %%% COMMENTS + void CompletionHandler() { - this._workflowModel.AddLink(stepId, completionId); + string completionId = this.RestartAfter(stepId, parentId); // %%% RESTART: FALSE + this._workflowModel.AddLink(completionId, RestartId(parentId)); // %%% RESTART: FALSE + + if (!item.Actions.Any()) + { + this._workflowModel.AddLink(stepId, completionId); + } } } } @@ -109,17 +96,20 @@ protected override void Visit(ConditionGroup item) ConditionGroupExecutor action = new(item); this.ContinueWith(action); - this.RestartFrom(action.Id, nameof(ConditionGroupExecutor), action.ParentId); + this.RestartAfter(action); // %%% RESTART: FALSE - // %%% BUG: item.ElseActions - - int index = 1; foreach (ConditionItem conditionItem in item.Conditions) { - // Visit each action in the condition item + string stepId = ConditionGroupExecutor.Steps.Item(item, conditionItem); + this._workflowModel.AddLink(action.Id, stepId, (result) => action.IsMatch(conditionItem, result)); + conditionItem.Accept(this); + } - ++index; + if (item.ElseActions?.Actions.Length > 0) + { + string stepId = ConditionGroupExecutor.Steps.Else(item); + this._workflowModel.AddLink(action.Id, stepId, (result) => action.IsElse(result)); } } @@ -128,9 +118,9 @@ protected override void Visit(GotoAction item) this.Trace(item); string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); - this.ContinueWith(this.CreateStep(item.Id.Value, nameof(GotoAction)), parentId); + this.ContinueWith(this.CreateStep(item.Id.Value), parentId); this._workflowModel.AddLink(item.Id.Value, item.ActionId.Value); - this.RestartFrom(item.Id.Value, nameof(GotoAction), parentId); + this.RestartAfter(item.Id.Value, parentId); // %%% RESTART: TRUE } protected override void Visit(Foreach item) @@ -140,15 +130,15 @@ protected override void Visit(Foreach item) ForeachExecutor action = new(item); string loopId = ForeachExecutor.Steps.Next(action.Id); this.ContinueWith(action, condition: null, CompletionHandler); - string restartId = this.RestartFrom(action); - this.ContinueWith(this.CreateStep(loopId, $"{nameof(ForeachExecutor)}_Next", action.TakeNext), action.Id); + string restartId = this.RestartAfter(action); // %%% RESTART: FALSE + this.ContinueWith(this.CreateStep(loopId, action.TakeNext), action.Id); this._workflowModel.AddLink(loopId, restartId, (_) => !action.HasValue); - this.ContinueWith(this.CreateStep(ForeachExecutor.Steps.Start(action.Id), $"{nameof(ForeachExecutor)}_Start"), action.Id, (_) => action.HasValue); + this.ContinueWith(this.CreateStep(ForeachExecutor.Steps.Start(action.Id)), action.Id, (_) => action.HasValue); void CompletionHandler() { string completionId = ForeachExecutor.Steps.End(action.Id); - this.ContinueWith(this.CreateStep(completionId, $"{nameof(ForeachExecutor)}_End", action.Reset), action.Id); + this.ContinueWith(this.CreateStep(completionId, action.Reset), action.Id); this._workflowModel.AddLink(completionId, loopId); } } @@ -157,13 +147,13 @@ protected override void Visit(BreakLoop item) { this.Trace(item); - string? loopId = this._workflowModel.LocateParent(item.GetParentId()); - if (loopId is not null) + ForeachExecutor? loopExecutor = this._workflowModel.LocateParent(item.GetParentId()); + if (loopExecutor is not null) { string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); - this.ContinueWith(this.CreateStep(item.Id.Value, nameof(BreakLoop)), parentId); - this._workflowModel.AddLink(item.Id.Value, RestartId(loopId)); - this.RestartFrom(item.Id.Value, nameof(BreakLoop), parentId); + this.ContinueWith(this.CreateStep(item.Id.Value), parentId); + this._workflowModel.AddLink(item.Id.Value, RestartId(loopExecutor.Id)); // %%% RESTART: TRUE + this.RestartAfter(item.Id.Value, parentId); } } @@ -171,13 +161,13 @@ protected override void Visit(ContinueLoop item) { this.Trace(item); - string? loopId = this._workflowModel.LocateParent(item.GetParentId()); - if (loopId is not null) + ForeachExecutor? loopExecutor = this._workflowModel.LocateParent(item.GetParentId()); + if (loopExecutor is not null) { string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); - this.ContinueWith(this.CreateStep(item.Id.Value, nameof(ContinueLoop)), parentId); - this._workflowModel.AddLink(item.Id.Value, ForeachExecutor.Steps.Next(loopId)); - this.RestartFrom(item.Id.Value, nameof(ContinueLoop), parentId); + this.ContinueWith(this.CreateStep(item.Id.Value), parentId); + this._workflowModel.AddLink(item.Id.Value, ForeachExecutor.Steps.Next(loopExecutor.Id)); + this.RestartAfter(item.Id.Value, parentId); // %%% RESTART: TRUE } } @@ -186,15 +176,15 @@ protected override void Visit(EndConversation item) this.Trace(item); string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); - this.ContinueWith(this.CreateStep(item.Id.Value, nameof(EndConversation)), parentId); - this.RestartFrom(item.Id.Value, nameof(EndConversation), parentId); + this.ContinueWith(this.CreateStep(item.Id.Value), parentId); + this.RestartAfter(item.Id.Value, parentId); // %%% RESTART: TRUE } protected override void Visit(AnswerQuestionWithAI item) { this.Trace(item); - this.ContinueWith(new AnswerQuestionWithAIExecutor(item)); + this.ContinueWith(new AnswerQuestionWithAIExecutor(item, this._workflowContext.CreateClient())); } protected override void Visit(SetVariable item) @@ -435,7 +425,8 @@ private void ContinueWith( Func? condition = null, Action? completionHandler = null) { - executor.Attach(this._executionContext); + executor.Logger = this._workflowContext.LoggerFactory.CreateLogger(executor.Id); + executor.WorkflowContext = this._workflowContext; // %%% HAXX: Initial state this.ContinueWith(executor, executor.ParentId, condition, completionHandler); } @@ -451,25 +442,19 @@ private void ContinueWith( private static string RestartId(string actionId) => $"{actionId}_Post"; - private string RestartFrom(WorkflowActionExecutor executor) => - this.RestartFrom(executor.Id, executor.GetType().Name, executor.ParentId); + private string RestartAfter(WorkflowActionExecutor executor) => + this.RestartAfter(executor.Id, executor.ParentId); - private string RestartFrom(string actionId, string name, string parentId) + private string RestartAfter(string actionId, string parentId) { string restartId = RestartId(actionId); - this._workflowModel.AddNode(this.CreateStep(restartId, $"{name}_Post"), parentId); + this._workflowModel.AddNode(this.CreateStep(restartId), parentId); return restartId; } - private WorkflowDelegateExecutor CreateStep(string actionId, string name, Action? stepAction = null) + private DelegateActionExecutor CreateStep(string actionId, Action? stepAction = null) { - WorkflowDelegateExecutor stepExecutor = - new(actionId, - () => - { - stepAction?.Invoke(); - return new ValueTask(); - }); + DelegateActionExecutor stepExecutor = new(actionId, stepAction); return stepExecutor; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowElementWalker.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowElementWalker.cs index 9e5de528d2..0769786420 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowElementWalker.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowElementWalker.cs @@ -12,10 +12,9 @@ public WorkflowElementWalker(BotElement rootElement, WorkflowActionVisitor visit { this._visitor = visitor; this.Visit(rootElement); - this.Workflow = this._visitor.Complete(); } - public Workflow Workflow { get; } + public Workflow GetWorkflow() => this._visitor.Complete(); public override bool DefaultVisit(BotElement definition) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowExecutionContext.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowExecutionContext.cs index f91cd0d8d8..130ac3e369 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowExecutionContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowExecutionContext.cs @@ -1,16 +1,15 @@ // Copyright (c) Microsoft. All rights reserved. -using System; -using Azure.AI.Agents.Persistent; using Microsoft.Agents.Workflows.Declarative.PowerFx; -using Microsoft.Extensions.Logging; using Microsoft.PowerFx; -namespace Microsoft.Agents.Workflows.Declarative.Execution; +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; -internal sealed record class WorkflowExecutionContext(RecalcEngine Engine, WorkflowScopes Scopes, Func ClientFactory, ILogger Logger) +internal sealed record class WorkflowExecutionContext(RecalcEngine Engine, WorkflowScopes Scopes) // %%% COLLAPSE (Executor?) and/or RENAME (Engine/State/PowerFx) { private WorkflowExpressionEngine? _expressionEngine; public WorkflowExpressionEngine ExpressionEngine => this._expressionEngine ??= new WorkflowExpressionEngine(this.Engine); + + public object? Result { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs index 54223bbd4d..f3ff89a480 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs @@ -12,7 +12,7 @@ namespace Microsoft.Agents.Workflows.Declarative.Interpreter; /// internal sealed class WorkflowModel { - public WorkflowModel(ExecutorIsh rootStep) + public WorkflowModel(Executor rootStep) { this.DefineNode(rootStep); } @@ -100,7 +100,7 @@ public void ConnectNodes(WorkflowBuilder workflowBuilder) } } - private ModelNode DefineNode(ExecutorIsh executor, ModelNode? parentNode = null, Type? executorType = null, Action? completionHandler = null) + private ModelNode DefineNode(Executor executor, ModelNode? parentNode = null, Type? executorType = null, Action? completionHandler = null) { ModelNode stepNode = new(executor, parentNode, executorType, completionHandler); @@ -109,7 +109,7 @@ private ModelNode DefineNode(ExecutorIsh executor, ModelNode? parentNode = null, return stepNode; } - internal string? LocateParent(string? itemId) + internal TAction? LocateParent(string? itemId) where TAction : Executor { if (string.IsNullOrEmpty(itemId)) { @@ -125,7 +125,7 @@ private ModelNode DefineNode(ExecutorIsh executor, ModelNode? parentNode = null, if (itemNode.ExecutorType == typeof(TAction)) { - return itemNode.Id; + return (TAction)itemNode.Executor; } itemId = itemNode.Parent?.Id; @@ -134,11 +134,11 @@ private ModelNode DefineNode(ExecutorIsh executor, ModelNode? parentNode = null, return null; } - private sealed class ModelNode(ExecutorIsh executor, ModelNode? parent = null, Type? executorType = null, Action? completionHandler = null) + private sealed class ModelNode(Executor executor, ModelNode? parent = null, Type? executorType = null, Action? completionHandler = null) { public string Id => executor.Id; - public ExecutorIsh Executor => executor; + public Executor Executor => executor; public Type? ExecutorType => executorType; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs similarity index 67% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs index 607eec7ab5..6c68816b40 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs @@ -7,23 +7,24 @@ using System.Threading.Tasks; using Azure.AI.Agents.Persistent; using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.Extensions.AI; using Microsoft.Extensions.AI.Agents; +using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.Workflows.Declarative.Execution; +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; -internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model) : WorkflowActionExecutor(model) +internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, PersistentAgentsClient client) : DeclarativeActionExecutor(model) { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(this.Model.Variable)}"); StringExpression userInputExpression = Throw.IfNull(this.Model.UserInput, $"{nameof(this.Model)}.{nameof(this.Model.UserInput)}"); - PersistentAgentsClient client = this.Context.ClientFactory.Invoke(); - using NewPersistentAgentsChatClient chatClient = new(client, "asst_ueIjfGxAjsnZ4A61LlbjG9vJ"); // %%% HAXX - AGENT ID + using NewPersistentAgentsChatClient chatClient = new(client, this.Id); // %%% HAXX - AGENT ID ChatClientAgent agent = new(chatClient); string? userInput = null; @@ -45,12 +46,17 @@ protected override async ValueTask ExecuteAsync(IWorkflowContext context, Cancel // await agent.RunAsync(userInput, thread: null, options, cancellationToken).ConfigureAwait(false) : // await agent.RunAsync(thread: null, options, cancellationToken).ConfigureAwait(false); - IAsyncEnumerable agentUpdates = - userInput != null ? - agent.RunStreamingAsync(userInput, thread: null, options, cancellationToken) : - agent.RunStreamingAsync(thread: null, options, cancellationToken); + AgentThread? thread = null; // %%% HAXX: SYSTEM THREAD + FormulaValue conversationValue = this.Context.Scopes.Get("ConversationId", WorkflowScopeType.System); + if (conversationValue is StringValue stringValue) + { + thread = new AgentThread() { ConversationId = stringValue.Value }; + } - await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(new ChatMessage(ChatRole.Assistant, "TESTING"))).ConfigureAwait(false); + IAsyncEnumerable agentUpdates = + !string.IsNullOrWhiteSpace(userInput) ? + agent.RunStreamingAsync(userInput, thread, options, cancellationToken) : + agent.RunStreamingAsync(thread, options, cancellationToken); string? conversationId = null; string? messageId = null; @@ -65,7 +71,7 @@ protected override async ValueTask ExecuteAsync(IWorkflowContext context, Cancel } agentResponseUpdates.Add(update); - conversationId ??= update.ResponseId; + conversationId ??= ((ChatResponseUpdate)update.RawRepresentation!).ConversationId; messageId ??= update.MessageId; await context.AddEventAsync(new DeclarativeWorkflowStreamEvent(update)).ConfigureAwait(false); } @@ -79,6 +85,12 @@ protected override async ValueTask ExecuteAsync(IWorkflowContext context, Cancel ChatMessage response = agentResponse.Messages.Last(); // %%% DECISION: Is last sufficient? (probably not) await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(response, agentResponse.Usage)).ConfigureAwait(false); - this.AssignTarget(this.Context, variablePath, response.ToRecord()); + this.AssignTarget(this.Context, PropertyPath.FromSegments(WorkflowScopeType.System.Name, "ConversationId"), FormulaValue.New(conversationId)); // %%% HAXX: SYSTEM THREAD + + PropertyPath? variablePath = this.Model.Variable?.Path; + if (variablePath is not null) + { + this.AssignTarget(this.Context, variablePath, response.ToRecordValue()); + } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs similarity index 88% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs index c09e808af9..d9f5322467 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ClearAllVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs @@ -2,13 +2,14 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; -namespace Microsoft.Agents.Workflows.Declarative.Execution; +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; -internal sealed class ClearAllVariablesExecutor(ClearAllVariables model) : WorkflowActionExecutor(model) +internal sealed class ClearAllVariablesExecutor(ClearAllVariables model) : DeclarativeActionExecutor(model) { protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { @@ -16,7 +17,7 @@ protected override ValueTask ExecuteAsync(IWorkflowContext context, Cancellation result.Value.Handle(new ScopeHandler(this.Context)); - return new ValueTask(); + return default; } private sealed class ScopeHandler(WorkflowExecutionContext context) : IEnumVariablesToClearHandler diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs new file mode 100644 index 0000000000..cfbd7df1ab --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class ConditionGroupExecutor : DeclarativeActionExecutor +{ + public static class Steps + { + public static string Item(ConditionGroup model, ConditionItem conditionItem) + { + if (conditionItem.Id is not null) + { + return conditionItem.Id; + } + int index = model.Conditions.IndexOf(conditionItem); + return $"{model.Id}_Items{index}"; + } + + public static string Else(ConditionGroup model) => model.ElseActions.Id.Value ?? $"{model.Id}_Else"; + } + + public ConditionGroupExecutor(ConditionGroup model) + : base(model) + { + } + + public bool IsMatch(ConditionItem conditionItem, object? result) + { + if (result is not ExecutionResultMessage message) + { + return false; + } + + return string.Equals(Steps.Item(this.Model, conditionItem), message.Result as string, StringComparison.Ordinal); + } + + public bool IsElse(object? result) + { + if (result is not ExecutionResultMessage message) + { + return false; + } + + return string.Equals(Steps.Else(this.Model), message.Result as string, StringComparison.Ordinal); + } + + protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + for (int index = 0; index < this.Model.Conditions.Length; ++index) + { + ConditionItem conditionItem = this.Model.Conditions[index]; + bool result = this.Context.Engine.Eval(conditionItem.Condition?.ExpressionText ?? "true").AsBoolean(); + if (result) + { + this.Context.Result = Steps.Item(this.Model, conditionItem); + break; + } + } + + this.Context.Result ??= Steps.Else(this.Model); + + return default; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs similarity index 93% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs index 3514650042..2b11b8c5c0 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/EditTableV2Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs @@ -5,15 +5,16 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.Workflows.Declarative.Execution; +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; -internal sealed class EditTableV2Executor(EditTableV2 model) : WorkflowActionExecutor(model) +internal sealed class EditTableV2Executor(EditTableV2 model) : DeclarativeActionExecutor(model) { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ForeachExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs similarity index 91% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ForeachExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs index 9b9fd2228b..c021186a0f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ForeachExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs @@ -4,15 +4,16 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.Workflows.Declarative.Execution; +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; -internal sealed class ForeachExecutor : WorkflowActionExecutor +internal sealed class ForeachExecutor : DeclarativeActionExecutor { public static class Steps { @@ -56,7 +57,7 @@ protected override ValueTask ExecuteAsync(IWorkflowContext context, Cancellation this.Reset(); - return new ValueTask(); + return default; } public void TakeNext() diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs similarity index 92% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs index 17c6a29953..74c1bd04ca 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ParseValueExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs @@ -5,15 +5,16 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.Workflows.Declarative.Execution; +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; internal sealed class ParseValueExecutor(ParseValue model) : - WorkflowActionExecutor(model) + DeclarativeActionExecutor(model) { protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { @@ -51,7 +52,7 @@ protected override ValueTask ExecuteAsync(IWorkflowContext context, Cancellation this.AssignTarget(this.Context, variablePath, parsedResult); - return new ValueTask(); + return default; } private static RecordValue ParseRecord(RecordDataType recordType, string rawText) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ResetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs similarity index 81% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ResetVariableExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs index 65ea7a8379..6c321c0b18 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/ResetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs @@ -4,14 +4,15 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.Workflows.Declarative.Execution; +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; internal sealed class ResetVariableExecutor(ResetVariable model) : - WorkflowActionExecutor(model) + DeclarativeActionExecutor(model) { protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { @@ -24,6 +25,6 @@ protected override ValueTask ExecuteAsync(IWorkflowContext context, Cancellation NAME: {this.Model.Variable!.Format()} """); - return new ValueTask(); + return default; } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs similarity index 86% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs index 9044a72a92..1688b2cb31 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SendActivityExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs @@ -4,13 +4,14 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Bot.ObjectModel; using Microsoft.Extensions.AI; -namespace Microsoft.Agents.Workflows.Declarative.Execution; +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; internal sealed class SendActivityExecutor(SendActivity model) : - WorkflowActionExecutor(model) + DeclarativeActionExecutor(model) { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetTextVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs similarity index 82% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetTextVariableExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs index 3b70e50666..33484008dc 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetTextVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs @@ -3,13 +3,14 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.Workflows.Declarative.Execution; +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; -internal sealed class SetTextVariableExecutor(SetTextVariable model) : WorkflowActionExecutor(model) +internal sealed class SetTextVariableExecutor(SetTextVariable model) : DeclarativeActionExecutor(model) { protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { @@ -26,6 +27,6 @@ protected override ValueTask ExecuteAsync(IWorkflowContext context, Cancellation this.AssignTarget(this.Context, variablePath, result); } - return new ValueTask(); + return default; } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs similarity index 79% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetVariableExecutor.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs index 4119088eba..f109905190 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Execution/SetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs @@ -3,14 +3,15 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.PowerFx.Types; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.Workflows.Declarative.Execution; +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; -internal sealed class SetVariableExecutor(SetVariable model) : WorkflowActionExecutor(model) +internal sealed class SetVariableExecutor(SetVariable model) : DeclarativeActionExecutor(model) { protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { @@ -27,6 +28,6 @@ protected override ValueTask ExecuteAsync(IWorkflowContext context, Cancellation this.AssignTarget(this.Context, variablePath, result.Value.ToFormulaValue()); } - return new ValueTask(); + return default; } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineExtensions.cs index e2cba8d2bc..9dbe420d90 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineExtensions.cs @@ -42,7 +42,7 @@ public static void SetScopedVariable(this RecalcEngine engine, WorkflowScopes sc engine.UpdateScope(scopes, scope); } - public static void SetScope(this RecalcEngine engine, string scopeName, RecordValue scopeRecord) + public static void AssignScope(this RecalcEngine engine, string scopeName, RecordValue scopeRecord) { engine.DeleteFormula(scopeName); engine.UpdateVariable(scopeName, scopeRecord); @@ -51,6 +51,6 @@ public static void SetScope(this RecalcEngine engine, string scopeName, RecordVa private static void UpdateScope(this RecalcEngine engine, WorkflowScopes scopes, WorkflowScopeType scope) { RecordValue scopeRecord = scopes.BuildRecord(scope); - engine.SetScope(scope.Name, scopeRecord); + engine.AssignScope(scope.Name, scopeRecord); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs index 9363b9b119..34fcc1c8ec 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs @@ -308,7 +308,7 @@ private EvaluationResult EvaluateState(ExpressionBase expression, { if (kvp.Value is RecordDataValue scopeRecord) { - this._engine.SetScope(kvp.Key, scopeRecord.ToRecordValue()); + this._engine.AssignScope(kvp.Key, scopeRecord.ToRecordValue()); } } @@ -317,10 +317,10 @@ private EvaluationResult EvaluateState(ExpressionBase expression, private EvaluationResult EvaluateScope(ExpressionBase expression, WorkflowScopes state) { - this._engine.SetScope(WorkflowScopeType.System.Name, state.BuildRecord(WorkflowScopeType.System)); - this._engine.SetScope(WorkflowScopeType.Env.Name, state.BuildRecord(WorkflowScopeType.Env)); - this._engine.SetScope(WorkflowScopeType.Global.Name, state.BuildRecord(WorkflowScopeType.Global)); - this._engine.SetScope(WorkflowScopeType.Topic.Name, state.BuildRecord(WorkflowScopeType.Topic)); + this._engine.AssignScope(WorkflowScopeType.System.Name, state.BuildRecord(WorkflowScopeType.System)); + this._engine.AssignScope(WorkflowScopeType.Env.Name, state.BuildRecord(WorkflowScopeType.Env)); + this._engine.AssignScope(WorkflowScopeType.Global.Name, state.BuildRecord(WorkflowScopeType.Global)); + this._engine.AssignScope(WorkflowScopeType.Topic.Name, state.BuildRecord(WorkflowScopeType.Topic)); return this.Evaluate(expression); } diff --git a/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs index 1c83d81108..4d81b5bdea 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs @@ -25,8 +25,8 @@ public interface IWorkflowContext ValueTask SendMessageAsync(object message); /// - /// Reads a state value from the workflow's state store. If no scope is provided, the executor's private - /// scope is used. + /// 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. @@ -49,4 +49,30 @@ public interface IWorkflowContext /// used. /// A ValueTask that represents the asynchronous update operation. ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null); + + /// + /// 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. + /// The name of the scope. + /// A representing the asynchronous operation. + ValueTask ReadWorkflowStateAsync(string key, string? scopeName = null); // %%% HAXX: WORKFLOW STATE + + /// + /// Asynchronously updates the state of a queue entry identified by the specified key and optional scope. + /// + /// + /// Subsequent reads by this executor will result in the new value of the state. Other executors will only see + /// the new state starting from the next SuperStep. + /// + /// The type of the value to associate with the queue entry. + /// The unique identifier for the queue entry to update. Cannot be null or empty. + /// The value to set for the queue entry. If null, the entry's state may be cleared or reset depending on + /// implementation. + /// An optional name that specifies the scope within which the queue entry resides. If null, the default scope is + /// used. + /// A ValueTask that represents the asynchronous update operation. + ValueTask QueueWorkflowStateUpdateAsync(string key, T? value, string? scopeName = null); // %%% HAXX: WORKFLOW STATE } diff --git a/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunner.cs index 8a2879de37..bf6a174393 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunner.cs @@ -34,7 +34,6 @@ ValueTask ISuperStepRunner.EnqueueMessageAsync(object message) return this.RunContext.AddExternalMessageAsync(message); } - private Dictionary PendingCalls { get; } = new(); private Workflow Workflow { get; init; } private InProcessRunnerContext RunContext { get; init; } private EdgeMap EdgeMap { get; init; } @@ -52,11 +51,6 @@ private void RaiseWorkflowEvent(WorkflowEvent workflowEvent) this.WorkflowEvent?.Invoke(this, workflowEvent); } - private bool IsResponse(object message) - { - return message is ExternalResponse; - } - private ValueTask> RouteExternalMessageAsync(object message) { return message is ExternalResponse response @@ -110,7 +104,7 @@ async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cance private async ValueTask RunSuperstepAsync(StepContext currentStep) { // Deliver the messages and queue the next step - List>> edgeTasks = new(); + List>> edgeTasks = []; foreach (ExecutorIdentity sender in currentStep.QueuedMessages.Keys) { IEnumerable senderMessages = currentStep.QueuedMessages[sender]; diff --git a/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs index ff25293cb6..c06c3df2eb 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs @@ -16,8 +16,8 @@ internal class InProcessRunnerContext : IRunnerContext { private StepContext _nextStep = new(); private readonly Dictionary> _executorProviders; - private readonly Dictionary _executors = new(); - private readonly Dictionary _externalRequests = new(); + private readonly Dictionary _executors = []; + private readonly Dictionary _externalRequests = []; public InProcessRunnerContext(Workflow workflow, ILogger? logger = null) { @@ -85,12 +85,14 @@ public ValueTask PostAsync(ExternalRequest request) public bool CompleteRequest(string requestId) => this._externalRequests.Remove(requestId); - public readonly List QueuedEvents = new(); + public readonly List QueuedEvents = []; internal StateManager StateManager { get; } = new(); private class BoundContext(InProcessRunnerContext RunnerContext, string ExecutorId) : IWorkflowContext { + private const string WorkflowId = "__workflow__"; + public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => RunnerContext.AddEventAsync(workflowEvent); public ValueTask SendMessageAsync(object message) => RunnerContext.SendMessageAsync(ExecutorId, message); @@ -99,5 +101,11 @@ public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeNam public ValueTask ReadStateAsync(string key, string? scopeName = null) => RunnerContext.StateManager.ReadStateAsync(ExecutorId, scopeName, key); + + public ValueTask QueueWorkflowStateUpdateAsync(string key, T? value, string? scopeName = null) // %%% HAXX: WORKFLOW STATE + => RunnerContext.StateManager.WriteStateAsync(WorkflowId, scopeName, key, value); + + public ValueTask ReadWorkflowStateAsync(string key, string? scopeName = null) // %%% HAXX: WORKFLOW STATE + => RunnerContext.StateManager.ReadStateAsync(WorkflowId, scopeName, key); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs index 507b363a38..ddfe157604 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/SwitchBuilder.cs @@ -83,7 +83,7 @@ internal WorkflowBuilder ReduceToFanOut(WorkflowBuilder builder, ExecutorIsh sou List<(Func Predicate, HashSet OutgoingIndicies)> caseMap = this._caseMap; HashSet defaultIndicies = this._defaultIndicies; - return builder.AddFanOutEdge(source, CasePartitioner, this._executors.ToArray()); + return builder.AddFanOutEdge(source, CasePartitioner, [.. this._executors]); IEnumerable CasePartitioner(object? input, int targetCount) { diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs index 351d6e8e6b..dc2c89a3c4 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs @@ -27,10 +27,8 @@ public sealed class AgentProxy : AIAgent /// The actor client used to communicate with the agent. public AgentProxy(string name, IActorClient client) { - Throw.IfNull(client); - Throw.IfNullOrEmpty(name); - this._client = client; - this.Name = name; + this._client = Throw.IfNull(client, nameof(client)); + this.Name = Throw.IfNullOrEmpty(name, nameof(name)); } /// @@ -53,9 +51,9 @@ public override async Task RunAsync( AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - Throw.IfNull(messages); - var agentThread = GetAgentThreadId(thread); - return await this.RunAsync(messages, agentThread, cancellationToken).ConfigureAwait(false); + Throw.IfNull(messages, nameof(messages)); + string agentThreadId = GetAgentThreadId(thread); + return await this.RunAsync(messages, agentThreadId, cancellationToken).ConfigureAwait(false); } /// @@ -65,9 +63,9 @@ public override async IAsyncEnumerable RunStreamingAsync AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - Throw.IfNull(messages); - var agentThread = GetAgentThreadId(thread); - await foreach (var item in this.RunStreamingAsync(messages, agentThread, cancellationToken).ConfigureAwait(false)) + Throw.IfNull(messages, nameof(messages)); + string agentThreadId = GetAgentThreadId(thread); + await foreach (var item in this.RunStreamingAsync(messages, agentThreadId, cancellationToken).ConfigureAwait(false)) { yield return item; } @@ -75,7 +73,6 @@ public override async IAsyncEnumerable RunStreamingAsync private async Task RunAsync(IReadOnlyCollection messages, string threadId, CancellationToken cancellationToken) { - Throw.IfNull(messages); var handle = await this.RunCoreAsync(messages, threadId, cancellationToken).ConfigureAwait(false); var response = await handle.GetResponseAsync(cancellationToken).ConfigureAwait(false); return response.Status switch @@ -93,7 +90,6 @@ private async IAsyncEnumerable RunStreamingAsync( string threadId, [EnumeratorCancellation] CancellationToken cancellationToken) { - Throw.IfNull(messages); var response = await this.RunCoreAsync(messages, threadId, cancellationToken).ConfigureAwait(false); var updateTypeInfo = AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentRunResponseUpdate)); await foreach (var update in response.WatchUpdatesAsync(cancellationToken).ConfigureAwait(false)) @@ -130,9 +126,7 @@ private static string GetAgentThreadId(AgentThread? thread) private async Task RunCoreAsync(IReadOnlyCollection messages, string threadId, CancellationToken cancellationToken) { - Debug.Assert(messages is not null); - Debug.Assert(threadId is not null); - var newMessages = new List(messages); + List newMessages = [.. messages]; var runRequest = new AgentRunRequest { @@ -140,7 +134,7 @@ private async Task RunCoreAsync(IReadOnlyCollection { }); - DeclarativeWorkflowContext context = new() - { - LoggerFactory = customLoggerFactory - }; - - // Act - WorkflowExecutionContext executionContext = context.CreateActionContext("workflow-id", scopes); - - // Assert - Assert.NotNull(executionContext); - Assert.NotNull(executionContext.Logger); - Assert.NotSame(NullLogger.Instance, executionContext.Logger); - } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index a2498a3a33..481a3009ef 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.CodeDom.Compiler; using System.Collections.Immutable; using System.IO; using System.Linq; @@ -77,18 +78,56 @@ public async Task GotoAction() this.AssertNotExecuted("sendActivity_3"); } - [Fact] - public async Task ConditionAction() + [Theory] + [InlineData(12)] + [InlineData(37)] + public async Task ConditionAction(int input) { - await this.RunWorkflow("Condition.yaml"); + await this.RunWorkflow("Condition.yaml", input); this.AssertExecutionCount(expectedCount: 9); this.AssertExecuted("setVariable_test"); this.AssertExecuted("conditionGroup_test"); - this.AssertExecuted("conditionItem_even"); - this.AssertExecuted("sendActivity_even"); + if (input % 2 == 0) + { + this.AssertExecuted("conditionItem_even"); + this.AssertExecuted("sendActivity_even"); + this.AssertNotExecuted("conditionItem_odd"); + this.AssertNotExecuted("sendActivity_odd"); + this.AssertMessage("EVEN"); + } + else + { + this.AssertExecuted("conditionItem_odd"); + this.AssertExecuted("sendActivity_odd"); + this.AssertNotExecuted("conditionItem_even"); + this.AssertNotExecuted("sendActivity_even"); + this.AssertMessage("ODD"); + } + this.AssertExecuted("end_all"); + } + + [Theory] + [InlineData(12, 7)] + [InlineData(37, 8)] + public async Task ConditionActionWithElse(int input, int expectedActions) + { + await this.RunWorkflow("ConditionElse.yaml", input); + this.AssertExecutionCount(expectedActions); + this.AssertExecuted("setVariable_test"); + this.AssertExecuted("conditionGroup_test"); + if (input % 2 == 0) + { + this.AssertExecuted("sendActivity_else"); + this.AssertNotExecuted("conditionItem_odd"); + this.AssertNotExecuted("sendActivity_odd"); + } + else + { + this.AssertExecuted("conditionItem_odd"); + this.AssertExecuted("sendActivity_odd"); + this.AssertNotExecuted("sendActivity_else"); + } this.AssertExecuted("end_all"); - this.AssertNotExecuted("conditionItem_odd"); - this.AssertNotExecuted("sendActivity_odd"); } [Theory] @@ -159,7 +198,7 @@ public void UnsupportedAction(Type type) WorkflowScopes scopes = new(); DeclarativeWorkflowContext workflowContext = DeclarativeWorkflowContext.Default; - WorkflowActionVisitor visitor = new(new RootExecutor(), workflowContext, scopes); + WorkflowActionVisitor visitor = new(new RootExecutor(), workflowContext); WorkflowElementWalker walker = new(dialogBuilder.Build(), visitor); Assert.True(visitor.HasUnsupportedActions); } @@ -182,16 +221,27 @@ private void AssertExecuted(string executorId) Assert.Contains(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); } - private async Task RunWorkflow(string workflowPath) + private void AssertMessage(string message) + { + Assert.Contains(this.WorkflowEvents.OfType(), e => string.Equals(e.Data.Text.Trim(), message, StringComparison.Ordinal)); + } + + private Task RunWorkflow(string workflowPath) => this.RunWorkflow(workflowPath, string.Empty); + + private async Task RunWorkflow(string workflowPath, TInput workflowInput) where TInput : notnull { using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath)); - DeclarativeWorkflowContext workflowContext = DeclarativeWorkflowContext.Default; + DeclarativeWorkflowContext workflowContext = new() { LoggerFactory = this.Output }; - Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); + Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); - StreamingRun run = await InProcessExecution.StreamAsync(workflow, ""); + StreamingRun run = await InProcessExecution.StreamAsync(workflow, workflowInput); this.WorkflowEvents = run.WatchStreamAsync().ToEnumerable().ToImmutableList(); + foreach (WorkflowEvent workflowEvent in this.WorkflowEvents) + { + this.Output.WriteLine(workflowEvent.ToString()); + } this.WorkflowEventCounts = this.WorkflowEvents.GroupBy(e => e.GetType()).ToImmutableDictionary(e => e.Key, e => e.Count()); } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ClearAllVariablesExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ClearAllVariablesExecutorTest.cs index 12ae84be2d..49d97340e9 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ClearAllVariablesExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ClearAllVariablesExecutorTest.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Agents.Workflows.Declarative.ObjectModel; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx.Types; using Xunit.Abstractions; diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ParseValueExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ParseValueExecutorTest.cs index e2417479e4..4099486cf1 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ParseValueExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ParseValueExecutorTest.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Agents.Workflows.Declarative.ObjectModel; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx.Types; using Xunit.Abstractions; diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ResetVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ResetVariableExecutorTest.cs index f0273b4fae..2418582e3b 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ResetVariableExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ResetVariableExecutorTest.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Agents.Workflows.Declarative.ObjectModel; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx.Types; using Xunit.Abstractions; @@ -11,7 +11,7 @@ namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Execution; /// /// Tests for . /// -public sealed class ResetVariableTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +public sealed class ResetVariableExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) { [Fact] public async Task ResetDefinedValue() diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs index 6009b28242..f3f60a2f18 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs @@ -2,7 +2,7 @@ using System.Linq; using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Agents.Workflows.Declarative.ObjectModel; using Microsoft.Bot.ObjectModel; using Xunit.Abstractions; diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetTextVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetTextVariableExecutorTest.cs index 87bd5ef642..4153feaa37 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetTextVariableExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetTextVariableExecutorTest.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Agents.Workflows.Declarative.ObjectModel; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx.Types; using Xunit.Abstractions; diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetVariableExecutorTest.cs index 92357e38a6..c534632987 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetVariableExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetVariableExecutorTest.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Agents.Workflows.Declarative.ObjectModel; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx.Types; using Xunit.Abstractions; diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs index c6fc734626..06f8519104 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs @@ -3,11 +3,12 @@ using System; using System.Linq; using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Execution; +using Microsoft.Agents.Workflows.Declarative.ObjectModel; using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Agents.Workflows.Reflection; using Microsoft.Bot.ObjectModel; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.PowerFx.Types; using Xunit.Abstractions; @@ -26,10 +27,11 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor internal async Task Execute(WorkflowActionExecutor executor) { - WorkflowExecutionContext context = new(RecalcEngineFactory.Create(this.Scopes), this.Scopes, () => null!, NullLogger.Instance); - executor.Attach(context); - WorkflowBuilder workflowBuilder = new(executor); - StreamingRun run = await InProcessExecution.StreamAsync(workflowBuilder.Build(), ""); + WorkflowExecutionContext context = new(RecalcEngineFactory.Create(this.Scopes), this.Scopes); + TestWorkflowExecutor workflowExecutor = new(); + WorkflowBuilder workflowBuilder = new(workflowExecutor); + workflowBuilder.AddEdge(workflowExecutor, executor); + StreamingRun run = await InProcessExecution.StreamAsync(workflowBuilder.Build(), this.Scopes); WorkflowEvent[] events = await run.WatchStreamAsync().ToArrayAsync(); return events; } @@ -69,4 +71,15 @@ protected TAction AssignParent(DialogAction.Builder actionBuilder) wher return (TAction)model.Actions[0]; } + + internal sealed class TestWorkflowExecutor() : + ReflectingExecutor(nameof(TestWorkflowExecutor)), + IMessageHandler + { + public async ValueTask HandleAsync(WorkflowScopes message, IWorkflowContext context) + { + await context.SetScopesAsync(message, default).ConfigureAwait(false); + await context.SendMessageAsync(new ExecutionResultMessage(this.Id)).ConfigureAwait(false); + } + } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Condition.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Condition.yaml index b5b201c635..5c29fe4a00 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Condition.yaml +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/Condition.yaml @@ -8,21 +8,21 @@ beginDialog: - kind: SetVariable id: setVariable_test - variable: Topic.Count - value: =32 + variable: Topic.TestValue + value: =Value(System.LastMessage.Text) - kind: ConditionGroup id: conditionGroup_test conditions: - id: conditionItem_odd - condition: =Mod(Topic.Count, 1) = 1 + condition: =Mod(Topic.TestValue, 2) = 1 actions: - kind: SendActivity id: sendActivity_odd activity: ODD - id: conditionItem_even - condition: =Mod(Topic.Count, 1) = 0 + condition: =Mod(Topic.TestValue, 2) = 0 actions: - kind: SendActivity id: sendActivity_even diff --git a/dotnet/samples/GettingStarted/Workflows/testCondition2.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ConditionElse.yaml similarity index 59% rename from dotnet/samples/GettingStarted/Workflows/testCondition2.yaml rename to dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ConditionElse.yaml index b5b201c635..3f0073439d 100644 --- a/dotnet/samples/GettingStarted/Workflows/testCondition2.yaml +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/ConditionElse.yaml @@ -8,25 +8,22 @@ beginDialog: - kind: SetVariable id: setVariable_test - variable: Topic.Count - value: =32 + variable: Topic.TestValue + value: =Value(System.LastMessage.Text) - kind: ConditionGroup id: conditionGroup_test conditions: - id: conditionItem_odd - condition: =Mod(Topic.Count, 1) = 1 + condition: =Mod(Topic.TestValue, 2) = 1 actions: - kind: SendActivity id: sendActivity_odd activity: ODD - - - id: conditionItem_even - condition: =Mod(Topic.Count, 1) = 0 - actions: - - kind: SendActivity - id: sendActivity_even - activity: EVEN + elseActions: + - kind: SendActivity + id: sendActivity_else + activity: EVEN - kind: EndConversation id: end_all diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_Simple_Workflow_Switch.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_Simple_Workflow_Switch.cs new file mode 100644 index 0000000000..f379bb096f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_Simple_Workflow_Switch.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Reflection; + +namespace Microsoft.Agents.Workflows.Sample; + +internal static class Step6Switch +{ + public static async ValueTask RunAsync(TextWriter writer) + { + ResultExecutor caseExecutor1 = new(1); + ResultExecutor caseExecutor2 = new(2); + ResultExecutor caseExecutor3 = new(3); + DefaultExecutor elseExecutor = new(); + FinalExecutor finalExecutor = new(); + DiscriminatingExecutor choiceExecutor = + new(caseExecutor1.Id, + caseExecutor2.Id, + caseExecutor3.Id); + + WorkflowBuilder builder = new(choiceExecutor); + builder.AddSwitch( + choiceExecutor, + switchBuilder => + switchBuilder + .AddCase(result => IsMatch(caseExecutor1.Id, result), caseExecutor1) + .AddCase(result => IsMatch(caseExecutor2.Id, result), caseExecutor2) + .AddCase(result => IsMatch(caseExecutor3.Id, result), caseExecutor3) + .WithDefault(elseExecutor)); + + builder.AddEdge(caseExecutor1, finalExecutor); + builder.AddEdge(caseExecutor2, finalExecutor); + builder.AddEdge(caseExecutor3, finalExecutor); + builder.AddEdge(elseExecutor, finalExecutor); + + Workflow workflow = builder.Build(); + StreamingRun run = await InProcessExecution.StreamAsync(workflow, "Hello, World!").ConfigureAwait(false); + + await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + { + writer.WriteLine($"{evt}"); + } + } + + private static bool IsMatch(string executorId, object? result) + { + return string.Equals(executorId, result as string, StringComparison.Ordinal); + } + + private sealed class DiscriminatingExecutor(params string[] options) : ReflectingExecutor(nameof(DiscriminatingExecutor)), IMessageHandler + { + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + int index = 0; + foreach (char c in message) + { + index += c; + index %= (options.Length + 1); + } + return options[index]; + } + } + + private sealed class ResultExecutor(int index) : ReflectingExecutor($"{nameof(ResultExecutor)}{index}"), IMessageHandler + { + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + await context.AddEventAsync(new WorkflowEvent($"#{index}: {message}")).ConfigureAwait(false); + await context.SendMessageAsync(new ExecutorCompleteMessage(this.Id)).ConfigureAwait(false); + } + } + + private sealed class DefaultExecutor() : ReflectingExecutor(nameof(DefaultExecutor)), IMessageHandler + { + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + await context.AddEventAsync(new WorkflowEvent($"#else: {message}")).ConfigureAwait(false); + await context.SendMessageAsync(new ExecutorCompleteMessage(this.Id)).ConfigureAwait(false); + } + } + + private sealed class FinalExecutor() : ReflectingExecutor(nameof(FinalExecutor)), IMessageHandler + { + public async ValueTask HandleAsync(ExecutorCompleteMessage message, IWorkflowContext context) + { + await context.AddEventAsync(new WorkflowEvent($"#exit: {message}")).ConfigureAwait(false); + } + } + + private sealed record class ExecutorCompleteMessage(string ExecutorId) + { + public DateTime TimeStamp { get; } = DateTime.UtcNow; + + public override string ToString() => $"{this.ExecutorId}: {this.TimeStamp.ToShortTimeString()}"; + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs index dc08df598e..8899337a9b 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SampleSmokeTest.cs @@ -83,6 +83,17 @@ public async Task Test_RunSample_Step5Async() string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext); Assert.Equal("You guessed correctly! You Win!", guessResult); } + + [Fact] + public async Task Test_RunSample_Step6Async() + { + using StringWriter writer = new(); + + await Step6Switch.RunAsync(writer); + + string result = writer.ToString(); + Assert.Contains("#exit: ResultExecutor2", result); + } } internal sealed class VerifyingPlaybackResponder diff --git a/workflows/MathChat.yaml b/workflows/MathChat.yaml new file mode 100644 index 0000000000..f059d2ad6a --- /dev/null +++ b/workflows/MathChat.yaml @@ -0,0 +1,46 @@ +kind: AdaptiveDialog +beginDialog: + + kind: OnActivity + id: workflow_demo + actions: + + - kind: SetVariable + id: set_project + variable: Topic.Project + value: =System.LastMessage.Text + + - kind: SetVariable # // %%% HAXX + id: set_count_0 + variable: Topic.TurnCount + value: 0 + + - kind: AnswerQuestionWithAI + id: asst_Tq5lpaIHgIbqQSKIpGxNq6A5 # // %%% HAXX + displayName: Student + userInput: =Topic.Project + + - kind: ResetVariable + id: reset_project + variable: Topic.Project + + - kind: AnswerQuestionWithAI + id: asst_0d4uQ7HiFCfRXEuiQkspCx2T # // %%% HAXX + displayName: Teacher + userInput: ="" # // %%% HAXX + + - kind: SetVariable + id: set_count_increment + variable: Topic.TurnCount + value: =Topic.TurnCount + 1 + + - kind: ConditionGroup + id: check_completion + conditions: + - condition: =Topic.TurnCount < 4 + id: check_turn_count + actions: + + - kind: GotoAction + id: goto_student_agent + actionId: asst_Tq5lpaIHgIbqQSKIpGxNq6A5 From 261a5bd230a4c1d36781f93c3e95bd79a6d2c474 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Sun, 17 Aug 2025 12:54:46 -0700 Subject: [PATCH 160/232] Checkpoint --- .../DeclarativeWorkflowBuilder.cs | 4 +- .../Interpreter/WorkflowActionVisitor.cs | 65 ++++++++++--------- .../DeclarativeWorkflowTest.cs | 12 +++- 3 files changed, 46 insertions(+), 35 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 19ac92e906..615af8c24e 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -21,7 +21,7 @@ public static class DeclarativeWorkflowBuilder public static Workflow Build(TextReader yamlReader, DeclarativeWorkflowContext context) where TInput : notnull { BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new UnknownActionException("Unable to parse workflow."); - string rootId = $"root_{GetRootId(rootElement)}"; + string rootId = WorkflowActionVisitor.RootId(GetWorkflowId(rootElement)); DeclarativeWorkflowExecutor rootExecutor = new(rootId); @@ -31,7 +31,7 @@ public static Workflow Build(TextReader yamlReader, DeclarativeW return walker.GetWorkflow(); } - private static string GetRootId(BotElement element) => // %%% CPS - WORKFLOW TYPE + private static string GetWorkflowId(BotElement element) => // %%% CPS - WORKFLOW TYPE element switch { AdaptiveDialog adaptiveDialog => adaptiveDialog.BeginDialog?.Id.Value ?? throw new UnknownActionException("Undefined dialog"), diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 63d8d88ee9..70dcd14fb2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -7,7 +7,6 @@ using Microsoft.Agents.Workflows.Declarative.ObjectModel; using Microsoft.Bot.ObjectModel; using Microsoft.Shared.Diagnostics; -using static System.Collections.Specialized.BitVector32; namespace Microsoft.Agents.Workflows.Declarative.Interpreter; @@ -43,22 +42,22 @@ protected override void Visit(ActionScope item) string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); - // %%% COMMENTS + // Handle case where root element is its own parent if (item.Id.Equals(parentId)) { - parentId = $"root_{parentId}"; + parentId = RootId(parentId); } this.ContinueWith(this.CreateStep(item.Id.Value), parentId, condition: null, CompletionHandler); - // %%% COMMENTS + // Complete the action scope. void CompletionHandler() { if (this._workflowModel.GetDepth(item.Id.Value) > 1) { - string completionId = RestartId(item.Id.Value); // %%% RESTART: FALSE - this.ContinueWith(this.CreateStep(completionId), item.Id.Value); - this._workflowModel.AddLink(completionId, RestartId(parentId)); // %%% RESTART: FALSE + string completionId = this.CompletionFor(item.Id.Value); // End scope + this._workflowModel.AddLinkFromPeer(item.Id.Value, completionId); // Connect with final action + this._workflowModel.AddLink(completionId, PostId(parentId)); // Merge with parent scope } } } @@ -76,11 +75,11 @@ public override void VisitConditionItem(ConditionItem item) base.VisitConditionItem(item); - // %%% COMMENTS + // Complete the condition item. void CompletionHandler() { - string completionId = this.RestartAfter(stepId, parentId); // %%% RESTART: FALSE - this._workflowModel.AddLink(completionId, RestartId(parentId)); // %%% RESTART: FALSE + string completionId = this.CompletionFor(stepId); // End items + this._workflowModel.AddLink(completionId, PostId(conditionGroup.Id)); // Merge with parent scope if (!item.Actions.Any()) { @@ -96,7 +95,7 @@ protected override void Visit(ConditionGroup item) ConditionGroupExecutor action = new(item); this.ContinueWith(action); - this.RestartAfter(action); // %%% RESTART: FALSE + this.CompletionFor(action.Id); foreach (ConditionItem conditionItem in item.Conditions) { @@ -120,7 +119,7 @@ protected override void Visit(GotoAction item) string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); this.ContinueWith(this.CreateStep(item.Id.Value), parentId); this._workflowModel.AddLink(item.Id.Value, item.ActionId.Value); - this.RestartAfter(item.Id.Value, parentId); // %%% RESTART: TRUE + this.RestartAfter(item.Id.Value, parentId); } protected override void Visit(Foreach item) @@ -129,17 +128,19 @@ protected override void Visit(Foreach item) ForeachExecutor action = new(item); string loopId = ForeachExecutor.Steps.Next(action.Id); - this.ContinueWith(action, condition: null, CompletionHandler); - string restartId = this.RestartAfter(action); // %%% RESTART: FALSE - this.ContinueWith(this.CreateStep(loopId, action.TakeNext), action.Id); - this._workflowModel.AddLink(loopId, restartId, (_) => !action.HasValue); - this.ContinueWith(this.CreateStep(ForeachExecutor.Steps.Start(action.Id)), action.Id, (_) => action.HasValue); + this.ContinueWith(action, condition: null, CompletionHandler); // Foreach + this.ContinueWith(this.CreateStep(loopId, action.TakeNext), action.Id); // Loop Increment + string exitId = this.CompletionFor(action.Id); // Loop exit + this._workflowModel.AddLink(loopId, exitId, (_) => !action.HasValue); + DelegateActionExecutor startAction = this.CreateStep(ForeachExecutor.Steps.Start(action.Id)); // Start actions + this._workflowModel.AddNode(startAction, action.Id); + this._workflowModel.AddLink(loopId, startAction.Id, (_) => action.HasValue); void CompletionHandler() { - string completionId = ForeachExecutor.Steps.End(action.Id); - this.ContinueWith(this.CreateStep(completionId, action.Reset), action.Id); - this._workflowModel.AddLink(completionId, loopId); + string endActionsId = ForeachExecutor.Steps.End(action.Id); // End actions + this.ContinueWith(this.CreateStep(endActionsId, action.Reset), action.Id); + this._workflowModel.AddLink(endActionsId, loopId); } } @@ -152,7 +153,7 @@ protected override void Visit(BreakLoop item) { string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); this.ContinueWith(this.CreateStep(item.Id.Value), parentId); - this._workflowModel.AddLink(item.Id.Value, RestartId(loopExecutor.Id)); // %%% RESTART: TRUE + this._workflowModel.AddLink(item.Id.Value, PostId(loopExecutor.Id)); this.RestartAfter(item.Id.Value, parentId); } } @@ -167,7 +168,7 @@ protected override void Visit(ContinueLoop item) string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); this.ContinueWith(this.CreateStep(item.Id.Value), parentId); this._workflowModel.AddLink(item.Id.Value, ForeachExecutor.Steps.Next(loopExecutor.Id)); - this.RestartAfter(item.Id.Value, parentId); // %%% RESTART: TRUE + this.RestartAfter(item.Id.Value, parentId); } } @@ -177,7 +178,7 @@ protected override void Visit(EndConversation item) string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); this.ContinueWith(this.CreateStep(item.Id.Value), parentId); - this.RestartAfter(item.Id.Value, parentId); // %%% RESTART: TRUE + this.RestartAfter(item.Id.Value, parentId); } protected override void Visit(AnswerQuestionWithAI item) @@ -440,18 +441,20 @@ private void ContinueWith( this._workflowModel.AddLinkFromPeer(parentId, executor.Id, condition); } - private static string RestartId(string actionId) => $"{actionId}_Post"; + public static string RootId(string? actionId) => $"root_{actionId ?? "workflow"}"; - private string RestartAfter(WorkflowActionExecutor executor) => - this.RestartAfter(executor.Id, executor.ParentId); + private static string PostId(string actionId) => $"{actionId}_Post"; - private string RestartAfter(string actionId, string parentId) + private string CompletionFor(string parentId) { - string restartId = RestartId(actionId); - this._workflowModel.AddNode(this.CreateStep(restartId), parentId); - return restartId; + string actionId = PostId(parentId); + this._workflowModel.AddNode(this.CreateStep(actionId), parentId); + return actionId; } + private void RestartAfter(string actionId, string parentId) => + this._workflowModel.AddNode(this.CreateStep($"{actionId}_Continue"), parentId); + private DelegateActionExecutor CreateStep(string actionId, Action? stepAction = null) { DelegateActionExecutor stepExecutor = new(actionId, stepAction); @@ -475,7 +478,7 @@ private void Trace(DialogAction item) string? parentId = item.GetParentId(); if (item.Id.Equals(parentId ?? string.Empty)) { - parentId = $"root_{parentId}"; + parentId = RootId(parentId); } Debug.WriteLine($"> VISIT: {new string('\t', this._workflowModel.GetDepth(parentId))}{FormatItem(item)} => {FormatParent(item)}"); } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index 481a3009ef..f4d714f376 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -240,13 +240,21 @@ private async Task RunWorkflow(string workflowPath, TInput workflowInput this.WorkflowEvents = run.WatchStreamAsync().ToEnumerable().ToImmutableList(); foreach (WorkflowEvent workflowEvent in this.WorkflowEvents) { - this.Output.WriteLine(workflowEvent.ToString()); + if (workflowEvent is ExecutorInvokeEvent invokeEvent) + { + ExecutionResultMessage? message = invokeEvent.Data as ExecutionResultMessage; + this.Output.WriteLine($"EXEC: {invokeEvent.ExecutorId} << {message?.ExecutorId ?? "?"} [{message?.Result ?? "-"}]"); + } + else if (workflowEvent is DeclarativeWorkflowMessageEvent messageEvent) + { + this.Output.WriteLine($"MESSAGE: {messageEvent.Data.Text.Trim()}"); + } } this.WorkflowEventCounts = this.WorkflowEvents.GroupBy(e => e.GetType()).ToImmutableDictionary(e => e.Key, e => e.Count()); } private sealed class RootExecutor() : - ReflectingExecutor("root_workflow"), + ReflectingExecutor(WorkflowActionVisitor.RootId("workflow")), IMessageHandler { public async ValueTask HandleAsync(string message, IWorkflowContext context) From 3624c675aecc053886f83118d98cfd857e8e6888 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Sun, 17 Aug 2025 14:02:22 -0700 Subject: [PATCH 161/232] Stable --- dotnet/demos/DeclarativeWorkflow/Program.cs | 2 +- .../Workflows/Workflows_Declarative.cs | 1 - .../Extensions/ChatMessageExtensions.cs | 2 +- .../Interpreter/WorkflowActionVisitor.cs | 33 ++++++++++++------- .../DeclarativeWorkflowTest.cs | 3 +- 5 files changed, 25 insertions(+), 16 deletions(-) diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index bfd6c894dc..e54b708a82 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -44,7 +44,7 @@ public static async Task Main(string[] args) // // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. // - Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); + Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); // ////////////////////////////////////////////////////// diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index 08b594540d..9849f7ab1d 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -25,7 +25,6 @@ public class Workflows_Declarative(ITestOutputHelper output) : OrchestrationSamp [InlineData("testChat", true)] [InlineData("testCondition0")] [InlineData("testCondition1")] - [InlineData("testCondition2")] [InlineData("testExpression")] //[InlineData("testTopic")] public async Task RunWorkflow(string fileName, bool enableApiIntercept = false) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs index c28535ce0f..edd3eec95d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.Workflows.Declarative.Extensions; internal static class ChatMessageExtensions { - public static RecordValue ToRecordValue(this ChatMessage message) => // %%% ALIGN: MessageType + public static RecordValue ToRecordValue(this ChatMessage message) => // %%% CPS - MESSAGETYPE RecordValue.NewRecordFromFields( new NamedValue(nameof(ChatMessage.MessageId), message.MessageId.ToFormulaValue()), new NamedValue(nameof(ChatMessage.Role), FormulaValue.New(message.Role.Value)), diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 70dcd14fb2..a4c1a108c3 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -55,7 +55,7 @@ void CompletionHandler() { if (this._workflowModel.GetDepth(item.Id.Value) > 1) { - string completionId = this.CompletionFor(item.Id.Value); // End scope + string completionId = this.ContinuationFor(item.Id.Value); // End scope this._workflowModel.AddLinkFromPeer(item.Id.Value, completionId); // Connect with final action this._workflowModel.AddLink(completionId, PostId(parentId)); // Merge with parent scope } @@ -78,9 +78,10 @@ public override void VisitConditionItem(ConditionItem item) // Complete the condition item. void CompletionHandler() { - string completionId = this.CompletionFor(stepId); // End items + string completionId = this.ContinuationFor(stepId); // End items this._workflowModel.AddLink(completionId, PostId(conditionGroup.Id)); // Merge with parent scope + // Merge link when no action group is defined if (!item.Actions.Any()) { this._workflowModel.AddLink(stepId, completionId); @@ -95,18 +96,26 @@ protected override void Visit(ConditionGroup item) ConditionGroupExecutor action = new(item); this.ContinueWith(action); - this.CompletionFor(action.Id); + this.ContinuationFor(action.Id, action.ParentId); + string? lastConditionItemId = null; foreach (ConditionItem conditionItem in item.Conditions) { - string stepId = ConditionGroupExecutor.Steps.Item(item, conditionItem); - this._workflowModel.AddLink(action.Id, stepId, (result) => action.IsMatch(conditionItem, result)); + // Create conditional link for conditional action + lastConditionItemId = ConditionGroupExecutor.Steps.Item(item, conditionItem); + this._workflowModel.AddLink(action.Id, lastConditionItemId, (result) => action.IsMatch(conditionItem, result)); conditionItem.Accept(this); } if (item.ElseActions?.Actions.Length > 0) { + if (lastConditionItemId is not null) + { + // Create clean start for else action from prior conditions + this.RestartAfter(lastConditionItemId, action.Id); + } + // Create conditional link for else action string stepId = ConditionGroupExecutor.Steps.Else(item); this._workflowModel.AddLink(action.Id, stepId, (result) => action.IsElse(result)); } @@ -130,15 +139,15 @@ protected override void Visit(Foreach item) string loopId = ForeachExecutor.Steps.Next(action.Id); this.ContinueWith(action, condition: null, CompletionHandler); // Foreach this.ContinueWith(this.CreateStep(loopId, action.TakeNext), action.Id); // Loop Increment - string exitId = this.CompletionFor(action.Id); // Loop exit - this._workflowModel.AddLink(loopId, exitId, (_) => !action.HasValue); - DelegateActionExecutor startAction = this.CreateStep(ForeachExecutor.Steps.Start(action.Id)); // Start actions + string continuationId = this.ContinuationFor(action.Id, action.ParentId); // Action continuation + this._workflowModel.AddLink(loopId, continuationId, (_) => !action.HasValue); + DelegateActionExecutor startAction = this.CreateStep(ForeachExecutor.Steps.Start(action.Id)); // Action start this._workflowModel.AddNode(startAction, action.Id); this._workflowModel.AddLink(loopId, startAction.Id, (_) => action.HasValue); void CompletionHandler() { - string endActionsId = ForeachExecutor.Steps.End(action.Id); // End actions + string endActionsId = ForeachExecutor.Steps.End(action.Id); // Loop continuation this.ContinueWith(this.CreateStep(endActionsId, action.Reset), action.Id); this._workflowModel.AddLink(endActionsId, loopId); } @@ -445,9 +454,11 @@ private void ContinueWith( private static string PostId(string actionId) => $"{actionId}_Post"; - private string CompletionFor(string parentId) + private string ContinuationFor(string parentId) => this.ContinuationFor(parentId, parentId); + + private string ContinuationFor(string actionId, string parentId) { - string actionId = PostId(parentId); + actionId = PostId(actionId); this._workflowModel.AddNode(this.CreateStep(actionId), parentId); return actionId; } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index f4d714f376..1844e7a8ad 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.CodeDom.Compiler; using System.Collections.Immutable; using System.IO; using System.Linq; @@ -108,7 +107,7 @@ public async Task ConditionAction(int input) [Theory] [InlineData(12, 7)] - [InlineData(37, 8)] + [InlineData(37, 9)] public async Task ConditionActionWithElse(int input, int expectedActions) { await this.RunWorkflow("ConditionElse.yaml", input); From 459f92bc3fe75b417aa35bbaf22c411f26a12473 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 18 Aug 2025 08:37:25 -0700 Subject: [PATCH 162/232] Update sample after merge --- dotnet/demos/DeclarativeWorkflow/demo250729.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/demos/DeclarativeWorkflow/demo250729.yaml b/dotnet/demos/DeclarativeWorkflow/demo250729.yaml index b58463c089..f086b4912a 100644 --- a/dotnet/demos/DeclarativeWorkflow/demo250729.yaml +++ b/dotnet/demos/DeclarativeWorkflow/demo250729.yaml @@ -37,7 +37,7 @@ beginDialog: # Use AI to answer the question - kind: AnswerQuestionWithAI - id: question_wEJ456 + id: asst_Tq5lpaIHgIbqQSKIpGxNq6A5 variable: Topic.Answer userInput: =Topic.Question additionalInstructions: "{Topic.Instructions}" From 532eaa643d7eda417966e2abee798d727f15b307 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 18 Aug 2025 09:31:51 -0700 Subject: [PATCH 163/232] Add "Question" workflow --- .../Properties/launchSettings.json | 6 +++++- workflows/Question.yaml | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 workflows/Question.yaml diff --git a/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json b/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json index b729c44105..a35c14e927 100644 --- a/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json +++ b/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json @@ -5,7 +5,11 @@ }, "MathChat": { "commandName": "Project", - "commandLineArgs": "\"C:\\Users\\crickman\\source\\repos\\af3\\workflows\\MathChat.yaml\"" + "commandLineArgs": "\"C:\\Users\\crickman\\source\\repos\\af5\\workflows\\MathChat.yaml\"" + }, + "Question": { + "commandName": "Project", + "commandLineArgs": "\"C:\\Users\\crickman\\source\\repos\\af4\\workflows\\Question.yaml\"" } } } \ No newline at end of file diff --git a/workflows/Question.yaml b/workflows/Question.yaml new file mode 100644 index 0000000000..5f7a75f31c --- /dev/null +++ b/workflows/Question.yaml @@ -0,0 +1,11 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: workflow_demo + actions: + + # Use AI to answer the question + - kind: AnswerQuestionWithAI + id: asst_Tq5lpaIHgIbqQSKIpGxNq6A5 + variable: Topic.Answer + userInput: Why is the sky blue? From 98480563cc12cf33fcff2358d07bd4a398bdd59b Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 18 Aug 2025 12:12:45 -0700 Subject: [PATCH 164/232] State clean-up checkpoint --- dotnet/agent-framework-dotnet.slnx | 1 + dotnet/demos/DeclarativeWorkflow/Program.cs | 2 +- .../Properties/launchSettings.json | 2 +- .../Workflows/Workflows_Declarative.cs | 2 +- .../DeclarativeWorkflowBuilder.cs | 2 +- ...ntext.cs => DeclarativeWorkflowOptions.cs} | 6 +- .../DeclarativeWorkflowContextExtensions.cs | 8 +-- .../Extensions/TemplateExtensions.cs | 2 +- .../Interpreter/DeclarativeActionExecutor.cs | 26 ++++---- ...owModel.cs => DeclarativeWorkflowModel.cs} | 4 +- .../Interpreter/DeclarativeWorkflowState.cs | 62 +++++++++++++++++++ .../Interpreter/WorkflowActionVisitor.cs | 16 ++--- .../Interpreter/WorkflowExecutionContext.cs | 15 ----- .../AnswerQuestionWithAIExecutor.cs | 16 ++--- .../ObjectModel/ClearAllVariablesExecutor.cs | 14 ++--- .../ObjectModel/ConditionGroupExecutor.cs | 21 ++++--- .../ObjectModel/EditTableV2Executor.cs | 20 +++--- .../ObjectModel/ForeachExecutor.cs | 17 +++-- .../ObjectModel/ParseValueExecutor.cs | 10 +-- .../ObjectModel/ResetVariableExecutor.cs | 5 +- .../ObjectModel/SendActivityExecutor.cs | 7 ++- .../ObjectModel/SetTextVariableExecutor.cs | 9 ++- .../ObjectModel/SetVariableExecutor.cs | 8 +-- .../PowerFx/RecalcEngineExtensions.cs | 56 ----------------- .../PowerFx/RecalcEngineFactory.cs | 16 ++--- .../PowerFx/WorkflowExpressionEngine.cs | 46 ++++++++------ .../PowerFx/WorkflowScopes.cs | 23 +++++++ .../DeclarativeWorkflowContextTest.cs | 6 +- .../DeclarativeWorkflowTest.cs | 4 +- .../Execution/WorkflowActionExecutorTest.cs | 1 - .../Interpreter/WorkflowModelTest.cs | 14 ++--- .../PowerFx/RecalcEngineFactoryTests.cs | 2 +- .../PowerFx/RecalcEngineTest.cs | 2 +- .../PowerFx/TemplateExtensionsTests.cs | 24 +------ .../PowerFx/WorkflowExpressionEngineTests.cs | 20 ------ 35 files changed, 232 insertions(+), 257 deletions(-) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{DeclarativeWorkflowContext.cs => DeclarativeWorkflowOptions.cs} (88%) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/{WorkflowModel.cs => DeclarativeWorkflowModel.cs} (97%) create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowExecutionContext.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineExtensions.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index bb465db31c..817d708290 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -20,6 +20,7 @@ + diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index e54b708a82..03fae76683 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -34,7 +34,7 @@ public static async Task Main(string[] args) // // DeclarativeWorkflowContext provides the components for workflow execution. // - DeclarativeWorkflowContext workflowContext = + DeclarativeWorkflowOptions workflowContext = new() { LoggerFactory = NullLoggerFactory.Instance, diff --git a/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json b/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json index a35c14e927..1bb64a0131 100644 --- a/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json +++ b/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json @@ -5,7 +5,7 @@ }, "MathChat": { "commandName": "Project", - "commandLineArgs": "\"C:\\Users\\crickman\\source\\repos\\af5\\workflows\\MathChat.yaml\"" + "commandLineArgs": "\"C:\\Users\\crickman\\source\\repos\\af4\\workflows\\MathChat.yaml\"" }, "Question": { "commandName": "Project", diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index 9849f7ab1d..c0bc09885a 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -47,7 +47,7 @@ public async Task RunWorkflow(string fileName, bool enableApiIntercept = false) // // DeclarativeWorkflowContext provides the components for workflow execution. // - DeclarativeWorkflowContext workflowContext = + DeclarativeWorkflowOptions workflowContext = new() { HttpClient = customClient, diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 615af8c24e..249eb5a33a 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -18,7 +18,7 @@ public static class DeclarativeWorkflowBuilder /// The reader that provides the workflow object model YAML. /// The execution context for the workflow. /// The that corresponds with the YAML object model. - public static Workflow Build(TextReader yamlReader, DeclarativeWorkflowContext context) where TInput : notnull + public static Workflow Build(TextReader yamlReader, DeclarativeWorkflowOptions context) where TInput : notnull { BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new UnknownActionException("Unable to parse workflow."); string rootId = WorkflowActionVisitor.RootId(GetWorkflowId(rootElement)); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs similarity index 88% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs index 890e76db14..01375e9031 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs @@ -9,11 +9,11 @@ namespace Microsoft.Agents.Workflows.Declarative; /// -/// Provides configuration and context for workflow execution. +/// Configuration options for workflow execution. /// -public sealed class DeclarativeWorkflowContext +public sealed class DeclarativeWorkflowOptions { - internal static DeclarativeWorkflowContext Default { get; } = new(); + internal static DeclarativeWorkflowOptions Default { get; } = new(); /// /// Defines the endpoint for the Foundry project. diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowContextExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowContextExtensions.cs index 43fe061efb..6d7643a85c 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowContextExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowContextExtensions.cs @@ -2,17 +2,17 @@ using Azure.AI.Agents.Persistent; using Azure.Core.Pipeline; -using Microsoft.Agents.Workflows.Declarative.ObjectModel; using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.PowerFx; namespace Microsoft.Agents.Workflows.Declarative.Extensions; internal static class DeclarativeWorkflowContextExtensions { - public static WorkflowExecutionContext CreateActionContext(this DeclarativeWorkflowContext context, string rootId, WorkflowScopes scopes) => - new(RecalcEngineFactory.Create(scopes, context.MaximumExpressionLength, context.MaximumCallDepth), scopes); + public static RecalcEngine CreateRecalcEngine(this DeclarativeWorkflowOptions context) => + RecalcEngineFactory.Create(context.MaximumExpressionLength, context.MaximumCallDepth); - public static PersistentAgentsClient CreateClient(this DeclarativeWorkflowContext context) + public static PersistentAgentsClient CreateClient(this DeclarativeWorkflowOptions context) { PersistentAgentsAdministrationClientOptions clientOptions = new(); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/TemplateExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/TemplateExtensions.cs index 82cd0cdcdf..62a8568cfb 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/TemplateExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/TemplateExtensions.cs @@ -20,7 +20,7 @@ internal static class TemplateExtensions return string.Concat(line?.Segments.Select(segment => engine.Format(segment)) ?? [string.Empty]); } - private static string? Format(this RecalcEngine engine, TemplateSegment segment) + public static string? Format(this RecalcEngine engine, TemplateSegment segment) { if (segment is TextSegment textSegment) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs index 90f885043e..30c7a77a97 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -31,7 +31,7 @@ internal abstract class WorkflowActionExecutor : public const string RootActionId = "(root)"; private string? _parentId; - private WorkflowExecutionContext? _context; + private DeclarativeWorkflowState? _state; protected WorkflowActionExecutor(DialogAction model) : base(model.Id.Value) @@ -50,15 +50,12 @@ protected WorkflowActionExecutor(DialogAction model) internal ILogger Logger { get; set; } = NullLogger.Instance; - internal DeclarativeWorkflowContext WorkflowContext { get; set; } = DeclarativeWorkflowContext.Default; // %%% HAXX: Initial state + internal DeclarativeWorkflowOptions Options { get; set; } = DeclarativeWorkflowOptions.Default; - protected WorkflowExecutionContext Context => - this._context ?? - throw new WorkflowExecutionException("Context not assigned"); - - private void Attach(WorkflowExecutionContext executionContext) // %%% IMPROVE ??? + protected DeclarativeWorkflowState State { - this._context = executionContext; + get => this._state ?? throw new WorkflowExecutionException("Context not assigned"); + private set { this._state = value; } } /// @@ -71,15 +68,14 @@ public async ValueTask HandleAsync(ExecutionResultMessage message, IWorkflowCont } WorkflowScopes scopes = await context.GetScopesAsync(default).ConfigureAwait(false); - WorkflowExecutionContext executionContext = this.WorkflowContext.CreateActionContext(this.Id, scopes); // %%% IMPROVE ??? - this.Attach(executionContext); // %%% REMOVE + this.State = new DeclarativeWorkflowState(this.Options.CreateRecalcEngine(), scopes); try { - await this.ExecuteAsync(context, cancellationToken: default).ConfigureAwait(false); + object? result = await this.ExecuteAsync(context, cancellationToken: default).ConfigureAwait(false); await context.SetScopesAsync(scopes, default).ConfigureAwait(false); - await context.SendMessageAsync(new ExecutionResultMessage(this.Id, executionContext.Result)).ConfigureAwait(false); + await context.SendMessageAsync(new ExecutionResultMessage(this.Id, result)).ConfigureAwait(false); } catch (WorkflowExecutionException) { @@ -93,11 +89,11 @@ public async ValueTask HandleAsync(ExecutionResultMessage message, IWorkflowCont } } - protected abstract ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default); + protected abstract ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default); - protected void AssignTarget(WorkflowExecutionContext context, PropertyPath targetPath, FormulaValue result) + protected void AssignTarget(PropertyPath targetPath, FormulaValue result) { - context.Engine.SetScopedVariable(context.Scopes, targetPath, result); + this.State.Set(targetPath, result); #if DEBUG string? resultValue = result.Format(); string valuePosition = (resultValue?.IndexOf('\n') ?? -1) >= 0 ? Environment.NewLine : " "; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowModel.cs similarity index 97% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowModel.cs index f3ff89a480..9809038f1e 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowModel.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowModel.cs @@ -10,9 +10,9 @@ namespace Microsoft.Agents.Workflows.Declarative.Interpreter; /// /// Provides dynamic model for constructing a declarative workflow. /// -internal sealed class WorkflowModel +internal sealed class DeclarativeWorkflowModel { - public WorkflowModel(Executor rootStep) + public DeclarativeWorkflowModel(Executor rootStep) { this.DefineNode(rootStep); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs new file mode 100644 index 0000000000..c959f1d767 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.Interpreter; + +internal sealed class DeclarativeWorkflowState +{ + private readonly RecalcEngine _engine; + private readonly WorkflowScopes _scopes; + private WorkflowExpressionEngine? _expressionEngine; + + public DeclarativeWorkflowState(RecalcEngine engine, WorkflowScopes? scopes = null) + { + this._scopes = scopes ?? new WorkflowScopes(); + this._engine = engine; + this._scopes.Bind(this._engine); + } + + // %%% TODO: IWorkflowContext + + public WorkflowScopes Scopes => this._scopes; // %%% NOT PUBLIC + + public WorkflowExpressionEngine ExpressionEngine => this._expressionEngine ??= new WorkflowExpressionEngine(this._engine); + + public void Clear(PropertyPath variablePath) => + this.Clear(WorkflowScopeType.Parse(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName)); + + public void Clear(WorkflowScopeType scope, string? varName = null) + { + if (string.IsNullOrWhiteSpace(varName)) + { + this._scopes.Clear(scope); + } + else + { + this._scopes.Remove(varName, scope); + } + + this._scopes.Bind(this._engine, scope); + } + + public void Set(PropertyPath variablePath, FormulaValue value) => + this.Set(WorkflowScopeType.Parse(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName), value); + + public void Set(WorkflowScopeType scope, string varName, FormulaValue value) + { + this._scopes.Set(varName, scope, value); + + this._scopes.Bind(this._engine, scope); + } + + public string? Format(IEnumerable template) => this._engine.Format(template); + + public string? Format(TemplateLine? line) => this._engine.Format(line); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index a4c1a108c3..99f1f6b5db 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -13,16 +13,16 @@ namespace Microsoft.Agents.Workflows.Declarative.Interpreter; internal sealed class WorkflowActionVisitor : DialogActionVisitor { private readonly WorkflowBuilder _workflowBuilder; - private readonly WorkflowModel _workflowModel; - private readonly DeclarativeWorkflowContext _workflowContext; + private readonly DeclarativeWorkflowModel _workflowModel; + private readonly DeclarativeWorkflowOptions _options; public WorkflowActionVisitor( Executor rootAction, - DeclarativeWorkflowContext workflowContext) + DeclarativeWorkflowOptions workflowContext) { - this._workflowModel = new WorkflowModel(rootAction); + this._workflowModel = new DeclarativeWorkflowModel(rootAction); this._workflowBuilder = new WorkflowBuilder(rootAction); - this._workflowContext = workflowContext; + this._options = workflowContext; } public bool HasUnsupportedActions { get; private set; } @@ -194,7 +194,7 @@ protected override void Visit(AnswerQuestionWithAI item) { this.Trace(item); - this.ContinueWith(new AnswerQuestionWithAIExecutor(item, this._workflowContext.CreateClient())); + this.ContinueWith(new AnswerQuestionWithAIExecutor(item, this._options.CreateClient())); } protected override void Visit(SetVariable item) @@ -435,8 +435,8 @@ private void ContinueWith( Func? condition = null, Action? completionHandler = null) { - executor.Logger = this._workflowContext.LoggerFactory.CreateLogger(executor.Id); - executor.WorkflowContext = this._workflowContext; // %%% HAXX: Initial state + executor.Logger = this._options.LoggerFactory.CreateLogger(executor.Id); + executor.Options = this._options; this.ContinueWith(executor, executor.ParentId, condition, completionHandler); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowExecutionContext.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowExecutionContext.cs deleted file mode 100644 index 130ac3e369..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowExecutionContext.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.Workflows.Declarative.PowerFx; -using Microsoft.PowerFx; - -namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; - -internal sealed record class WorkflowExecutionContext(RecalcEngine Engine, WorkflowScopes Scopes) // %%% COLLAPSE (Executor?) and/or RENAME (Engine/State/PowerFx) -{ - private WorkflowExpressionEngine? _expressionEngine; - - public WorkflowExpressionEngine ExpressionEngine => this._expressionEngine ??= new WorkflowExpressionEngine(this.Engine); - - public object? Result { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs index 6c68816b40..f304da9311 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs @@ -20,7 +20,7 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, PersistentAgentsClient client) : DeclarativeActionExecutor(model) { - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { StringExpression userInputExpression = Throw.IfNull(this.Model.UserInput, $"{nameof(this.Model)}.{nameof(this.Model.UserInput)}"); @@ -30,15 +30,15 @@ protected override async ValueTask ExecuteAsync(IWorkflowContext context, Cancel string? userInput = null; if (this.Model.UserInput is not null) { - EvaluationResult result = this.Context.ExpressionEngine.GetValue(userInputExpression, this.Context.Scopes); - userInput = result.Value; + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(userInputExpression, this.State.Scopes); + userInput = expressionResult.Value; } ChatClientAgentRunOptions options = new( new ChatOptions() { - Instructions = this.Context.Engine.Format(this.Model.AdditionalInstructions) ?? string.Empty, + Instructions = this.State.Format(this.Model.AdditionalInstructions) ?? string.Empty, }); //AgentRunResponse agentResponse = @@ -47,7 +47,7 @@ protected override async ValueTask ExecuteAsync(IWorkflowContext context, Cancel // await agent.RunAsync(thread: null, options, cancellationToken).ConfigureAwait(false); AgentThread? thread = null; // %%% HAXX: SYSTEM THREAD - FormulaValue conversationValue = this.Context.Scopes.Get("ConversationId", WorkflowScopeType.System); + FormulaValue conversationValue = this.State.Scopes.Get("ConversationId", WorkflowScopeType.System); if (conversationValue is StringValue stringValue) { thread = new AgentThread() { ConversationId = stringValue.Value }; @@ -85,12 +85,14 @@ protected override async ValueTask ExecuteAsync(IWorkflowContext context, Cancel ChatMessage response = agentResponse.Messages.Last(); // %%% DECISION: Is last sufficient? (probably not) await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(response, agentResponse.Usage)).ConfigureAwait(false); - this.AssignTarget(this.Context, PropertyPath.FromSegments(WorkflowScopeType.System.Name, "ConversationId"), FormulaValue.New(conversationId)); // %%% HAXX: SYSTEM THREAD + this.AssignTarget(PropertyPath.FromSegments(WorkflowScopeType.System.Name, "ConversationId"), FormulaValue.New(conversationId)); // %%% HAXX: SYSTEM THREAD PropertyPath? variablePath = this.Model.Variable?.Path; if (variablePath is not null) { - this.AssignTarget(this.Context, variablePath, response.ToRecordValue()); + this.AssignTarget(variablePath, response.ToRecordValue()); } + + return default; } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs index d9f5322467..b001ce6d72 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs @@ -11,20 +11,20 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; internal sealed class ClearAllVariablesExecutor(ClearAllVariables model) : DeclarativeActionExecutor(model) { - protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - EvaluationResult result = this.Context.ExpressionEngine.GetValue(this.Model.Variables, this.Context.Scopes); + EvaluationResult variablesResult = this.State.ExpressionEngine.GetValue(this.Model.Variables, this.State.Scopes); - result.Value.Handle(new ScopeHandler(this.Context)); + variablesResult.Value.Handle(new ScopeHandler(this.State)); return default; } - private sealed class ScopeHandler(WorkflowExecutionContext context) : IEnumVariablesToClearHandler + private sealed class ScopeHandler(DeclarativeWorkflowState state) : IEnumVariablesToClearHandler { public void HandleAllGlobalVariables() { - context.Engine.ClearScope(context.Scopes, WorkflowScopeType.Global); + state.Clear(WorkflowScopeType.Global); } public void HandleConversationHistory() @@ -34,7 +34,7 @@ public void HandleConversationHistory() public void HandleConversationScopedVariables() { - context.Engine.ClearScope(context.Scopes, WorkflowScopeType.Topic); + state.Clear(WorkflowScopeType.Topic); } public void HandleUnknownValue() @@ -44,7 +44,7 @@ public void HandleUnknownValue() public void HandleUserScopedVariables() { - context.Engine.ClearScope(context.Scopes, WorkflowScopeType.Env); // %%% DECISION: Is this correct? If not, what? + state.Clear(WorkflowScopeType.Env); // %%% DECISION: Is this correct? If not, what? } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs index cfbd7df1ab..5faf843634 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; @@ -50,21 +51,25 @@ public bool IsElse(object? result) return string.Equals(Steps.Else(this.Model), message.Result as string, StringComparison.Ordinal); } - protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously { for (int index = 0; index < this.Model.Conditions.Length; ++index) { ConditionItem conditionItem = this.Model.Conditions[index]; - bool result = this.Context.Engine.Eval(conditionItem.Condition?.ExpressionText ?? "true").AsBoolean(); - if (result) + if (conditionItem.Condition is null) { - this.Context.Result = Steps.Item(this.Model, conditionItem); - break; + continue; // Skip if no condition is defined } - } - this.Context.Result ??= Steps.Else(this.Model); + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(conditionItem.Condition, this.State.Scopes); + if (expressionResult.Value) + { + return Steps.Item(this.Model, conditionItem); + } + } - return default; + return Steps.Else(this.Model); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs index 2b11b8c5c0..925009ce21 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs @@ -16,11 +16,11 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; internal sealed class EditTableV2Executor(EditTableV2 model) : DeclarativeActionExecutor(model) { - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { PropertyPath variablePath = Throw.IfNull(this.Model.ItemsVariable?.Path, $"{nameof(this.Model)}.{nameof(this.Model.ItemsVariable)}"); - FormulaValue table = this.Context.Scopes.Get(variablePath.VariableName!, WorkflowScopeType.Parse(variablePath.VariableScopeName)); + FormulaValue table = this.State.Scopes.Get(variablePath.VariableName!, WorkflowScopeType.Parse(variablePath.VariableScopeName)); if (table is not TableValue tableValue) { throw new WorkflowExecutionException($"Require '{variablePath.Format()}' to be a table, not: '{table.GetType().Name}'."); @@ -30,30 +30,32 @@ protected override async ValueTask ExecuteAsync(IWorkflowContext context, Cancel if (changeType is AddItemOperation addItemOperation) { ValueExpression addItemValue = Throw.IfNull(addItemOperation.Value, $"{nameof(this.Model)}.{nameof(this.Model.ChangeType)}"); - EvaluationResult result = this.Context.ExpressionEngine.GetValue(addItemValue, this.Context.Scopes); - RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), result.Value.ToFormulaValue()); + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(addItemValue, this.State.Scopes); + RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), expressionResult.Value.ToFormulaValue()); await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); - this.AssignTarget(this.Context, variablePath, tableValue); + this.AssignTarget(variablePath, tableValue); } else if (changeType is ClearItemsOperation) { await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false); - this.AssignTarget(this.Context, variablePath, tableValue); + this.AssignTarget(variablePath, tableValue); } else if (changeType is RemoveItemOperation removeItemOperation) { ValueExpression removeItemValue = Throw.IfNull(removeItemOperation.Value, $"{nameof(this.Model)}.{nameof(this.Model.ChangeType)}"); - EvaluationResult result = this.Context.ExpressionEngine.GetValue(removeItemValue, this.Context.Scopes); - if (result.Value.ToFormulaValue() is TableValue removeItemTable) + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(removeItemValue, this.State.Scopes); + if (expressionResult.Value.ToFormulaValue() is TableValue removeItemTable) { await tableValue.RemoveAsync(removeItemTable?.Rows.Select(row => row.Value), all: true, cancellationToken).ConfigureAwait(false); } } else if (changeType is TakeFirstItemOperation) { - this.AssignTarget(this.Context, variablePath, tableValue.Rows.First().Value); // %%% TABLE OR RECORD ??? + this.AssignTarget(variablePath, tableValue.Rows.First().Value); // %%% TABLE OR RECORD ??? } + return default; + static RecordValue BuildRecord(RecordType recordType, FormulaValue value) { return FormulaValue.NewRecordFromFields(recordType, GetValues()); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs index c021186a0f..f70a0c8d22 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs @@ -5,7 +5,6 @@ using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.Interpreter; -using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.PowerFx.Types; @@ -33,7 +32,7 @@ public ForeachExecutor(Foreach model) public bool HasValue { get; private set; } - protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { this._index = 0; @@ -44,14 +43,14 @@ protected override ValueTask ExecuteAsync(IWorkflowContext context, Cancellation } else { - EvaluationResult result = this.Context.ExpressionEngine.GetValue(this.Model.Items, this.Context.Scopes); - if (result.Value is TableDataValue tableValue) + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(this.Model.Items, this.State.Scopes); + if (expressionResult.Value is TableDataValue tableValue) { this._values = [.. tableValue.Values.Select(value => value.Properties.Values.First().ToFormulaValue())]; } else { - this._values = [result.Value.ToFormulaValue()]; + this._values = [expressionResult.Value.ToFormulaValue()]; } } @@ -66,11 +65,11 @@ public void TakeNext() { FormulaValue value = this._values[this._index]; - this.Context.Engine.SetScopedVariable(this.Context.Scopes, Throw.IfNull(this.Model.Value), value); + this.State.Set(Throw.IfNull(this.Model.Value), value); if (this.Model.Index is not null) { - this.Context.Engine.SetScopedVariable(this.Context.Scopes, this.Model.Index.Path, FormulaValue.New(this._index)); + this.State.Set(this.Model.Index.Path, FormulaValue.New(this._index)); } this._index++; @@ -79,10 +78,10 @@ public void TakeNext() public void Reset() { - this.Context.Engine.ClearScopedVariable(this.Context.Scopes, Throw.IfNull(this.Model.Value)); + this.State.Clear(Throw.IfNull(this.Model.Value)); if (this.Model.Index is not null) { - this.Context.Engine.ClearScopedVariable(this.Context.Scopes, this.Model.Index); + this.State.Clear(this.Model.Index); } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs index 74c1bd04ca..d187afb8bb 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs @@ -16,16 +16,16 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; internal sealed class ParseValueExecutor(ParseValue model) : DeclarativeActionExecutor(model) { - protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); ValueExpression valueExpression = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); - EvaluationResult result = this.Context.ExpressionEngine.GetValue(valueExpression, this.Context.Scopes); + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(valueExpression, this.State.Scopes); FormulaValue? parsedResult = null; - if (result.Value is StringDataValue stringValue) + if (expressionResult.Value is StringDataValue stringValue) { if (string.IsNullOrWhiteSpace(stringValue.Value)) { @@ -47,10 +47,10 @@ protected override ValueTask ExecuteAsync(IWorkflowContext context, Cancellation if (parsedResult is null) { - throw new WorkflowExecutionException($"Unable to parse {result.Value.GetType().Name}"); + throw new WorkflowExecutionException($"Unable to parse {expressionResult.Value.GetType().Name}"); } - this.AssignTarget(this.Context, variablePath, parsedResult); + this.AssignTarget(variablePath, parsedResult); return default; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs index 6c321c0b18..6801b1fca8 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs @@ -5,7 +5,6 @@ using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.Interpreter; -using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Shared.Diagnostics; @@ -14,11 +13,11 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; internal sealed class ResetVariableExecutor(ResetVariable model) : DeclarativeActionExecutor(model) { - protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { PropertyPath variablePath = Throw.IfNull(this.Model.Variable, $"{nameof(this.Model)}.{nameof(model.Variable)}"); - this.Context.Engine.ClearScopedVariable(this.Context.Scopes, this.Model.Variable); + this.State.Clear(this.Model.Variable); Debug.WriteLine( $""" !!! CLEAR {this.GetType().Name} [{this.Id}] diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs index 1688b2cb31..dde75dbc78 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs @@ -3,7 +3,6 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Bot.ObjectModel; using Microsoft.Extensions.AI; @@ -13,7 +12,7 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; internal sealed class SendActivityExecutor(SendActivity model) : DeclarativeActionExecutor(model) { - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { if (this.Model.Activity is MessageActivityTemplate messageActivity) { @@ -23,10 +22,12 @@ protected override async ValueTask ExecuteAsync(IWorkflowContext context, Cancel templateBuilder.AppendLine($"\t{messageActivity.Summary}"); } - string? activityText = this.Context.Engine.Format(messageActivity.Text)?.Trim(); + string? activityText = this.State.Format(messageActivity.Text)?.Trim(); templateBuilder.AppendLine(activityText); await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(new ChatMessage(ChatRole.Assistant, templateBuilder.ToString()))).ConfigureAwait(false); } + + return default; } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs index 33484008dc..7ed14802d9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs @@ -2,7 +2,6 @@ using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx.Types; @@ -12,19 +11,19 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; internal sealed class SetTextVariableExecutor(SetTextVariable model) : DeclarativeActionExecutor(model) { - protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); if (this.Model.Value is null) { - this.AssignTarget(this.Context, variablePath, FormulaValue.NewBlank()); + this.AssignTarget(variablePath, FormulaValue.NewBlank()); } else { - FormulaValue result = FormulaValue.New(this.Context.Engine.Format(this.Model.Value)); + FormulaValue expressionResult = FormulaValue.New(this.State.Format(this.Model.Value)); - this.AssignTarget(this.Context, variablePath, result); + this.AssignTarget(variablePath, expressionResult); } return default; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs index f109905190..a9cbe2a3c2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs @@ -13,19 +13,19 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; internal sealed class SetVariableExecutor(SetVariable model) : DeclarativeActionExecutor(model) { - protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); if (this.Model.Value is null) { - this.AssignTarget(this.Context, variablePath, FormulaValue.NewBlank()); + this.AssignTarget(variablePath, FormulaValue.NewBlank()); } else { - EvaluationResult result = this.Context.ExpressionEngine.GetValue(this.Model.Value, this.Context.Scopes); + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(this.Model.Value, this.State.Scopes); - this.AssignTarget(this.Context, variablePath, result.Value.ToFormulaValue()); + this.AssignTarget(variablePath, expressionResult.Value.ToFormulaValue()); } return default; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineExtensions.cs deleted file mode 100644 index 9dbe420d90..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineExtensions.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Bot.ObjectModel; -using Microsoft.PowerFx; -using Microsoft.PowerFx.Types; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.Workflows.Declarative.PowerFx; - -internal static class RecalcEngineExtensions -{ - public static void ClearScope(this RecalcEngine engine, WorkflowScopes scopes, WorkflowScopeType scope) - { - // Clear all scope values. - scopes.Clear(scope); - - // Rebuild scope record and update engine - engine.UpdateScope(scopes, scope); - } - - public static void ClearScopedVariable(this RecalcEngine engine, WorkflowScopes scopes, PropertyPath variablePath) => - engine.ClearScopedVariable(scopes, WorkflowScopeType.Parse(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName)); - - public static void ClearScopedVariable(this RecalcEngine engine, WorkflowScopes scopes, WorkflowScopeType scope, string varName) - { - // Clear value. - scopes.Remove(varName, scope); - - // Rebuild scope record and update engine - engine.UpdateScope(scopes, scope); - } - - public static void SetScopedVariable(this RecalcEngine engine, WorkflowScopes scopes, PropertyPath variablePath, FormulaValue value) => - engine.SetScopedVariable(scopes, WorkflowScopeType.Parse(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName), value); - - public static void SetScopedVariable(this RecalcEngine engine, WorkflowScopes scopes, WorkflowScopeType scope, string varName, FormulaValue value) - { - // Assign value. - scopes.Set(varName, scope, value); - - // Rebuild scope record and update engine - engine.UpdateScope(scopes, scope); - } - - public static void AssignScope(this RecalcEngine engine, string scopeName, RecordValue scopeRecord) - { - engine.DeleteFormula(scopeName); - engine.UpdateVariable(scopeName, scopeRecord); - } - - private static void UpdateScope(this RecalcEngine engine, WorkflowScopes scopes, WorkflowScopeType scope) - { - RecordValue scopeRecord = scopes.BuildRecord(scope); - engine.AssignScope(scope.Name, scopeRecord); - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs index 6f14c81eeb..30372c4650 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx; using Microsoft.PowerFx.Types; @@ -8,25 +9,18 @@ namespace Microsoft.Agents.Workflows.Declarative.PowerFx; internal static class RecalcEngineFactory { public static RecalcEngine Create( - WorkflowScopes scopes, int? maximumExpressionLength = null, int? maximumCallDepth = null) { RecalcEngine engine = new(CreateConfig()); - SetScope(WorkflowScopeType.Topic); - SetScope(WorkflowScopeType.Global); - SetScope(WorkflowScopeType.Env); - SetScope(WorkflowScopeType.System); - - return engine; - - void SetScope(WorkflowScopeType scope) + foreach (string scopeName in VariableScopeNames.AllScopes) { - RecordValue record = scopes.BuildRecord(scope); - engine.UpdateVariable(scope.Name, record); + engine.UpdateVariable(scopeName, FormulaValue.NewBlank()); } + return engine; + PowerFxConfig CreateConfig() { PowerFxConfig config = new(Features.PowerFxV1); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs index 34fcc1c8ec..21b185846f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs @@ -22,39 +22,39 @@ public WorkflowExpressionEngine(RecalcEngine engine) this._engine = engine; } - public EvaluationResult GetValue(BoolExpression boolean, WorkflowScopes state) => this.GetValue(boolean, state, this.EvaluateScope); + public EvaluationResult GetValue(BoolExpression boolean, WorkflowScopes? state = null) => this.GetValue(boolean, state, this.EvaluateScope); public EvaluationResult GetValue(BoolExpression boolean, RecordDataValue state) => this.GetValue(boolean, state, this.EvaluateState); - public EvaluationResult GetValue(StringExpression expression, WorkflowScopes state) => this.GetValue(expression, state, this.EvaluateScope); + public EvaluationResult GetValue(StringExpression expression, WorkflowScopes? state = null) => this.GetValue(expression, state, this.EvaluateScope); public EvaluationResult GetValue(StringExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); - public EvaluationResult GetValue(ValueExpression expression, WorkflowScopes state) => this.GetValue(expression, state, this.EvaluateScope); + public EvaluationResult GetValue(ValueExpression expression, WorkflowScopes? state = null) => this.GetValue(expression, state, this.EvaluateScope); public EvaluationResult GetValue(ValueExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); - public EvaluationResult GetValue(IntExpression expression, WorkflowScopes state) => this.GetValue(expression, state, this.EvaluateScope); + public EvaluationResult GetValue(IntExpression expression, WorkflowScopes? state = null) => this.GetValue(expression, state, this.EvaluateScope); public EvaluationResult GetValue(IntExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); - public EvaluationResult GetValue(NumberExpression expression, WorkflowScopes state) => this.GetValue(expression, state, this.EvaluateScope); + public EvaluationResult GetValue(NumberExpression expression, WorkflowScopes? state = null) => this.GetValue(expression, state, this.EvaluateScope); public EvaluationResult GetValue(NumberExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); - public EvaluationResult GetValue(ObjectExpression expression, WorkflowScopes state) where TValue : BotElement => this.GetValue(expression, state, this.EvaluateScope); + public EvaluationResult GetValue(ObjectExpression expression, WorkflowScopes? state = null) where TValue : BotElement => this.GetValue(expression, state, this.EvaluateScope); public EvaluationResult GetValue(ObjectExpression expression, RecordDataValue state) where TValue : BotElement => this.GetValue(expression, state, this.EvaluateState); - public ImmutableArray GetValue(ArrayExpression expression, WorkflowScopes state) => this.GetValue(expression, state, this.EvaluateScope).Value; + public ImmutableArray GetValue(ArrayExpression expression, WorkflowScopes? state = null) => this.GetValue(expression, state, this.EvaluateScope).Value; public ImmutableArray GetValue(ArrayExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState).Value; - public ImmutableArray GetValue(ArrayExpressionOnly expression, WorkflowScopes state) => this.GetValue(expression, state, this.EvaluateScope).Value; + public ImmutableArray GetValue(ArrayExpressionOnly expression, WorkflowScopes? state = null) => this.GetValue(expression, state, this.EvaluateScope).Value; public ImmutableArray GetValue(ArrayExpressionOnly expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState).Value; - public EvaluationResult GetValue(EnumExpression expression, WorkflowScopes state) where TValue : EnumWrapper => + public EvaluationResult GetValue(EnumExpression expression, WorkflowScopes? state = null) where TValue : EnumWrapper => this.GetValue(expression, state, this.EvaluateScope); public EvaluationResult GetValue(EnumExpression expression, RecordDataValue state) where TValue : EnumWrapper => @@ -181,7 +181,7 @@ private EvaluationResult GetValue(NumberExpression expression, T return new EvaluationResult(formulaValue.Value, expressionResult.Sensitivity); } - private EvaluationResult GetValue(ValueExpression expression, TState state, Func> evaluator) + private EvaluationResult GetValue(ValueExpression expression, TState? state, Func> evaluator) { Throw.IfNull(expression, nameof(expression)); @@ -195,7 +195,7 @@ private EvaluationResult GetValue(ValueExpression expression, return new EvaluationResult(expressionResult.Value.ToDataValue(), expressionResult.Sensitivity); } - private EvaluationResult GetValue(EnumExpression expression, TState state, Func> evaluator) where TValue : EnumWrapper + private EvaluationResult GetValue(EnumExpression expression, TState? state, Func> evaluator) where TValue : EnumWrapper { Throw.IfNull(expression, nameof(expression)); @@ -302,25 +302,31 @@ private static ImmutableArray ParseArrayResults(FormulaValue val } } - private EvaluationResult EvaluateState(ExpressionBase expression, RecordDataValue state) + private EvaluationResult EvaluateState(ExpressionBase expression, RecordDataValue? state) { - foreach (KeyValuePair kvp in state.Properties) + if (state is not null) { - if (kvp.Value is RecordDataValue scopeRecord) + foreach (KeyValuePair kvp in state.Properties) { - this._engine.AssignScope(kvp.Key, scopeRecord.ToRecordValue()); + if (kvp.Value is RecordDataValue scopeRecord) + { + Bind(kvp.Key, scopeRecord.ToRecordValue()); + } } } return this.Evaluate(expression); + + void Bind(string scopeName, RecordValue stateRecord) + { + this._engine.DeleteFormula(scopeName); + this._engine.UpdateVariable(scopeName, stateRecord); + } } - private EvaluationResult EvaluateScope(ExpressionBase expression, WorkflowScopes state) + private EvaluationResult EvaluateScope(ExpressionBase expression, WorkflowScopes? state = null) { - this._engine.AssignScope(WorkflowScopeType.System.Name, state.BuildRecord(WorkflowScopeType.System)); - this._engine.AssignScope(WorkflowScopeType.Env.Name, state.BuildRecord(WorkflowScopeType.Env)); - this._engine.AssignScope(WorkflowScopeType.Global.Name, state.BuildRecord(WorkflowScopeType.Global)); - this._engine.AssignScope(WorkflowScopeType.Topic.Name, state.BuildRecord(WorkflowScopeType.Topic)); + state?.Bind(this._engine); return this.Evaluate(expression); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs index 3ba8ccc711..625d2024f8 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx; using Microsoft.PowerFx.Types; namespace Microsoft.Agents.Workflows.Declarative.PowerFx; @@ -43,6 +44,28 @@ IEnumerable> BuildStateFields() } } + public void Bind(RecalcEngine engine, WorkflowScopeType? type = null) + { + if (type is not null) + { + Bind(type); + } + else + { + Bind(WorkflowScopeType.Topic); + Bind(WorkflowScopeType.Global); + Bind(WorkflowScopeType.Env); + Bind(WorkflowScopeType.System); + } + + void Bind(WorkflowScopeType scope) + { + RecordValue scopeRecord = this.BuildRecord(scope); + engine.DeleteFormula(scope.Name); + engine.UpdateVariable(scope.Name, scopeRecord); + } + } + public FormulaValue Get(string name, WorkflowScopeType? type = null) { if (this._scopes[type ?? WorkflowScopeType.Topic].TryGetValue(name, out FormulaValue? value)) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs index eb5f64715f..d715f1308f 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs @@ -14,7 +14,7 @@ public class DeclarativeWorkflowContextTests public void DefaultHasExpectedValues() { // Assert - DeclarativeWorkflowContext context = DeclarativeWorkflowContext.Default; + DeclarativeWorkflowOptions context = DeclarativeWorkflowOptions.Default; Assert.Equal(string.Empty, context.ProjectEndpoint); Assert.IsType(context.ProjectCredentials); Assert.Null(context.MaximumCallDepth); @@ -27,7 +27,7 @@ public void DefaultHasExpectedValues() public void InitializeDefaultValues() { // Act - DeclarativeWorkflowContext context = new(); + DeclarativeWorkflowOptions context = new(); // Assert Assert.Equal(string.Empty, context.ProjectEndpoint); @@ -50,7 +50,7 @@ public void InitializeExplicitValues() ILoggerFactory loggerFactory = LoggerFactory.Create(builder => { }); // Act - DeclarativeWorkflowContext context = new() + DeclarativeWorkflowOptions context = new() { ProjectEndpoint = projectEndpoint, ProjectCredentials = credentials, diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index 1844e7a8ad..7354b050c8 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -196,7 +196,7 @@ public void UnsupportedAction(Type type) }; WorkflowScopes scopes = new(); - DeclarativeWorkflowContext workflowContext = DeclarativeWorkflowContext.Default; + DeclarativeWorkflowOptions workflowContext = DeclarativeWorkflowOptions.Default; WorkflowActionVisitor visitor = new(new RootExecutor(), workflowContext); WorkflowElementWalker walker = new(dialogBuilder.Build(), visitor); Assert.True(visitor.HasUnsupportedActions); @@ -230,7 +230,7 @@ private void AssertMessage(string message) private async Task RunWorkflow(string workflowPath, TInput workflowInput) where TInput : notnull { using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath)); - DeclarativeWorkflowContext workflowContext = new() { LoggerFactory = this.Output }; + DeclarativeWorkflowOptions workflowContext = new() { LoggerFactory = this.Output }; Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs index 06f8519104..16464adb20 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs @@ -27,7 +27,6 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor internal async Task Execute(WorkflowActionExecutor executor) { - WorkflowExecutionContext context = new(RecalcEngineFactory.Create(this.Scopes), this.Scopes); TestWorkflowExecutor workflowExecutor = new(); WorkflowBuilder workflowBuilder = new(workflowExecutor); workflowBuilder.AddEdge(workflowExecutor, executor); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Interpreter/WorkflowModelTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Interpreter/WorkflowModelTest.cs index 5aab294ab7..fe9af0c044 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Interpreter/WorkflowModelTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Interpreter/WorkflowModelTest.cs @@ -9,21 +9,21 @@ namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Interpreter; /// -/// Tests execution of workflow created by . +/// Tests execution of workflow created by . /// public sealed class DeclarativeWorkflowModelTest(ITestOutputHelper output) : WorkflowTest(output) { [Fact] public async Task GetDepthForDefault() { - WorkflowModel model = new(this.CreateExecutor("root")); + DeclarativeWorkflowModel model = new(this.CreateExecutor("root")); Assert.Equal(0, model.GetDepth(null)); } [Fact] public async Task GetDepthForMissingNode() { - WorkflowModel model = new(this.CreateExecutor("root")); + DeclarativeWorkflowModel model = new(this.CreateExecutor("root")); Assert.Throws(() => model.GetDepth("missing")); } @@ -31,7 +31,7 @@ public async Task GetDepthForMissingNode() public async Task ConnectMissingNode() { TestExecutor rootExecutor = this.CreateExecutor("root"); - WorkflowModel model = new(rootExecutor); + DeclarativeWorkflowModel model = new(rootExecutor); model.AddLink("root", "missing"); WorkflowBuilder workflowBuilder = new(rootExecutor); Assert.Throws(() => model.ConnectNodes(workflowBuilder)); @@ -40,21 +40,21 @@ public async Task ConnectMissingNode() [Fact] public async Task AddToMissingParent() { - WorkflowModel model = new(this.CreateExecutor("root")); + DeclarativeWorkflowModel model = new(this.CreateExecutor("root")); Assert.Throws(() => model.AddNode(this.CreateExecutor("next"), "missing")); } [Fact] public async Task LinkFromMissingSource() { - WorkflowModel model = new(this.CreateExecutor("root")); + DeclarativeWorkflowModel model = new(this.CreateExecutor("root")); Assert.Throws(() => model.AddLink("missing", "anything")); } [Fact] public async Task LocateMissingParent() { - WorkflowModel model = new(this.CreateExecutor("root")); + DeclarativeWorkflowModel model = new(this.CreateExecutor("root")); Assert.Null(model.LocateParent(null)); Assert.Throws(() => model.LocateParent("missing")); } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs index b274142ccb..5aa491ce9d 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs @@ -49,7 +49,7 @@ public void HasSetFunctionEnabled() public void HasCorrectMaximumExpressionLength() { // Arrange - RecalcEngine engine = RecalcEngineFactory.Create(this.Scopes, 2000, 3); + RecalcEngine engine = RecalcEngineFactory.Create(2000, 3); // Assert Assert.Equal(2000, engine.Config.MaximumExpressionLength); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineTest.cs index 022baf481b..db849323aa 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineTest.cs @@ -13,5 +13,5 @@ public abstract class RecalcEngineTest(ITestOutputHelper output) : WorkflowTest( { internal WorkflowScopes Scopes { get; } = new(); - protected RecalcEngine CreateEngine(int maximumExpressionLength = 500) => RecalcEngineFactory.Create(this.Scopes, maximumExpressionLength); + protected RecalcEngine CreateEngine(int maximumExpressionLength = 500) => RecalcEngineFactory.Create(maximumExpressionLength); } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs index 9286ccbced..6fa9667143 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs @@ -110,6 +110,7 @@ public void FormatVariableSegment() ExpressionSegment expressionSegment = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source"))); TemplateLine line = new([expressionSegment]); RecalcEngine engine = this.CreateEngine(); + this.Scopes.Bind(engine); // Act string? result = engine.Format(line); @@ -118,29 +119,6 @@ public void FormatVariableSegment() Assert.Equal("Hello World", result); } - //[Fact] - //public void Format_WithExpressionSegmentWithVariableReference_ReturnsEvaluatedValue() - //{ - // // Arrange - // Mock mockVariableRef = new(); - // mockVariableRef.Setup(vr => vr.ToString()).Returns("myVariable"); - - // Expression expression = new() { VariableReference = mockVariableRef.Object }; - // ExpressionSegment expressionSegment = new() { Expression = expression }; - // TemplateLine line = new([expressionSegment]); - - // _mockFormulaValue.Setup(fv => fv.Format()).Returns("VariableValue"); - // _mockEngine.Setup(e => e.Eval("myVariable")).Returns(_mockFormulaValue.Object); - - // // Act - // string? result = engine.Format(line); - - // // Assert - // Assert.Equal("VariableValue", result); - // _mockEngine.Verify(e => e.Eval("myVariable"), Times.Once); - // _mockFormulaValue.Verify(fv => fv.Format(), Times.Once); - //} - [Fact] public void FormatExpressionSegmentUndefined() { diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs index 5b89b958cc..e3c3c0ed57 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs @@ -417,26 +417,6 @@ public void EnumExpressionGetValueForFormula() expectedValue: VariablesToClear.ConversationScopedVariables); } - //// Enum Expression Tests - //[Fact] - //public void GetValueForEnumExpressionWithLiteral() - //{ - // // Arrange - // RecalcEngine engine = this.CreateEngine(); - // WorkflowExpressionEngine expressionEngine = new(engine); - // WorkflowScopes scopes = new(); - - // TestEnum testEnum = TestEnum.Create(TestEnumValue.Value1); - // EnumExpression expression = new EnumExpression(testEnum); - - // // Act - // EvaluationResult result = expressionEngine.GetValue(expression, scopes); - - // // Assert - // Assert.Equal(TestEnumValue.Value1, result.Value.Value); - // Assert.Equal(SensitivityLevel.None, result.Sensitivity); - //} - #endregion #region ObjectExpression Tests From 57919420089e3f89be79b60b6741935f1b0360c1 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 18 Aug 2025 12:19:22 -0700 Subject: [PATCH 165/232] State clean-up --- .../Interpreter/DeclarativeWorkflowState.cs | 10 ++++++---- .../ObjectModel/AnswerQuestionWithAIExecutor.cs | 4 ++-- .../ObjectModel/ClearAllVariablesExecutor.cs | 2 +- .../ObjectModel/ConditionGroupExecutor.cs | 2 +- .../ObjectModel/EditTableV2Executor.cs | 7 +++---- .../ObjectModel/ForeachExecutor.cs | 2 +- .../ObjectModel/ParseValueExecutor.cs | 2 +- .../ObjectModel/SetVariableExecutor.cs | 2 +- .../PowerFx/WorkflowExpressionEngineTests.cs | 4 ++-- 9 files changed, 18 insertions(+), 17 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs index c959f1d767..c0943fb6b3 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs @@ -23,10 +23,6 @@ public DeclarativeWorkflowState(RecalcEngine engine, WorkflowScopes? scopes = nu this._scopes.Bind(this._engine); } - // %%% TODO: IWorkflowContext - - public WorkflowScopes Scopes => this._scopes; // %%% NOT PUBLIC - public WorkflowExpressionEngine ExpressionEngine => this._expressionEngine ??= new WorkflowExpressionEngine(this._engine); public void Clear(PropertyPath variablePath) => @@ -46,6 +42,12 @@ public void Clear(WorkflowScopeType scope, string? varName = null) this._scopes.Bind(this._engine, scope); } + public FormulaValue Get(PropertyPath variablePath) => + this.Get(WorkflowScopeType.Parse(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName)); + + public FormulaValue Get(WorkflowScopeType scope, string varName) => + this._scopes.Get(varName, scope); + public void Set(PropertyPath variablePath, FormulaValue value) => this.Set(WorkflowScopeType.Parse(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName), value); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs index f304da9311..071e268374 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs @@ -30,7 +30,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P string? userInput = null; if (this.Model.UserInput is not null) { - EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(userInputExpression, this.State.Scopes); + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(userInputExpression); userInput = expressionResult.Value; } @@ -47,7 +47,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P // await agent.RunAsync(thread: null, options, cancellationToken).ConfigureAwait(false); AgentThread? thread = null; // %%% HAXX: SYSTEM THREAD - FormulaValue conversationValue = this.State.Scopes.Get("ConversationId", WorkflowScopeType.System); + FormulaValue conversationValue = this.State.Get(WorkflowScopeType.System, "ConversationId"); if (conversationValue is StringValue stringValue) { thread = new AgentThread() { ConversationId = stringValue.Value }; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs index b001ce6d72..f6add1051c 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs @@ -13,7 +13,7 @@ internal sealed class ClearAllVariablesExecutor(ClearAllVariables model) : Decla { protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - EvaluationResult variablesResult = this.State.ExpressionEngine.GetValue(this.Model.Variables, this.State.Scopes); + EvaluationResult variablesResult = this.State.ExpressionEngine.GetValue(this.Model.Variables); variablesResult.Value.Handle(new ScopeHandler(this.State)); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs index 5faf843634..5ba73fc40b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs @@ -63,7 +63,7 @@ public bool IsElse(object? result) continue; // Skip if no condition is defined } - EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(conditionItem.Condition, this.State.Scopes); + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(conditionItem.Condition); if (expressionResult.Value) { return Steps.Item(this.Model, conditionItem); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs index 925009ce21..5c740da9d7 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs @@ -6,7 +6,6 @@ using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.Interpreter; -using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.PowerFx.Types; @@ -20,7 +19,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model) : DeclarativeAction { PropertyPath variablePath = Throw.IfNull(this.Model.ItemsVariable?.Path, $"{nameof(this.Model)}.{nameof(this.Model.ItemsVariable)}"); - FormulaValue table = this.State.Scopes.Get(variablePath.VariableName!, WorkflowScopeType.Parse(variablePath.VariableScopeName)); + FormulaValue table = this.State.Get(variablePath); if (table is not TableValue tableValue) { throw new WorkflowExecutionException($"Require '{variablePath.Format()}' to be a table, not: '{table.GetType().Name}'."); @@ -30,7 +29,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model) : DeclarativeAction if (changeType is AddItemOperation addItemOperation) { ValueExpression addItemValue = Throw.IfNull(addItemOperation.Value, $"{nameof(this.Model)}.{nameof(this.Model.ChangeType)}"); - EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(addItemValue, this.State.Scopes); + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(addItemValue); RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), expressionResult.Value.ToFormulaValue()); await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); this.AssignTarget(variablePath, tableValue); @@ -43,7 +42,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model) : DeclarativeAction else if (changeType is RemoveItemOperation removeItemOperation) { ValueExpression removeItemValue = Throw.IfNull(removeItemOperation.Value, $"{nameof(this.Model)}.{nameof(this.Model.ChangeType)}"); - EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(removeItemValue, this.State.Scopes); + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(removeItemValue); if (expressionResult.Value.ToFormulaValue() is TableValue removeItemTable) { await tableValue.RemoveAsync(removeItemTable?.Rows.Select(row => row.Value), all: true, cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs index f70a0c8d22..6b26e15a61 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs @@ -43,7 +43,7 @@ public ForeachExecutor(Foreach model) } else { - EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(this.Model.Items, this.State.Scopes); + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(this.Model.Items); if (expressionResult.Value is TableDataValue tableValue) { this._values = [.. tableValue.Values.Select(value => value.Properties.Values.First().ToFormulaValue())]; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs index d187afb8bb..5e3ab297db 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs @@ -21,7 +21,7 @@ internal sealed class ParseValueExecutor(ParseValue model) : PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); ValueExpression valueExpression = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); - EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(valueExpression, this.State.Scopes); + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(valueExpression); FormulaValue? parsedResult = null; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs index a9cbe2a3c2..4ebf29806c 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs @@ -23,7 +23,7 @@ internal sealed class SetVariableExecutor(SetVariable model) : DeclarativeAction } else { - EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(this.Model.Value, this.State.Scopes); + EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(this.Model.Value); this.AssignTarget(variablePath, expressionResult.Value.ToFormulaValue()); } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs index e3c3c0ed57..27634e2ae3 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs @@ -179,7 +179,7 @@ public void StringExpressionGetValueForFormula() expectedValue: "AB"); } - //[Fact] + //[Fact] // %%% TEST COVERAGE //public void GetValueForStringExpressionWithRecordDataValue() //{ // // Arrange @@ -469,7 +469,7 @@ public void ObjectExpressionGetValueForVariable(bool useState) useState); } - //[Fact] // %%% TODO: TEST COVERAGE + //[Fact] // %%% TEST COVERAGE //public void ObjectExpressionGetValueForFormula() //{ // // Arrange, Act & Assert From 919bdc3494596096f7694467a69999e0f2b98806 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 18 Aug 2025 15:42:10 -0700 Subject: [PATCH 166/232] Sample updated --- dotnet/agent-framework-dotnet.slnx | 2 + .../DeclarativeWorkflow.csproj | 9 +- .../HttpInterceptHandler.cs | 46 ++++++ dotnet/demos/DeclarativeWorkflow/Program.cs | 108 +++++++++++-- .../Properties/launchSettings.json | 15 +- .../demos/DeclarativeWorkflow/demo250729.yaml | 52 ------ .../Workflows/Workflows_Declarative.cs | 150 ++++++------------ .../GettingStarted/Workflows/testChat.yaml | 16 -- .../Workflows/testCondition0.yaml | 70 -------- .../Workflows/testCondition1.yaml | 84 ---------- .../ObjectModel/SendActivityExecutor.cs | 2 +- .../Microsoft.Agents.Workflows/Executor.cs | 2 +- .../ExecutorFailureEvent.cs | 9 +- workflows/HelloWorld.yaml | 10 ++ workflows/MathChat.yaml | 7 +- workflows/Question.yaml | 2 +- 16 files changed, 225 insertions(+), 359 deletions(-) create mode 100644 dotnet/demos/DeclarativeWorkflow/HttpInterceptHandler.cs delete mode 100644 dotnet/demos/DeclarativeWorkflow/demo250729.yaml delete mode 100644 dotnet/samples/GettingStarted/Workflows/testChat.yaml delete mode 100644 dotnet/samples/GettingStarted/Workflows/testCondition0.yaml delete mode 100644 dotnet/samples/GettingStarted/Workflows/testCondition1.yaml create mode 100644 workflows/HelloWorld.yaml diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index bb465db31c..c579199313 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -19,7 +19,9 @@ + + diff --git a/dotnet/demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj b/dotnet/demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj index 6d79ff0350..e0f7052964 100644 --- a/dotnet/demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj +++ b/dotnet/demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj @@ -2,7 +2,8 @@ Exe - $(ProjectsTargetFrameworks) + net9.0 + net9.0 $(ProjectsDebugTargetFrameworks) 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 @@ -26,10 +27,4 @@ - - - Always - - - diff --git a/dotnet/demos/DeclarativeWorkflow/HttpInterceptHandler.cs b/dotnet/demos/DeclarativeWorkflow/HttpInterceptHandler.cs new file mode 100644 index 0000000000..12c997ffee --- /dev/null +++ b/dotnet/demos/DeclarativeWorkflow/HttpInterceptHandler.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +#if NET + +using System; +using System.Diagnostics; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Demo.DeclarativeWorkflow; + +internal sealed record class HttpResponseIntercept(HttpMethod RequestMethod, Uri? RequestUri, string? ResponseContent); + +internal sealed class HttpInterceptHandler : HttpClientHandler +{ + public Func? OnIntercept { get; set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // Call the inner handler to process the request and get the response + HttpResponseMessage response = await base.SendAsync(request, cancellationToken); + + // Intercept and modify the response + Debug.WriteLine($"{request.Method} {request.RequestUri}"); + string? responseContent = null; + if (response.Content != null) + { + responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + + response.Content = new StringContent(responseContent); + + Debug.WriteLine($"API:{Environment.NewLine}" + responseContent); + } + + if (this.OnIntercept is not null) + { + // Invoke the intercept callback if it is set + await this.OnIntercept(new HttpResponseIntercept(request.Method, request.RequestUri, responseContent)).ConfigureAwait(false); + } + + return response; + } +} + +#endif diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index e54b708a82..2c770513b5 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Net.Http; using System.Reflection; using System.Threading.Tasks; using Azure.Identity; @@ -17,12 +18,22 @@ namespace Demo.DeclarativeWorkflow; internal static class Program { + private const string DefaultWorkflow = "HelloWorld.yaml"; + private const string HttpEventFileName = "http.log"; + public static async Task Main(string[] args) { + string workflowFile = GetWorkflowFile(args); + // Load configuration and create kernel with Azure OpenAI Chat Completion service IConfiguration config = InitializeConfig(); - Notify("PROCESS INIT\n"); + // Create custom HTTP client with intercept handler + await using StreamWriter eventWriter = new(HttpEventFileName, append: false); + using HttpClient customClient = new(new HttpInterceptHandler() { OnIntercept = OnHttpIntercept, CheckCertificateRevocationList = true }, disposeHandler: true); + + // Read and parse the declarative workflow. + Notify("PROCESS INIT"); Stopwatch timer = Stopwatch.StartNew(); @@ -30,13 +41,14 @@ public static async Task Main(string[] args) // // HOW TO: Create a workflow from a YAML file. // - using StreamReader yamlReader = File.OpenText(args.FirstOrDefault() ?? "demo250729.yaml"); + using StreamReader yamlReader = File.OpenText(workflowFile); // // DeclarativeWorkflowContext provides the components for workflow execution. // DeclarativeWorkflowContext workflowContext = new() { + HttpClient = customClient, LoggerFactory = NullLoggerFactory.Instance, ProjectEndpoint = Throw.IfNull(config["AzureAI:Endpoint"]), ProjectCredentials = new AzureCliCredential(), @@ -48,14 +60,14 @@ public static async Task Main(string[] args) // ////////////////////////////////////////////////////// - Notify($"PROCESS DEFINED: {timer.Elapsed}\n"); + Notify($"\nPROCESS DEFINED: {timer.Elapsed}"); - Notify("PROCESS INVOKE\n"); + Notify("\nPROCESS INVOKE"); ////////////////////////////////////////////// // Run the workflow, just like any other workflow string? messageId = null; - StreamingRun run = await InProcessExecution.StreamAsync(workflow, "What is the formula for fibbinocci sequence"); + StreamingRun run = await InProcessExecution.StreamAsync(workflow, GetWorkflowInput(args)); await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { if (evt is ExecutorInvokeEvent executorInvoked) @@ -66,6 +78,10 @@ public static async Task Main(string[] args) { Debug.WriteLine($"!!! EXIT #{executorComplete.ExecutorId}"); } + else if (evt is ExecutorFailureEvent executorFailure) + { + Debug.WriteLine($"!!! ERROR #{executorFailure.ExecutorId}: {executorFailure.Data?.Message ?? "Unknown"}"); + } else if (evt is DeclarativeWorkflowStreamEvent streamEvent) { if (!string.Equals(messageId, streamEvent.Data.MessageId, StringComparison.Ordinal)) @@ -84,11 +100,6 @@ public static async Task Main(string[] args) { Console.ForegroundColor = ConsoleColor.Gray; Console.Write(streamEvent.Data); - //if (streamEvent.Usage is not null) - //{ - // Console.ForegroundColor = ConsoleColor.DarkGray; - // Console.WriteLine($"[Tokens Total: {streamEvent.Usage.TotalTokenCount}, Input: {streamEvent.Usage.InputTokenCount}, Output: {streamEvent.Usage.OutputTokenCount}]"); - //} } finally { @@ -99,19 +110,18 @@ public static async Task Main(string[] args) { try { - Console.WriteLine(Environment.NewLine); - + Console.WriteLine(); if (messageEvent.Data.MessageId is null) { Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine(messageEvent.Data); + Console.WriteLine(messageEvent.Data?.Text.Trim()); } else { Console.ForegroundColor = ConsoleColor.White; Console.WriteLine($"#{messageEvent.Data.MessageId}:"); Console.ForegroundColor = ConsoleColor.DarkGreen; - Console.WriteLine(messageEvent.Data); + Console.WriteLine(messageEvent.Data?.Text.Trim()); if (messageEvent.Usage is not null) { Console.ForegroundColor = ConsoleColor.DarkGray; @@ -127,7 +137,75 @@ public static async Task Main(string[] args) } ////////////////////////////////////////////// - Notify("PROCESS DONE"); + Notify("\nPROCESS DONE"); + + string GetWorkflowInput(string[] args) + { + string? input = GetWorkflowInputs(args).FirstOrDefault(); + + try + { + Console.ForegroundColor = ConsoleColor.Yellow; + + Console.Write("\nINPUT: "); + + if (!string.IsNullOrWhiteSpace(input)) + { + Console.WriteLine(input); + return input; + } + while (string.IsNullOrWhiteSpace(input)) + { + input = Console.ReadLine(); + } + + Console.WriteLine(); + + return input.Trim(); + } + finally + { + Console.ResetColor(); + } + } + + ValueTask OnHttpIntercept(HttpResponseIntercept intercept) + { + eventWriter.WriteLine($"{intercept.RequestMethod} {intercept.RequestUri}"); + if (intercept.ResponseContent is not null) + { + eventWriter.WriteLine($"API:{Environment.NewLine}" + intercept.ResponseContent); + } + return default; + } + } + + private static string GetWorkflowFile(string[] args) + { + string workflowFile = args.FirstOrDefault() ?? DefaultWorkflow; + if (!File.Exists(workflowFile) && !Path.IsPathFullyQualified(workflowFile)) + { + workflowFile = Path.Combine(@"..\..\..\..\..\..\Workflows", workflowFile); + } + + if (!File.Exists(workflowFile)) + { + throw new InvalidOperationException($"Unable to locate workflow: {Path.GetFullPath(workflowFile)}."); + } + + return workflowFile; + } + + private static string[] GetWorkflowInputs(string[] args) + { + if (args.Length == 0) + { + return []; + } + + string[] workflowInput = [.. args.Skip(1)]; + + return workflowInput; } // Load configuration from user-secrets diff --git a/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json b/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json index a35c14e927..855cdf26f2 100644 --- a/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json +++ b/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json @@ -1,15 +1,20 @@ { "profiles": { - "Default": { - "commandName": "Project" + "HelloWorld": { + "commandName": "Project", + "commandLineArgs": "\"HelloWorld.yaml\" \"HELLO WORLD!\"" }, - "MathChat": { + "Interactive": { "commandName": "Project", - "commandLineArgs": "\"C:\\Users\\crickman\\source\\repos\\af5\\workflows\\MathChat.yaml\"" + "commandLineArgs": "\"Question.yaml\"" }, "Question": { "commandName": "Project", - "commandLineArgs": "\"C:\\Users\\crickman\\source\\repos\\af4\\workflows\\Question.yaml\"" + "commandLineArgs": "\"Question.yaml\" \"Why is the sky blue?\"" + }, + "MathChat": { + "commandName": "Project", + "commandLineArgs": "\"MathChat.yaml\" \"What is the formula for fibbinocci sequence?\"" } } } \ No newline at end of file diff --git a/dotnet/demos/DeclarativeWorkflow/demo250729.yaml b/dotnet/demos/DeclarativeWorkflow/demo250729.yaml deleted file mode 100644 index f086b4912a..0000000000 --- a/dotnet/demos/DeclarativeWorkflow/demo250729.yaml +++ /dev/null @@ -1,52 +0,0 @@ -kind: AdaptiveDialog -beginDialog: - kind: OnActivity - id: activity_xyz123 - actions: - - # Capture optional agent instructions - - kind: SetVariable - id: setVariable_NZ2u0l - variable: Topic.Instructions - value: =System.LastMessage.Text - - # Assign a list of inputs in JSON format to a variable - - kind: SetVariable - id: setVariable_aASlmF - displayName: List all of questions for LLM - variable: Topic.Questions - value: |- - =[ - "Why is the sky blue?", - "What is the capital of France?", - "Where do rainbows come from?", - ] - - # Loop over each question in the list - - kind: Foreach - id: foreach_mVIecC - items: =Topic.Questions - index: Topic.LoopIndex - value: Topic.Question - actions: - - # Display the current question - - kind: SendActivity - id: sendActivity_lMn07p - activity: "Question {Topic.LoopIndex + 1} - {Topic.Question}" - - # Use AI to answer the question - - kind: AnswerQuestionWithAI - id: asst_Tq5lpaIHgIbqQSKIpGxNq6A5 - variable: Topic.Answer - userInput: =Topic.Question - additionalInstructions: "{Topic.Instructions}" - - # After processing all questions, display a completion message - - kind: SendActivity - id: sendActivity_SVoNSV - activity: Complete! - - # End the conversation - - kind: EndConversation - id: end_8nXE8H diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index 9849f7ab1d..0fc2e89d9f 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -1,7 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -#if NET - using System.Diagnostics; using Azure.Identity; using Microsoft.Agents.Orchestration; @@ -18,125 +16,67 @@ namespace Workflows; /// public class Workflows_Declarative(ITestOutputHelper output) : OrchestrationSample(output) { - private const bool EnableApiIntercept = false; - [Theory] - [InlineData("deepResearch")] - [InlineData("testChat", true)] - [InlineData("testCondition0")] - [InlineData("testCondition1")] [InlineData("testExpression")] //[InlineData("testTopic")] - public async Task RunWorkflow(string fileName, bool enableApiIntercept = false) + public async Task RunWorkflow(string fileName) { - HttpClient? customClient = null; - try + Debug.WriteLine("WORKFLOW INIT\n"); + + ////////////////////////////////////////////////////// + // + // HOW TO: Create a workflow from a YAML file. + // + using StreamReader yamlReader = File.OpenText(@$"{nameof(Workflows)}\{fileName}.yaml"); + // + // DeclarativeWorkflowContext provides the components for workflow execution. + // + DeclarativeWorkflowContext workflowContext = + new() + { + LoggerFactory = this.LoggerFactory, + ProjectEndpoint = Throw.IfNull(TestConfiguration.AzureAI.Endpoint), + ProjectCredentials = new AzureCliCredential(), + }; + // + // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. + // + Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); + // + ////////////////////////////////////////////////////// + + Debug.WriteLine("\nWORKFLOW INVOKE\n"); + + StreamingRun run = await InProcessExecution.StreamAsync(workflow, ""); + await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (enableApiIntercept || EnableApiIntercept) + if (evt is ExecutorInvokeEvent executorInvoked) { - customClient = new(new InterceptHandler(), disposeHandler: true); + Debug.WriteLine($"!!! ENTER #{executorInvoked.ExecutorId}"); } - - Debug.WriteLine("WORKFLOW INIT\n"); - - ////////////////////////////////////////////////////// - // - // HOW TO: Create a workflow from a YAML file. - // - using StreamReader yamlReader = File.OpenText(@$"{nameof(Workflows)}\{fileName}.yaml"); - // - // DeclarativeWorkflowContext provides the components for workflow execution. - // - DeclarativeWorkflowContext workflowContext = - new() - { - HttpClient = customClient, - LoggerFactory = this.LoggerFactory, - ProjectEndpoint = Throw.IfNull(TestConfiguration.AzureAI.Endpoint), - ProjectCredentials = new AzureCliCredential(), - }; - // - // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. - // - Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); - // - ////////////////////////////////////////////////////// - - Debug.WriteLine("\nWORKFLOW INVOKE\n"); - - StreamingRun run = await InProcessExecution.StreamAsync(workflow, ""); - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + else if (evt is ExecutorCompleteEvent executorComplete) { - if (evt is ExecutorInvokeEvent executorInvoked) - { - Debug.WriteLine($"!!! ENTER #{executorInvoked.ExecutorId}"); - } - else if (evt is ExecutorCompleteEvent executorComplete) + Debug.WriteLine($"!!! EXIT #{executorComplete.ExecutorId}"); + } + else if (evt is DeclarativeWorkflowMessageEvent messageEvent) + { + if (messageEvent.Data.MessageId is null) { - Debug.WriteLine($"!!! EXIT #{executorComplete.ExecutorId}"); + Console.WriteLine(messageEvent.Data); } - else if (evt is DeclarativeWorkflowMessageEvent messageEvent) + else { - if (messageEvent.Data.MessageId is null) - { - Console.WriteLine(messageEvent.Data); - } - else + Console.WriteLine($"#{messageEvent.Data.MessageId}:"); + Console.WriteLine(messageEvent.Data); + if (messageEvent.Usage is not null) { - Console.WriteLine($"#{messageEvent.Data.MessageId}:"); - Console.WriteLine(messageEvent.Data); - if (messageEvent.Usage is not null) - { - Console.WriteLine($"[Tokens Total: {messageEvent.Usage.TotalTokenCount}, Input: {messageEvent.Usage.InputTokenCount}, Output: {messageEvent.Usage.OutputTokenCount}]"); - } - Console.WriteLine(); + Console.WriteLine($"[Tokens Total: {messageEvent.Usage.TotalTokenCount}, Input: {messageEvent.Usage.InputTokenCount}, Output: {messageEvent.Usage.OutputTokenCount}]"); } + Console.WriteLine(); } } - - Debug.WriteLine("\nWORKFLOW DONE"); - } - finally - { - customClient?.Dispose(); - } - } -} - -internal sealed class InterceptHandler : HttpClientHandler -{ - //private static readonly JsonSerializerOptions s_options = new() { WriteIndented = true }; - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - // Call the inner handler to process the request and get the response - HttpResponseMessage response = await base.SendAsync(request, cancellationToken); - - // Intercept and modify the response - Debug.WriteLine($"{request.Method} {request.RequestUri}"); - if (response.Content != null) - { - string responseContent; - //try - //{ - // JsonDocument responseDocument = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); - // responseContent = JsonSerializer.Serialize(responseDocument, s_options); - //} - //catch (ArgumentException) - //{ - // responseContent = await response.Content.ReadAsStringAsync(cancellationToken); - //} - //catch (JsonException) - //{ - responseContent = await response.Content.ReadAsStringAsync(cancellationToken); - //} - response.Content = new StringContent(responseContent); - - Debug.WriteLine($"API:{Environment.NewLine}" + responseContent); } - return response; + Debug.WriteLine("\nWORKFLOW DONE"); } } - -#endif diff --git a/dotnet/samples/GettingStarted/Workflows/testChat.yaml b/dotnet/samples/GettingStarted/Workflows/testChat.yaml deleted file mode 100644 index fbf8af2bc5..0000000000 --- a/dotnet/samples/GettingStarted/Workflows/testChat.yaml +++ /dev/null @@ -1,16 +0,0 @@ -kind: AdaptiveDialog -beginDialog: - kind: OnActivity - id: activity_xyz123 - actions: - - # Use AI to answer the question - - kind: AnswerQuestionWithAI - id: question_wEJ456 - variable: Topic.Answer - userInput: Why is the sky blue? - - # Display the AI's answer - - kind: SendActivity - id: sendActivity_zA3f0p - activity: "AI - {Topic.Answer}" diff --git a/dotnet/samples/GettingStarted/Workflows/testCondition0.yaml b/dotnet/samples/GettingStarted/Workflows/testCondition0.yaml deleted file mode 100644 index dd5c9f7387..0000000000 --- a/dotnet/samples/GettingStarted/Workflows/testCondition0.yaml +++ /dev/null @@ -1,70 +0,0 @@ -kind: AdaptiveDialog -beginDialog: - - kind: OnActivity - id: activity_xyz123 - type: Message - actions: - - - kind: SetVariable - id: setVariable_start - displayName: Invocation count - variable: Topic.Count - value: =0 - - - kind: SetVariable - id: setVariable_loop - displayName: Invocation count - variable: Topic.Count - value: =Topic.Count + 11 - - - kind: SendActivity - id: sendActivity_loop - activity: Looping (x{Topic.Count}) - - - kind: ConditionGroup - id: conditionGroup_test - conditions: - - id: conditionItem_p1 - condition: =Topic.Count < 3 - - - id: conditionItem_p2 - condition: =Topic.Count >= 3 && Topic.Count < 6 - actions: - - kind: SendActivity - id: sendActivity_p2 - activity: 3 <= ({Topic.Count}) < 6 - - - kind: GotoAction - id: goto_p2 - actionId: sendActivity_done - - - id: conditionItem_p3 - condition: =Topic.Count >= 6 && Topic.Count < 9 - actions: - - kind: SendActivity - id: sendActivity_p3 - activity: 6 <= ({Topic.Count}) < 9 - - - id: conditionItem_p4 - condition: =Topic.Count >= 9 - actions: - - kind: SendActivity - id: sendActivity_p4 - activity: ({Topic.Count}) >= 9 - - # elseActions: - # - kind: SendActivity - # id: sendActivity_rOk31p - # activity: All done (x{Topic.Count}) - - # - kind: EndConversation - # id: end_SVoNSV - - - kind: SendActivity - id: sendActivity_extra - activity: Fall-through... - - - kind: SendActivity - id: sendActivity_done - activity: Complete! diff --git a/dotnet/samples/GettingStarted/Workflows/testCondition1.yaml b/dotnet/samples/GettingStarted/Workflows/testCondition1.yaml deleted file mode 100644 index 958f8d1aa9..0000000000 --- a/dotnet/samples/GettingStarted/Workflows/testCondition1.yaml +++ /dev/null @@ -1,84 +0,0 @@ -kind: AdaptiveDialog -beginDialog: - kind: OnActivity - id: activity_xyz123 - type: Message - actions: - - kind: SetVariable - id: setVariable_u4cBtN - displayName: Invocation count - variable: Topic.Count - value: =0 - - - kind: GotoAction - id: goto_skJ8u - actionId: setVariable_a9f4o2 - - - kind: SendActivity - id: sendActivity_skJ8u - activity: NEVER A! - - - kind: SetVariable - id: setVariable_a9f4o2 - displayName: Invocation count - variable: Topic.Count - value: =Topic.Count + 1 - - - kind: SendActivity - id: sendActivity_aGsbRo - activity: Looping (x{Topic.Count}) - - - kind: ConditionGroup - id: conditionGroup_mVIecC - conditions: - - id: conditionItem_p1 - condition: =Topic.Count < 3 - actions: - - kind: SendActivity - id: sendActivity_Pkkmpq - activity: Just started (x{Topic.Count}) - - - id: conditionItem_p2 - condition: =Topic.Count >= 3 && Topic.Count < 6 - actions: - - kind: SendActivity - id: sendActivity_aLM1o3 - activity: Making progress (x{Topic.Count}) - - - kind: GotoAction - id: goto_LzfJ8u - actionId: setVariable_a9f4o2 - - - id: conditionItem_p3 - condition: =Topic.Count >= 6 - actions: - - kind: SendActivity - id: sendActivity_rOk31p - activity: All done (x{Topic.Count}) - - - kind: EndConversation - id: end_SVoNSV - - # - kind: GotoAction - # id: goto_HAX - # actionId: sendActivity_ohn03s - - # elseActions: - # - kind: SendActivity - # id: sendActivity_rOk31p - # activity: All done (x{Topic.Count}) - - # - kind: EndConversation - # id: end_SVoNSV - - - kind: SendActivity - id: sendActivity_fJsbRz - activity: Fallthrough (x{Topic.Count}) - - - kind: GotoAction - id: goto_fTJ8u - actionId: setVariable_a9f4o2 - - - kind: SendActivity - id: sendActivity_ohn03s - activity: NEVER B! diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs index 1688b2cb31..3afac58c96 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs @@ -26,7 +26,7 @@ protected override async ValueTask ExecuteAsync(IWorkflowContext context, Cancel string? activityText = this.Context.Engine.Format(messageActivity.Text)?.Trim(); templateBuilder.AppendLine(activityText); - await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(new ChatMessage(ChatRole.Assistant, templateBuilder.ToString()))).ConfigureAwait(false); + await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(new ChatMessage(ChatRole.Assistant, templateBuilder.ToString().Trim()))).ConfigureAwait(false); } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Executor.cs b/dotnet/src/Microsoft.Agents.Workflows/Executor.cs index 4dc81b18b1..95b27a7a42 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Executor.cs @@ -85,7 +85,7 @@ internal MessageRouter Router if (!result.IsSuccess) { - throw new TargetInvocationException($"Error invoking handler for {message.GetType()}", result.Exception!); + throw new TargetInvocationException($"Error invoking handler for {message.GetType()}", result.Exception); } if (result.IsVoid) diff --git a/dotnet/src/Microsoft.Agents.Workflows/ExecutorFailureEvent.cs b/dotnet/src/Microsoft.Agents.Workflows/ExecutorFailureEvent.cs index 524549c7dd..b3a3168cd0 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/ExecutorFailureEvent.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/ExecutorFailureEvent.cs @@ -9,4 +9,11 @@ namespace Microsoft.Agents.Workflows; /// /// The unique identifier of the executor that has failed. /// The exception representing the error. -public sealed class ExecutorFailureEvent(string executorId, Exception? err) : ExecutorEvent(executorId, data: err); +public sealed class ExecutorFailureEvent(string executorId, Exception? err) + : ExecutorEvent(executorId, data: err) +{ + /// + /// The exception that caused the executor to fail. This may be null if no exception was thrown. + /// + public new Exception? Data => err; +} diff --git a/workflows/HelloWorld.yaml b/workflows/HelloWorld.yaml new file mode 100644 index 0000000000..960e1f27bd --- /dev/null +++ b/workflows/HelloWorld.yaml @@ -0,0 +1,10 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: workflow_demo + actions: + + # Respond with input + - kind: SendActivity + id: sendActivity_demo + activity: {System.LastMessage.Text} \ No newline at end of file diff --git a/workflows/MathChat.yaml b/workflows/MathChat.yaml index f059d2ad6a..3baa23b886 100644 --- a/workflows/MathChat.yaml +++ b/workflows/MathChat.yaml @@ -20,9 +20,14 @@ beginDialog: displayName: Student userInput: =Topic.Project - - kind: ResetVariable + # - kind: ResetVariable + # id: reset_project + # variable: Topic.Project + + - kind: SetVariable # // %%% HAXX id: reset_project variable: Topic.Project + value: =Blank() - kind: AnswerQuestionWithAI id: asst_0d4uQ7HiFCfRXEuiQkspCx2T # // %%% HAXX diff --git a/workflows/Question.yaml b/workflows/Question.yaml index 5f7a75f31c..1cfbc26b5b 100644 --- a/workflows/Question.yaml +++ b/workflows/Question.yaml @@ -8,4 +8,4 @@ beginDialog: - kind: AnswerQuestionWithAI id: asst_Tq5lpaIHgIbqQSKIpGxNq6A5 variable: Topic.Answer - userInput: Why is the sky blue? + userInput: {System.LastMessage.Text} From e68289e70d2915d28a2e104d885bfe31ce0b5819 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 18 Aug 2025 16:26:47 -0700 Subject: [PATCH 167/232] Expression bug fix --- .../Extensions/PropertyPathExtensions.cs | 2 +- workflows/Question.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/PropertyPathExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/PropertyPathExtensions.cs index 634b367d64..2fbc5c28ef 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/PropertyPathExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/PropertyPathExtensions.cs @@ -6,5 +6,5 @@ namespace Microsoft.Agents.Workflows.Declarative.Extensions; internal static class PropertyPathExtensions { - public static string Format(this PropertyPath path) => $"{path.VariableScopeName}.{path.VariableName}"; + public static string Format(this PropertyPath path) => string.Join(".", path.Segments()); } diff --git a/workflows/Question.yaml b/workflows/Question.yaml index 1cfbc26b5b..e10ed051f1 100644 --- a/workflows/Question.yaml +++ b/workflows/Question.yaml @@ -6,6 +6,6 @@ beginDialog: # Use AI to answer the question - kind: AnswerQuestionWithAI - id: asst_Tq5lpaIHgIbqQSKIpGxNq6A5 + id: asst_orsBf06Bxz9B1hjVjiiQoPqf variable: Topic.Answer - userInput: {System.LastMessage.Text} + userInput: =System.LastMessage.Text From 816406accc488e8c72dd8c936f4f8a5cb51e4899 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 18 Aug 2025 16:29:53 -0700 Subject: [PATCH 168/232] Sample formatting --- dotnet/demos/DeclarativeWorkflow/Program.cs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index ea4940ba3c..df4fe881d8 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -92,13 +92,13 @@ public static async Task Main(string[] args) if (messageId is not null) { - Console.ForegroundColor = ConsoleColor.White; + Console.ForegroundColor = ConsoleColor.Cyan; Console.WriteLine($"#{messageId}:"); } } try { - Console.ForegroundColor = ConsoleColor.Yellow; + Console.ResetColor(); Console.Write(streamEvent.Data); } finally @@ -118,10 +118,6 @@ public static async Task Main(string[] args) } else { - //Console.ForegroundColor = ConsoleColor.White; - //Console.WriteLine($"#{messageEvent.Data.MessageId}:"); - //Console.ForegroundColor = ConsoleColor.DarkGreen; - //Console.WriteLine(messageEvent.Data?.Text.Trim()); if (messageEvent.Usage is not null) { Console.ForegroundColor = ConsoleColor.DarkGray; @@ -149,7 +145,7 @@ string GetWorkflowInput(string[] args) Console.Write("\nINPUT: "); - Console.ForegroundColor = ConsoleColor.Yellow; + Console.ForegroundColor = ConsoleColor.White; if (!string.IsNullOrWhiteSpace(input)) { From e83c721a8c234e33f1ca05c9bc33ac50a91b8883 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 18 Aug 2025 16:56:56 -0700 Subject: [PATCH 169/232] Add unit test --- .../Extensions/IWorkflowContextExtensions.cs | 8 +-- .../Interpreter/DeclarativeActionExecutor.cs | 4 +- .../DeclarativeWorkflowExecutor.cs | 13 +++-- .../Execution/WorkflowActionExecutorTest.cs | 2 +- .../PowerFx/WorkflowExpressionEngineTests.cs | 49 ++++++++----------- 5 files changed, 38 insertions(+), 38 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs index abf48e8ea8..552a45a40b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs @@ -10,10 +10,10 @@ internal static class IWorkflowContextExtensions { private const string ScopesKey = "__scopes__"; - public static async Task GetScopesAsync(this IWorkflowContext context, CancellationToken cancellationToken) => - await context.ReadWorkflowStateAsync(ScopesKey).ConfigureAwait(false) ?? // %%% DEEPER INTEGRATION + public static async Task GetScopedStateAsync(this IWorkflowContext context, CancellationToken cancellationToken) => + await context.ReadWorkflowStateAsync(ScopesKey).ConfigureAwait(false) ?? new(); - public static async Task SetScopesAsync(this IWorkflowContext context, WorkflowScopes scopes, CancellationToken cancellationToken) => - await context.QueueWorkflowStateUpdateAsync(ScopesKey, scopes).ConfigureAwait(false); // %%% DEEPER INTEGRATION + public static async Task SetScopedStateAsync(this IWorkflowContext context, WorkflowScopes scopes, CancellationToken cancellationToken) => + await context.QueueWorkflowStateUpdateAsync(ScopesKey, scopes).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs index 30c7a77a97..51960eb69a 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -67,14 +67,14 @@ public async ValueTask HandleAsync(ExecutionResultMessage message, IWorkflowCont return; } - WorkflowScopes scopes = await context.GetScopesAsync(default).ConfigureAwait(false); + WorkflowScopes scopes = await context.GetScopedStateAsync(default).ConfigureAwait(false); this.State = new DeclarativeWorkflowState(this.Options.CreateRecalcEngine(), scopes); try { object? result = await this.ExecuteAsync(context, cancellationToken: default).ConfigureAwait(false); - await context.SetScopesAsync(scopes, default).ConfigureAwait(false); + await context.SetScopedStateAsync(scopes, default).ConfigureAwait(false); await context.SendMessageAsync(new ExecutionResultMessage(this.Id, result)).ConfigureAwait(false); } catch (WorkflowExecutionException) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs index afd8931aa6..810e344a4f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs @@ -19,12 +19,19 @@ internal sealed class DeclarativeWorkflowExecutor(string workflowId) : { public async ValueTask HandleAsync(TInput message, IWorkflowContext context) { - ChatMessage input = new(ChatRole.User, $"{message}"); // %%% HAXX: Convert to ChatMessage - WorkflowScopes scopes = await context.GetScopesAsync(default).ConfigureAwait(false); + ChatMessage input = + message switch + { + ChatMessage chatMessage => chatMessage, + string stringMessage => new ChatMessage(ChatRole.User, stringMessage), + _ => new(ChatRole.User, $"{message}") + }; + + WorkflowScopes scopes = await context.GetScopedStateAsync(default).ConfigureAwait(false); scopes.Set("LastMessage", WorkflowScopeType.System, input.ToRecordValue()); - await context.SetScopesAsync(scopes, default).ConfigureAwait(false); + await context.SetScopedStateAsync(scopes, default).ConfigureAwait(false); await context.SendMessageAsync(new ExecutionResultMessage(this.Id)).ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs index 16464adb20..566ba3dd03 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs @@ -77,7 +77,7 @@ internal sealed class TestWorkflowExecutor() : { public async ValueTask HandleAsync(WorkflowScopes message, IWorkflowContext context) { - await context.SetScopesAsync(message, default).ConfigureAwait(false); + await context.SetScopedStateAsync(message, default).ConfigureAwait(false); await context.SendMessageAsync(new ExecutionResultMessage(this.Id)).ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs index 27634e2ae3..f20aadd172 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs @@ -179,25 +179,23 @@ public void StringExpressionGetValueForFormula() expectedValue: "AB"); } - //[Fact] // %%% TEST COVERAGE - //public void GetValueForStringExpressionWithRecordDataValue() - //{ - // // Arrange - // RecalcEngine engine = this.CreateEngine(); - // WorkflowExpressionEngine expressionEngine = new(engine); - // RecordDataValue state = new RecordDataValue(); - // RecordDataValue globalScope = new RecordDataValue(); - // globalScope.Properties["testValue"] = new StringDataValue("test"); - // state.Properties["Global"] = globalScope; - // StringExpression expression = StringExpression.Variable(PropertyPath.Create("Global.testValue")); - - // // Act - // EvaluationResult result = expressionEngine.GetValue(expression, state); - - // // Assert - // Assert.Equal("test", result.Value); - // Assert.Equal(SensitivityLevel.None, result.Sensitivity); - //} + [Fact] + public void StringExpressionGetValueForRecord() + { + // Arrange + RecordValue state = FormulaValue.NewRecordFromFields([new NamedValue("test", FormulaValue.New("value"))]); + this.Scopes.Set("TestRecord", WorkflowScopeType.Global, state); + + // Arrange, Act & Assert + this.EvaluateExpression( + StringExpression.Variable(PropertyPath.Create("Global.TestRecord")), + expectedValue: + """ + { + "test": "value" + } + """.Replace("\n", Environment.NewLine)); + } #endregion @@ -469,15 +467,6 @@ public void ObjectExpressionGetValueForVariable(bool useState) useState); } - //[Fact] // %%% TEST COVERAGE - //public void ObjectExpressionGetValueForFormula() - //{ - // // Arrange, Act & Assert - // this.EvaluateExpression( - // ObjectExpression.Expression(@"""{\""schemaName\"": "" & "" \""test\""}"""), - // expectedValue: ObjectData.ToDataValue()); - //} - #endregion #region ArrayExpression Tests @@ -660,6 +649,7 @@ private EvaluationResult EvaluateExpression( { // Arrange RecalcEngine engine = this.CreateEngine(); + this.Scopes.Bind(engine); WorkflowExpressionEngine expressionEngine = new(engine); // Act @@ -678,6 +668,7 @@ private ImmutableArray EvaluateArrayExpression( { // Arrange RecalcEngine engine = this.CreateEngine(); + this.Scopes.Bind(engine); WorkflowExpressionEngine expressionEngine = new(engine); // Act @@ -694,6 +685,7 @@ private void EvaluateInvalidExpression(Action eval { // Arrange RecalcEngine engine = this.CreateEngine(); + this.Scopes.Bind(engine); WorkflowExpressionEngine expressionEngine = new(engine); // Act From ab63813c67c9aa15283e3f5416624a4ef52e87f5 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 18 Aug 2025 17:07:21 -0700 Subject: [PATCH 170/232] Comments --- .../ObjectModel/AnswerQuestionWithAIExecutor.cs | 9 ++------- .../src/Microsoft.Agents.Workflows/IWorkflowContext.cs | 4 ++-- .../InProc/InProcessRunnerContext.cs | 4 ++-- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs index 071e268374..54b8dda64c 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs @@ -41,13 +41,8 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P Instructions = this.State.Format(this.Model.AdditionalInstructions) ?? string.Empty, }); - //AgentRunResponse agentResponse = - // userInput != null ? - // await agent.RunAsync(userInput, thread: null, options, cancellationToken).ConfigureAwait(false) : - // await agent.RunAsync(thread: null, options, cancellationToken).ConfigureAwait(false); - - AgentThread? thread = null; // %%% HAXX: SYSTEM THREAD - FormulaValue conversationValue = this.State.Get(WorkflowScopeType.System, "ConversationId"); + AgentThread? thread = null; + FormulaValue conversationValue = this.State.Get(WorkflowScopeType.System, "ConversationId"); // %%% HAXX: SYSTEM THREAD if (conversationValue is StringValue stringValue) { thread = new AgentThread() { ConversationId = stringValue.Value }; diff --git a/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs index 4d81b5bdea..9a4a2dffd2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs @@ -58,7 +58,7 @@ public interface IWorkflowContext /// The key of the state value. /// The name of the scope. /// A representing the asynchronous operation. - ValueTask ReadWorkflowStateAsync(string key, string? scopeName = null); // %%% HAXX: WORKFLOW STATE + ValueTask ReadWorkflowStateAsync(string key, string? scopeName = null); // %%% HAXX: _WORKFLOW_ STATE /// /// Asynchronously updates the state of a queue entry identified by the specified key and optional scope. @@ -74,5 +74,5 @@ public interface IWorkflowContext /// An optional name that specifies the scope within which the queue entry resides. If null, the default scope is /// used. /// A ValueTask that represents the asynchronous update operation. - ValueTask QueueWorkflowStateUpdateAsync(string key, T? value, string? scopeName = null); // %%% HAXX: WORKFLOW STATE + ValueTask QueueWorkflowStateUpdateAsync(string key, T? value, string? scopeName = null); // %%% HAXX: _WORKFLOW_ STATE } diff --git a/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs index c06c3df2eb..ce030109da 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs @@ -102,10 +102,10 @@ public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeNam public ValueTask ReadStateAsync(string key, string? scopeName = null) => RunnerContext.StateManager.ReadStateAsync(ExecutorId, scopeName, key); - public ValueTask QueueWorkflowStateUpdateAsync(string key, T? value, string? scopeName = null) // %%% HAXX: WORKFLOW STATE + public ValueTask QueueWorkflowStateUpdateAsync(string key, T? value, string? scopeName = null) // %%% HAXX: _WORKFLOW_ STATE => RunnerContext.StateManager.WriteStateAsync(WorkflowId, scopeName, key, value); - public ValueTask ReadWorkflowStateAsync(string key, string? scopeName = null) // %%% HAXX: WORKFLOW STATE + public ValueTask ReadWorkflowStateAsync(string key, string? scopeName = null) // %%% HAXX: _WORKFLOW_ STATE => RunnerContext.StateManager.ReadStateAsync(WorkflowId, scopeName, key); } } From acdd7296d1db23b6b4471abf18b9d4828a14dd40 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 19 Aug 2025 09:55:07 -0700 Subject: [PATCH 171/232] Scope cleanup --- .../Workflows/testExpression.yaml | 34 +++--- .../GettingStarted/Workflows/testTopic.yaml | 26 ----- .../Extensions/IWorkflowContextExtensions.cs | 36 +++++-- .../Interpreter/DeclarativeActionExecutor.cs | 1 - .../DeclarativeWorkflowExecutor.cs | 3 +- .../Interpreter/DeclarativeWorkflowState.cs | 22 ++-- .../AnswerQuestionWithAIExecutor.cs | 5 +- .../ObjectModel/ClearAllVariablesExecutor.cs | 7 +- .../ObjectModel/ParseValueExecutor.cs | 2 +- .../PowerFx/WorkflowScope.cs | 4 +- .../PowerFx/WorkflowScopes.cs | 67 ++++++------ .../PowerFx/WorkflowScopesType.cs | 48 --------- .../Execution/StateManager.cs | 6 +- .../Execution/StateScope.cs | 15 +-- .../IWorkflowContext.cs | 26 ----- .../InProc/InProcessRunnerContext.cs | 8 -- .../Execution/WorkflowActionExecutorTest.cs | 13 ++- .../PowerFx/WorkflowExpressionEngineTests.cs | 20 ++-- .../PowerFx/WorkflowScopeTypeTest.cs | 102 ------------------ .../PowerFx/WorkflowScopesTests.cs | 49 ++++----- .../WorkflowTest.cs | 4 +- 21 files changed, 165 insertions(+), 333 deletions(-) delete mode 100644 dotnet/samples/GettingStarted/Workflows/testTopic.yaml delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopesType.cs delete mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopeTypeTest.cs diff --git a/dotnet/samples/GettingStarted/Workflows/testExpression.yaml b/dotnet/samples/GettingStarted/Workflows/testExpression.yaml index fe4a309fb4..35151ee25d 100644 --- a/dotnet/samples/GettingStarted/Workflows/testExpression.yaml +++ b/dotnet/samples/GettingStarted/Workflows/testExpression.yaml @@ -4,26 +4,36 @@ beginDialog: id: activity_xyz123 type: Message actions: - - kind: SetVariable - id: setVariable1 + # - kind: SetVariable + # id: setVariable1 + # variable: Topic.TestList + # value: ["zaz", "zbz", "zcz", "zdz", "zez", "zfz"] + + - kind: ParseValue + id: setVariable2 + displayName: Parse ledger response variable: Topic.TestList - value: =["zaz", "zbz", "zcz", "zdz", "zez", "zfz"] + valueType: + kind: Record + properties: + key: Number + value: [{"Key": 1}, {"Key": 2}] # - kind: SetVariable # id: setVariable2 - # variable: Topic.TestResult - # value: |- - # =CountIf(Topic.TestList, !IsBlank(Find("e", Value))) > 0 && - # !IsBlank(3) - -# value: =CountIf(Topic.TestList, !IsBlank(Find("e", Value))) > 0 - #value: =CountIf(Topic.TestList, 1) -# value: =!IsBlank(Topic.TestList) + # variable: Topic.TestList + # value: [{"Key": 1}, {"Key": 2}] - kind: SetVariable id: setVariable3 variable: Topic.Result3 - value: =Find("e", "abcdefg") + value: =CountIf(Topic.TestList, Key > 1) + #value: =Find("e", "abcdefg") + #value: =CountIf(Topic.TestList, 1) + #value: =CountIf(Topic.TestList, !IsBlank(Find("e", Value))) +# value: =!IsBlank(Topic.TestList) + +# value: =CountIf(Topic.TestList, !IsBlank(Find("e", Value))) > 0 #value: =CountIf(Topic.TestList, 1) # value: =!IsBlank(Topic.TestList) diff --git a/dotnet/samples/GettingStarted/Workflows/testTopic.yaml b/dotnet/samples/GettingStarted/Workflows/testTopic.yaml deleted file mode 100644 index a4380697d9..0000000000 --- a/dotnet/samples/GettingStarted/Workflows/testTopic.yaml +++ /dev/null @@ -1,26 +0,0 @@ -kind: AdaptiveDialog -beginDialog: - kind: OnActivity - id: activity_xyz123 - type: Message - actions: - - - kind: SetVariable - id: setVariable_u4cBtN - displayName: Invocation count - variable: Topic.FirstValue - value: ABC - - - kind: SetVariable - id: setVariable_a8ybTn - displayName: Invocation count - variable: Workflow.SecondValue - value: 123 - - - kind: SendActivity - id: sendActivity_SVoNSV - activity: {Workflow.FirstValue} - - - kind: SendActivity - id: sendActivity_fJsbRz - activity: {Topic.SecondValue} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs index 552a45a40b..64e93d1b0c 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs @@ -1,19 +1,43 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; namespace Microsoft.Agents.Workflows.Declarative.Extensions; internal static class IWorkflowContextExtensions { - private const string ScopesKey = "__scopes__"; + private const string ScopesKey = "__workflow__"; - public static async Task GetScopedStateAsync(this IWorkflowContext context, CancellationToken cancellationToken) => - await context.ReadWorkflowStateAsync(ScopesKey).ConfigureAwait(false) ?? - new(); + public static async Task GetScopedStateAsync(this IWorkflowContext context, CancellationToken cancellationToken) + { + IEnumerable> readTasks = + VariableScopeNames.AllScopes.Select( + scopeName => context.ReadStateAsync(scopeName, ScopesKey).AsTask()); - public static async Task SetScopedStateAsync(this IWorkflowContext context, WorkflowScopes scopes, CancellationToken cancellationToken) => - await context.QueueWorkflowStateUpdateAsync(ScopesKey, scopes).ConfigureAwait(false); + WorkflowScope?[] scopes = await Task.WhenAll(readTasks).ConfigureAwait(false); + Dictionary scopesMap = scopes.OfType().ToDictionary(scope => scope!.Name, scope => scope); + + return new WorkflowScopes(VariableScopeNames.AllScopes.ToDictionary(scopeName => scopeName, scopeName => GetScope(scopeName))); + + WorkflowScope GetScope(string scopeName) + { + if (!scopesMap.TryGetValue(scopeName, out WorkflowScope? scope)) + { + scope = new WorkflowScope(scopeName); + } + return scope; + } + } + + public static async Task SetScopedStateAsync(this IWorkflowContext context, WorkflowScopes scopes, CancellationToken cancellationToken) + { + IEnumerable writeTasks = scopes.Select(scope => context.QueueStateUpdateAsync(scope.Name, scope, ScopesKey).AsTask()); + + await Task.WhenAll(writeTasks).ConfigureAwait(false); + } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs index 51960eb69a..14ed631d10 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.ObjectModel; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Agents.Workflows.Reflection; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs index 810e344a4f..2a1f591fa1 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs @@ -4,6 +4,7 @@ using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Agents.Workflows.Reflection; +using Microsoft.Bot.ObjectModel; using Microsoft.Extensions.AI; namespace Microsoft.Agents.Workflows.Declarative.Interpreter; @@ -29,7 +30,7 @@ public async ValueTask HandleAsync(TInput message, IWorkflowContext context) WorkflowScopes scopes = await context.GetScopedStateAsync(default).ConfigureAwait(false); - scopes.Set("LastMessage", WorkflowScopeType.System, input.ToRecordValue()); + scopes.Set("LastMessage", VariableScopeNames.System, input.ToRecordValue()); await context.SetScopedStateAsync(scopes, default).ConfigureAwait(false); await context.SendMessageAsync(new ExecutionResultMessage(this.Id)).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs index c0943fb6b3..c2da3aa484 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs @@ -26,36 +26,36 @@ public DeclarativeWorkflowState(RecalcEngine engine, WorkflowScopes? scopes = nu public WorkflowExpressionEngine ExpressionEngine => this._expressionEngine ??= new WorkflowExpressionEngine(this._engine); public void Clear(PropertyPath variablePath) => - this.Clear(WorkflowScopeType.Parse(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName)); + this.Clear(Throw.IfNull(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName)); - public void Clear(WorkflowScopeType scope, string? varName = null) + public void Clear(string scopeName, string? varName = null) { if (string.IsNullOrWhiteSpace(varName)) { - this._scopes.Clear(scope); + this._scopes.Clear(scopeName); } else { - this._scopes.Remove(varName, scope); + this._scopes.Remove(varName, scopeName); } - this._scopes.Bind(this._engine, scope); + this._scopes.Bind(this._engine, scopeName); } public FormulaValue Get(PropertyPath variablePath) => - this.Get(WorkflowScopeType.Parse(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName)); + this.Get(Throw.IfNull(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName)); - public FormulaValue Get(WorkflowScopeType scope, string varName) => + public FormulaValue Get(string scope, string varName) => this._scopes.Get(varName, scope); public void Set(PropertyPath variablePath, FormulaValue value) => - this.Set(WorkflowScopeType.Parse(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName), value); + this.Set(Throw.IfNull(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName), value); - public void Set(WorkflowScopeType scope, string varName, FormulaValue value) + public void Set(string scopeName, string varName, FormulaValue value) { - this._scopes.Set(varName, scope, value); + this._scopes.Set(varName, scopeName, value); - this._scopes.Bind(this._engine, scope); + this._scopes.Bind(this._engine, scopeName); } public string? Format(IEnumerable template) => this._engine.Format(template); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs index 54b8dda64c..905dd77b6e 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs @@ -8,7 +8,6 @@ using Azure.AI.Agents.Persistent; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.Interpreter; -using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.Extensions.AI; @@ -42,7 +41,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P }); AgentThread? thread = null; - FormulaValue conversationValue = this.State.Get(WorkflowScopeType.System, "ConversationId"); // %%% HAXX: SYSTEM THREAD + FormulaValue conversationValue = this.State.Get(VariableScopeNames.System, "ConversationId"); // %%% HAXX: SYSTEM THREAD if (conversationValue is StringValue stringValue) { thread = new AgentThread() { ConversationId = stringValue.Value }; @@ -80,7 +79,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P ChatMessage response = agentResponse.Messages.Last(); // %%% DECISION: Is last sufficient? (probably not) await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(response, agentResponse.Usage)).ConfigureAwait(false); - this.AssignTarget(PropertyPath.FromSegments(WorkflowScopeType.System.Name, "ConversationId"), FormulaValue.New(conversationId)); // %%% HAXX: SYSTEM THREAD + this.AssignTarget(PropertyPath.FromSegments(VariableScopeNames.System, "ConversationId"), FormulaValue.New(conversationId)); // %%% HAXX: SYSTEM THREAD PropertyPath? variablePath = this.Model.Variable?.Path; if (variablePath is not null) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs index f6add1051c..e3554c3298 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs @@ -3,7 +3,6 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Interpreter; -using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; @@ -24,7 +23,7 @@ private sealed class ScopeHandler(DeclarativeWorkflowState state) : IEnumVariabl { public void HandleAllGlobalVariables() { - state.Clear(WorkflowScopeType.Global); + state.Clear(VariableScopeNames.Global); } public void HandleConversationHistory() @@ -34,7 +33,7 @@ public void HandleConversationHistory() public void HandleConversationScopedVariables() { - state.Clear(WorkflowScopeType.Topic); + state.Clear(VariableScopeNames.Topic); } public void HandleUnknownValue() @@ -44,7 +43,7 @@ public void HandleUnknownValue() public void HandleUserScopedVariables() { - state.Clear(WorkflowScopeType.Env); // %%% DECISION: Is this correct? If not, what? + state.Clear(VariableScopeNames.Environment); // %%% DECISION: Is this correct? If not, what? } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs index 5e3ab297db..1006041674 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs @@ -16,7 +16,7 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; internal sealed class ParseValueExecutor(ParseValue model) : DeclarativeActionExecutor(model) { - protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); ValueExpression valueExpression = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs index bcf1a2e40a..47c9ccd600 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs @@ -10,8 +10,10 @@ namespace Microsoft.Agents.Workflows.Declarative.PowerFx; /// /// The set of variables for a specific action scope. /// -internal sealed class WorkflowScope : Dictionary +internal sealed class WorkflowScope(string scopeName) : Dictionary { + public string Name => scopeName; + public RecordValue BuildRecord() { return FormulaValue.NewRecordFromFields(GetFields()); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs index 625d2024f8..7c81d58310 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections; using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx; using Microsoft.PowerFx.Types; @@ -11,25 +13,30 @@ namespace Microsoft.Agents.Workflows.Declarative.PowerFx; /// /// Contains all action scopes for a process. /// -internal sealed class WorkflowScopes +internal sealed class WorkflowScopes : IEnumerable { - private readonly ImmutableDictionary _scopes; + private readonly ImmutableDictionary _scopes; - public WorkflowScopes() + public WorkflowScopes(Dictionary? scopes = null) { - Dictionary scopes = - new() + this._scopes = VariableScopeNames.AllScopes.ToDictionary(scopeName => scopeName, scopeName => GetScope(scopeName)).ToImmutableDictionary(); + + WorkflowScope GetScope(string scopeName) + { + if (scopes is not null && scopes.TryGetValue(scopeName, out WorkflowScope? scope)) { - { WorkflowScopeType.Env, [] }, - { WorkflowScopeType.Topic, [] }, - { WorkflowScopeType.Global, [] }, - { WorkflowScopeType.System, [] }, - }; + return scope; + } - this._scopes = scopes.ToImmutableDictionary(); + return new WorkflowScope(scopeName); + } } - public RecordValue BuildRecord(WorkflowScopeType scope) => this._scopes[scope].BuildRecord(); + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + public IEnumerator GetEnumerator() => this._scopes.Values.GetEnumerator(); + + public RecordValue BuildRecord(string scopeName) => this._scopes[scopeName].BuildRecord(); public RecordDataValue BuildState() { @@ -37,14 +44,14 @@ public RecordDataValue BuildState() IEnumerable> BuildStateFields() { - foreach (KeyValuePair kvp in this._scopes) + foreach (KeyValuePair kvp in this._scopes) { - yield return new(kvp.Key.Name, kvp.Value.BuildState()); + yield return new(kvp.Key, kvp.Value.BuildState()); } } } - public void Bind(RecalcEngine engine, WorkflowScopeType? type = null) + public void Bind(RecalcEngine engine, string? type = null) { if (type is not null) { @@ -52,23 +59,23 @@ public void Bind(RecalcEngine engine, WorkflowScopeType? type = null) } else { - Bind(WorkflowScopeType.Topic); - Bind(WorkflowScopeType.Global); - Bind(WorkflowScopeType.Env); - Bind(WorkflowScopeType.System); + Bind(VariableScopeNames.Topic); + Bind(VariableScopeNames.Global); + Bind(VariableScopeNames.Environment); + Bind(VariableScopeNames.System); } - void Bind(WorkflowScopeType scope) + void Bind(string scopeName) { - RecordValue scopeRecord = this.BuildRecord(scope); - engine.DeleteFormula(scope.Name); - engine.UpdateVariable(scope.Name, scopeRecord); + RecordValue scopeRecord = this.BuildRecord(scopeName); + engine.DeleteFormula(scopeName); + engine.UpdateVariable(scopeName, scopeRecord); } } - public FormulaValue Get(string name, WorkflowScopeType? type = null) + public FormulaValue Get(string name, string? scopeName = null) { - if (this._scopes[type ?? WorkflowScopeType.Topic].TryGetValue(name, out FormulaValue? value)) + if (this._scopes[scopeName ?? VariableScopeNames.Topic].TryGetValue(name, out FormulaValue? value)) { return value; } @@ -76,13 +83,13 @@ public FormulaValue Get(string name, WorkflowScopeType? type = null) return FormulaValue.NewBlank(); } - public void Clear(WorkflowScopeType type) => this._scopes[type].Clear(); + public void Clear(string scopeName) => this._scopes[scopeName].Clear(); - public void Remove(string name) => this.Remove(name, WorkflowScopeType.Topic); + public void Remove(string name) => this.Remove(name, VariableScopeNames.Topic); - public void Remove(string name, WorkflowScopeType type) => this._scopes[type].Remove(name); + public void Remove(string name, string scopeName) => this._scopes[scopeName].Remove(name); - public void Set(string name, FormulaValue value) => this.Set(name, WorkflowScopeType.Topic, value); + public void Set(string name, FormulaValue value) => this.Set(name, VariableScopeNames.Topic, value); - public void Set(string name, WorkflowScopeType type, FormulaValue value) => this._scopes[type][name] = value; + public void Set(string name, string scopeName, FormulaValue value) => this._scopes[scopeName][name] = value; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopesType.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopesType.cs deleted file mode 100644 index 692516fd48..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopesType.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using Microsoft.Bot.ObjectModel; - -namespace Microsoft.Agents.Workflows.Declarative.PowerFx; - -/// -/// Describes the type of action scope. -/// -internal sealed class WorkflowScopeType -{ - // https://msazure.visualstudio.com/CCI/_git/ObjectModel?path=/src/ObjectModel/Nodes/VariableScopeNames.cs&_a=contents&version=GBmain - public static readonly WorkflowScopeType Env = new(VariableScopeNames.Environment); - public static readonly WorkflowScopeType Topic = new(VariableScopeNames.Topic); - public static readonly WorkflowScopeType Global = new(VariableScopeNames.Global); - public static readonly WorkflowScopeType System = new(VariableScopeNames.System); - - public static WorkflowScopeType Parse(string? scope) - { - return scope switch - { - nameof(Env) => Env, - nameof(Global) => Global, - nameof(System) => System, - nameof(Topic) => Topic, - null => throw new InvalidScopeException("Undefined action scope type."), - _ => throw new InvalidScopeException($"Unknown action scope type: {scope}."), - }; - } - - private WorkflowScopeType(string name) - { - this.Name = name; - } - - public string Name { get; } - - public string Format(string name) => $"{this.Name}.{name}"; - - public override string ToString() => this.Name; - - public override int GetHashCode() => this.Name.GetHashCode(); - - public override bool Equals(object? obj) => - (obj is WorkflowScopeType other && this.Name.Equals(other.Name, StringComparison.Ordinal)) || - (obj is string name && this.Name.Equals(name, StringComparison.Ordinal)); -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/StateManager.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StateManager.cs index c0b6efbe2b..b563221757 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/StateManager.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/StateManager.cs @@ -71,19 +71,19 @@ public ValueTask WriteStateAsync(ScopeId scopeId, string key, T? value) public async ValueTask PublishUpdatesAsync() { - Dictionary>> updatesByScope = new(); + Dictionary>> updatesByScope = []; // Aggregate the updates for each scope foreach (UpdateKey key in this._queuedUpdates.Keys) { if (!updatesByScope.TryGetValue(key.ScopeId, out Dictionary>? scopeUpdates)) { - updatesByScope[key.ScopeId] = scopeUpdates = new(); + updatesByScope[key.ScopeId] = scopeUpdates = []; } if (!scopeUpdates.TryGetValue(key.Key, out List? stateUpdates)) { - scopeUpdates[key.Key] = stateUpdates = new(); + scopeUpdates[key.Key] = stateUpdates = []; } stateUpdates.Add(this._queuedUpdates[key]); diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/StateScope.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StateScope.cs index b832a9bb4a..c64e1e1978 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/StateScope.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/StateScope.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -43,19 +44,19 @@ public ValueTask WriteStateAsync(Dictionary> updates) continue; } - if (updates[key].Count > 1) - { - throw new InvalidOperationException($"Expected exactly one update for key '{key}'."); - } + //if (updates[key].Count > 1) // %%% HAXX: SUPERSTEP STATE MANAGEMENT + //{ + // throw new InvalidOperationException($"Expected exactly one update for key '{key}'."); + //} - StateUpdate upadte = updates[key][0]; - if (upadte.IsDelete) + StateUpdate update = updates[key].Last(); + if (update.IsDelete) { this._stateData.Remove(key); } else { - this._stateData[key] = upadte.Value!; + this._stateData[key] = update.Value!; } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs index 9a4a2dffd2..9d6b1cd814 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs @@ -49,30 +49,4 @@ public interface IWorkflowContext /// used. /// A ValueTask that represents the asynchronous update operation. ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null); - - /// - /// 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. - /// The name of the scope. - /// A representing the asynchronous operation. - ValueTask ReadWorkflowStateAsync(string key, string? scopeName = null); // %%% HAXX: _WORKFLOW_ STATE - - /// - /// Asynchronously updates the state of a queue entry identified by the specified key and optional scope. - /// - /// - /// Subsequent reads by this executor will result in the new value of the state. Other executors will only see - /// the new state starting from the next SuperStep. - /// - /// The type of the value to associate with the queue entry. - /// The unique identifier for the queue entry to update. Cannot be null or empty. - /// The value to set for the queue entry. If null, the entry's state may be cleared or reset depending on - /// implementation. - /// An optional name that specifies the scope within which the queue entry resides. If null, the default scope is - /// used. - /// A ValueTask that represents the asynchronous update operation. - ValueTask QueueWorkflowStateUpdateAsync(string key, T? value, string? scopeName = null); // %%% HAXX: _WORKFLOW_ STATE } diff --git a/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs index ce030109da..37aa1bfb4b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs @@ -91,8 +91,6 @@ public ValueTask PostAsync(ExternalRequest request) private class BoundContext(InProcessRunnerContext RunnerContext, string ExecutorId) : IWorkflowContext { - private const string WorkflowId = "__workflow__"; - public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => RunnerContext.AddEventAsync(workflowEvent); public ValueTask SendMessageAsync(object message) => RunnerContext.SendMessageAsync(ExecutorId, message); @@ -101,11 +99,5 @@ public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeNam public ValueTask ReadStateAsync(string key, string? scopeName = null) => RunnerContext.StateManager.ReadStateAsync(ExecutorId, scopeName, key); - - public ValueTask QueueWorkflowStateUpdateAsync(string key, T? value, string? scopeName = null) // %%% HAXX: _WORKFLOW_ STATE - => RunnerContext.StateManager.WriteStateAsync(WorkflowId, scopeName, key, value); - - public ValueTask ReadWorkflowStateAsync(string key, string? scopeName = null) // %%% HAXX: _WORKFLOW_ STATE - => RunnerContext.StateManager.ReadStateAsync(WorkflowId, scopeName, key); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs index 566ba3dd03..f7f96c0b36 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs @@ -3,7 +3,6 @@ using System; using System.Linq; using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.ObjectModel; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Agents.Workflows.Declarative.PowerFx; @@ -41,19 +40,19 @@ internal void VerifyModel(DialogAction model, WorkflowActionExecutor action) Assert.Equal(model, action.Model); } - protected void VerifyState(string variableName, FormulaValue expectedValue) => this.VerifyState(variableName, WorkflowScopeType.Topic, expectedValue); + protected void VerifyState(string variableName, FormulaValue expectedValue) => this.VerifyState(variableName, VariableScopeNames.Topic, expectedValue); - internal void VerifyState(string variableName, WorkflowScopeType scope, FormulaValue expectedValue) + internal void VerifyState(string variableName, string scopeName, FormulaValue expectedValue) { - FormulaValue actualValue = this.Scopes.Get(variableName, scope); + FormulaValue actualValue = this.Scopes.Get(variableName, scopeName); Assert.Equal(expectedValue.Format(), actualValue.Format()); } - protected void VerifyUndefined(string variableName) => this.VerifyUndefined(variableName, WorkflowScopeType.Topic); + protected void VerifyUndefined(string variableName) => this.VerifyUndefined(variableName, VariableScopeNames.Topic); - internal void VerifyUndefined(string variableName, WorkflowScopeType scope) + internal void VerifyUndefined(string variableName, string scopeName) { - Assert.IsType(this.Scopes.Get(variableName, scope)); + Assert.IsType(this.Scopes.Get(variableName, scopeName)); } protected TAction AssignParent(DialogAction.Builder actionBuilder) where TAction : DialogAction diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs index f20aadd172..e6b30410d7 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs @@ -34,15 +34,15 @@ private static class Variables public WorkflowExpressionEngineTests(ITestOutputHelper output) : base(output) { - this.Scopes.Set(Variables.GlobalValue, WorkflowScopeType.Global, FormulaValue.New(255)); - this.Scopes.Set(Variables.BoolValue, WorkflowScopeType.Topic, FormulaValue.New(true)); - this.Scopes.Set(Variables.StringValue, WorkflowScopeType.Topic, FormulaValue.New("Hello World")); - this.Scopes.Set(Variables.IntValue, WorkflowScopeType.Topic, FormulaValue.New(long.MaxValue)); - this.Scopes.Set(Variables.NumberValue, WorkflowScopeType.Topic, FormulaValue.New(33.3)); - this.Scopes.Set(Variables.EnumValue, WorkflowScopeType.Topic, FormulaValue.New(nameof(VariablesToClear.ConversationScopedVariables))); - this.Scopes.Set(Variables.ObjectValue, WorkflowScopeType.Topic, ObjectData); - this.Scopes.Set(Variables.ArrayValue, WorkflowScopeType.Topic, TableData); - this.Scopes.Set(Variables.BlankValue, WorkflowScopeType.Topic, FormulaValue.NewBlank()); + this.Scopes.Set(Variables.GlobalValue, VariableScopeNames.Global, FormulaValue.New(255)); + this.Scopes.Set(Variables.BoolValue, VariableScopeNames.Topic, FormulaValue.New(true)); + this.Scopes.Set(Variables.StringValue, VariableScopeNames.Topic, FormulaValue.New("Hello World")); + this.Scopes.Set(Variables.IntValue, VariableScopeNames.Topic, FormulaValue.New(long.MaxValue)); + this.Scopes.Set(Variables.NumberValue, VariableScopeNames.Topic, FormulaValue.New(33.3)); + this.Scopes.Set(Variables.EnumValue, VariableScopeNames.Topic, FormulaValue.New(nameof(VariablesToClear.ConversationScopedVariables))); + this.Scopes.Set(Variables.ObjectValue, VariableScopeNames.Topic, ObjectData); + this.Scopes.Set(Variables.ArrayValue, VariableScopeNames.Topic, TableData); + this.Scopes.Set(Variables.BlankValue, VariableScopeNames.Topic, FormulaValue.NewBlank()); } #region Unsupported Expression Tests @@ -184,7 +184,7 @@ public void StringExpressionGetValueForRecord() { // Arrange RecordValue state = FormulaValue.NewRecordFromFields([new NamedValue("test", FormulaValue.New("value"))]); - this.Scopes.Set("TestRecord", WorkflowScopeType.Global, state); + this.Scopes.Set("TestRecord", VariableScopeNames.Global, state); // Arrange, Act & Assert this.EvaluateExpression( diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopeTypeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopeTypeTest.cs deleted file mode 100644 index 2639a103cb..0000000000 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopeTypeTest.cs +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.Workflows.Declarative.PowerFx; -using Microsoft.Bot.ObjectModel; - -namespace Microsoft.Agents.Workflows.Declarative.UnitTests.PowerFx; - -public class WorkflowScopeTypeTests -{ - [Fact] - public void StaticFieldsHaveCorrectNames() - { - Assert.Equal(VariableScopeNames.Environment, WorkflowScopeType.Env.Name); - Assert.Equal(VariableScopeNames.Topic, WorkflowScopeType.Topic.Name); - Assert.Equal(VariableScopeNames.Global, WorkflowScopeType.Global.Name); - Assert.Equal(VariableScopeNames.System, WorkflowScopeType.System.Name); - } - - [Fact] - public void ParseReturnsCorrectScopeType() - { - WorkflowScopeType envScope = WorkflowScopeType.Parse("Env"); - WorkflowScopeType topicScope = WorkflowScopeType.Parse("Topic"); - WorkflowScopeType globalScope = WorkflowScopeType.Parse("Global"); - WorkflowScopeType systemScope = WorkflowScopeType.Parse("System"); - - Assert.Same(WorkflowScopeType.Env, envScope); - Assert.Same(WorkflowScopeType.Topic, topicScope); - Assert.Same(WorkflowScopeType.Global, globalScope); - Assert.Same(WorkflowScopeType.System, systemScope); - } - - [Fact] - public void ParseThrowsForNullScope() - { - InvalidScopeException exception = Assert.Throws(() => WorkflowScopeType.Parse(null)); - Assert.Equal("Undefined action scope type.", exception.Message); - } - - [Fact] - public void ParseThrowsForUnknownScope() - { - string unknownScope = "Unknown"; - InvalidScopeException exception = Assert.Throws(() => WorkflowScopeType.Parse(unknownScope)); - Assert.Equal($"Unknown action scope type: {unknownScope}.", exception.Message); - } - - [Fact] - public void FormatReturnsScopedName() - { - string variableName = "myVariable"; - - string formattedEnv = WorkflowScopeType.Env.Format(variableName); - string formattedTopic = WorkflowScopeType.Topic.Format(variableName); - string formattedGlobal = WorkflowScopeType.Global.Format(variableName); - string formattedSystem = WorkflowScopeType.System.Format(variableName); - - Assert.Equal($"{VariableScopeNames.Environment}.{variableName}", formattedEnv); - Assert.Equal($"{VariableScopeNames.Topic}.{variableName}", formattedTopic); - Assert.Equal($"{VariableScopeNames.Global}.{variableName}", formattedGlobal); - Assert.Equal($"{VariableScopeNames.System}.{variableName}", formattedSystem); - } - - [Fact] - public void ToStringReturnsName() - { - Assert.Equal(VariableScopeNames.Environment, WorkflowScopeType.Env.ToString()); - Assert.Equal(VariableScopeNames.Topic, WorkflowScopeType.Topic.ToString()); - Assert.Equal(VariableScopeNames.Global, WorkflowScopeType.Global.ToString()); - Assert.Equal(VariableScopeNames.System, WorkflowScopeType.System.ToString()); - } - - [Fact] - public void GetHashCodeReturnsNameHashCode() - { - Assert.Equal(VariableScopeNames.Environment.GetHashCode(), WorkflowScopeType.Env.GetHashCode()); - Assert.Equal(VariableScopeNames.Topic.GetHashCode(), WorkflowScopeType.Topic.GetHashCode()); - Assert.Equal(VariableScopeNames.Global.GetHashCode(), WorkflowScopeType.Global.GetHashCode()); - Assert.Equal(VariableScopeNames.System.GetHashCode(), WorkflowScopeType.System.GetHashCode()); - } - - [Fact] - public void EqualsReturnsTrueForSameType() - { - Assert.True(WorkflowScopeType.Env.Equals(WorkflowScopeType.Env)); - Assert.False(WorkflowScopeType.Env.Equals(WorkflowScopeType.Topic)); - } - - [Fact] - public void EqualsReturnsTrueForMatchingString() - { - Assert.True(WorkflowScopeType.Env.Equals(VariableScopeNames.Environment)); - Assert.False(WorkflowScopeType.Env.Equals(VariableScopeNames.Topic)); - } - - [Fact] - public void EqualsReturnsFalseForNonMatchingTypes() - { - Assert.False(WorkflowScopeType.Env.Equals(42)); - Assert.False(WorkflowScopeType.Env.Equals(null)); - } -} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs index a31d4afb21..e25c3241c9 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs @@ -2,6 +2,7 @@ using System.Linq; using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx.Types; namespace Microsoft.Agents.Workflows.Declarative.UnitTests.PowerFx; @@ -15,10 +16,10 @@ public void ConstructorInitializesAllScopes() WorkflowScopes scopes = new(); // Assert - RecordValue envRecord = scopes.BuildRecord(WorkflowScopeType.Env); - RecordValue topicRecord = scopes.BuildRecord(WorkflowScopeType.Topic); - RecordValue globalRecord = scopes.BuildRecord(WorkflowScopeType.Global); - RecordValue systemRecord = scopes.BuildRecord(WorkflowScopeType.System); + RecordValue envRecord = scopes.BuildRecord(VariableScopeNames.Environment); + RecordValue topicRecord = scopes.BuildRecord(VariableScopeNames.Topic); + RecordValue globalRecord = scopes.BuildRecord(VariableScopeNames.Global); + RecordValue systemRecord = scopes.BuildRecord(VariableScopeNames.System); Assert.NotNull(envRecord); Assert.NotNull(topicRecord); @@ -33,7 +34,7 @@ public void BuildRecordWhenEmpty() WorkflowScopes scopes = new(); // Act - RecordValue record = scopes.BuildRecord(WorkflowScopeType.Topic); + RecordValue record = scopes.BuildRecord(VariableScopeNames.Topic); // Assert Assert.NotNull(record); @@ -46,10 +47,10 @@ public void BuildRecordContainsSetValues() // Arrange WorkflowScopes scopes = new(); FormulaValue testValue = FormulaValue.New("test"); - scopes.Set("key1", WorkflowScopeType.Topic, testValue); + scopes.Set("key1", VariableScopeNames.Topic, testValue); // Act - RecordValue record = scopes.BuildRecord(WorkflowScopeType.Topic); + RecordValue record = scopes.BuildRecord(VariableScopeNames.Topic); // Assert Assert.NotNull(record); @@ -66,20 +67,20 @@ public void BuildRecordForAllScopeTypes() FormulaValue testValue = FormulaValue.New("test"); // Act & Assert - scopes.Set("envKey", WorkflowScopeType.Env, testValue); - RecordValue envRecord = scopes.BuildRecord(WorkflowScopeType.Env); + scopes.Set("envKey", VariableScopeNames.Environment, testValue); + RecordValue envRecord = scopes.BuildRecord(VariableScopeNames.Environment); Assert.Single(envRecord.Fields); - scopes.Set("topicKey", WorkflowScopeType.Topic, testValue); - RecordValue topicRecord = scopes.BuildRecord(WorkflowScopeType.Topic); + scopes.Set("topicKey", VariableScopeNames.Topic, testValue); + RecordValue topicRecord = scopes.BuildRecord(VariableScopeNames.Topic); Assert.Single(topicRecord.Fields); - scopes.Set("globalKey", WorkflowScopeType.Global, testValue); - RecordValue globalRecord = scopes.BuildRecord(WorkflowScopeType.Global); + scopes.Set("globalKey", VariableScopeNames.Global, testValue); + RecordValue globalRecord = scopes.BuildRecord(VariableScopeNames.Global); Assert.Single(globalRecord.Fields); - scopes.Set("systemKey", WorkflowScopeType.System, testValue); - RecordValue systemRecord = scopes.BuildRecord(WorkflowScopeType.System); + scopes.Set("systemKey", VariableScopeNames.System, testValue); + RecordValue systemRecord = scopes.BuildRecord(VariableScopeNames.System); Assert.Single(systemRecord.Fields); } @@ -89,7 +90,7 @@ public void GetWithImplicitScope() // Arrange WorkflowScopes scopes = new(); FormulaValue testValue = FormulaValue.New("test"); - scopes.Set("key1", WorkflowScopeType.Topic, testValue); + scopes.Set("key1", VariableScopeNames.Topic, testValue); // Act FormulaValue result = scopes.Get("key1"); @@ -104,10 +105,10 @@ public void GetWithSpecifiedScope() // Arrange WorkflowScopes scopes = new(); FormulaValue testValue = FormulaValue.New("test"); - scopes.Set("key1", WorkflowScopeType.Global, testValue); + scopes.Set("key1", VariableScopeNames.Global, testValue); // Act - FormulaValue result = scopes.Get("key1", WorkflowScopeType.Global); + FormulaValue result = scopes.Get("key1", VariableScopeNames.Global); // Assert Assert.Equal(testValue, result); @@ -124,7 +125,7 @@ public void SetDefaultScope() scopes.Set("key1", testValue); // Assert - FormulaValue result = scopes.Get("key1", WorkflowScopeType.Topic); + FormulaValue result = scopes.Get("key1", VariableScopeNames.Topic); Assert.Equal(testValue, result); } @@ -136,10 +137,10 @@ public void SetSpecifiedScope() FormulaValue testValue = FormulaValue.New("test"); // Act - scopes.Set("key1", WorkflowScopeType.System, testValue); + scopes.Set("key1", VariableScopeNames.System, testValue); // Assert - FormulaValue result = scopes.Get("key1", WorkflowScopeType.System); + FormulaValue result = scopes.Get("key1", VariableScopeNames.System); Assert.Equal(testValue, result); } @@ -152,11 +153,11 @@ public void SetOverwritesExistingValue() FormulaValue newValue = FormulaValue.New("new"); // Act - scopes.Set("key1", WorkflowScopeType.Topic, initialValue); - scopes.Set("key1", WorkflowScopeType.Topic, newValue); + scopes.Set("key1", VariableScopeNames.Topic, initialValue); + scopes.Set("key1", VariableScopeNames.Topic, newValue); // Assert - FormulaValue result = scopes.Get("key1", WorkflowScopeType.Topic); + FormulaValue result = scopes.Get("key1", VariableScopeNames.Topic); Assert.Equal(newValue, result); } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowTest.cs index 9f35353139..7c0ad46d0e 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/WorkflowTest.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using Microsoft.Agents.Workflows.Declarative.PowerFx; +using Microsoft.Bot.ObjectModel; using Xunit.Abstractions; namespace Microsoft.Agents.Workflows.Declarative.UnitTests; @@ -33,5 +33,5 @@ protected virtual void Dispose(bool isDisposing) } } - internal static string FormatVariablePath(string variableName, WorkflowScopeType? scope = null) => $"{scope ?? WorkflowScopeType.Topic}.{variableName}"; + internal static string FormatVariablePath(string variableName, string? scope = null) => $"{scope ?? VariableScopeNames.Topic}.{variableName}"; } From f92ff707fd2444f3abdb83892298855e43fce015 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 19 Aug 2025 10:07:59 -0700 Subject: [PATCH 172/232] Refine cleanup --- .../PowerFx/WorkflowScopes.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs index 7c81d58310..afd25568bc 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs @@ -59,10 +59,10 @@ public void Bind(RecalcEngine engine, string? type = null) } else { - Bind(VariableScopeNames.Topic); - Bind(VariableScopeNames.Global); - Bind(VariableScopeNames.Environment); - Bind(VariableScopeNames.System); + foreach (string scopeName in VariableScopeNames.AllScopes) + { + Bind(scopeName); + } } void Bind(string scopeName) From e60961838636a28df1863dd950c4e00168f4d431 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 19 Aug 2025 11:14:31 -0700 Subject: [PATCH 173/232] Fill gaps --- .../Extensions/DataValueExtensions.cs | 10 ++ .../Extensions/RecordDataTypeExtensions.cs | 14 ++- .../Interpreter/WorkflowActionVisitor.cs | 4 +- .../ObjectModel/EditTableExecutor.cs | 91 +++++++++++++++++++ .../ObjectModel/EditTableV2Executor.cs | 21 ++++- 5 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs index 0bcf709be4..f9533ca979 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs @@ -53,6 +53,16 @@ public static RecordValue ToRecordValue(this RecordDataValue recordDataValue) => recordDataValue.Properties.Select( property => new NamedValue(property.Key, property.Value.ToFormulaValue()))); + public static RecordType ToRecordType(this RecordDataType record) + { + RecordType recordType = RecordType.Empty(); + foreach (KeyValuePair property in record.Properties) + { + recordType.Add(property.Key, property.Value.Type.ToFormulaType()); + } + return recordType; + } + public static RecordType ParseRecordType(this RecordDataValue record) { RecordType recordType = RecordType.Empty(); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs index 4a3abaaac8..7dc5a46aa7 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Linq; using System.Text.Json; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx.Types; @@ -28,11 +29,20 @@ IEnumerable ParseValues() DateDataType => DateValue.New(propertyElement.GetDateTime()), TimeDataType => TimeValue.New(propertyElement.GetDateTimeOffset().TimeOfDay), RecordDataType recordType => recordType.ParseRecord(propertyElement), - //TableDataType tableType => FormulaValue.NewTable(propertyElement.EnumerateArray().Select(item => // %%% SUPPORT: Table ))) - _ => throw new UnknownDataTypeException($"Unsupported data type '{property.Value.Type}' for property '{property.Key}'") + TableDataType tableType => ParseTable(tableType, propertyElement), + _ => throw new UnknownDataTypeException($"Unsupported data type '{property.Value.Type}' for property '{property.Key}'"), }; yield return new NamedValue(property.Key, parsedValue); } + + static TableValue ParseTable(TableDataType tableType, JsonElement propertyElement) + { + RecordDataType recordType = tableType.ToRecord(); + return + FormulaValue.NewTable( + recordType.ToRecordType(), + propertyElement.EnumerateArray().Select(tableElement => ParseRecord(recordType, tableElement))); + } } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 99f1f6b5db..218803a7ec 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -225,9 +225,11 @@ protected override void Visit(ResetVariable item) this.ContinueWith(new ResetVariableExecutor(item)); } - protected override void Visit(EditTable item) // %%% SUPPORT: EditTable + protected override void Visit(EditTable item) { this.Trace(item); + + this.ContinueWith(new EditTableExecutor(item)); } protected override void Visit(EditTableV2 item) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs new file mode 100644 index 0000000000..07b9cfd44d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; + +internal sealed class EditTableExecutor(EditTable model) : DeclarativeActionExecutor(model) +{ + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + PropertyPath variablePath = Throw.IfNull(this.Model.ItemsVariable?.Path, $"{nameof(this.Model)}.{nameof(this.Model.ItemsVariable)}"); + + FormulaValue table = this.State.Get(variablePath); + if (table is not TableValue tableValue) + { + throw new WorkflowExecutionException($"Require '{variablePath.Format()}' to be a table, not: '{table.GetType().Name}'."); + } + + TableChangeType changeType = this.Model.ChangeType.Value; + switch (this.Model.ChangeType.Value) + { + case TableChangeType.Add: + ValueExpression addItemValue = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); + EvaluationResult addResult = this.State.ExpressionEngine.GetValue(addItemValue); + RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), addResult.Value.ToFormulaValue()); + await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); + this.AssignTarget(variablePath, newRecord); + break; + case TableChangeType.Remove: + ValueExpression removeItemValue = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); + EvaluationResult removeResult = this.State.ExpressionEngine.GetValue(removeItemValue); + if (removeResult.Value is TableDataValue removeItemTable) + { + await tableValue.RemoveAsync(removeItemTable?.Values.Select(row => row.ToRecordValue()), all: true, cancellationToken).ConfigureAwait(false); + this.AssignTarget(variablePath, RecordValue.Empty()); + } + break; + case TableChangeType.Clear: + await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false); + this.AssignTarget(variablePath, FormulaValue.NewBlank()); + break; + case TableChangeType.TakeFirst: + RecordValue? firstRow = tableValue.Rows.FirstOrDefault()?.Value; + if (firstRow is not null) + { + await tableValue.RemoveAsync([firstRow], all: true, cancellationToken).ConfigureAwait(false); + this.AssignTarget(variablePath, firstRow); + } + break; + case TableChangeType.TakeLast: + RecordValue? lastRow = tableValue.Rows.LastOrDefault()?.Value; + if (lastRow is not null) + { + await tableValue.RemoveAsync([lastRow], all: true, cancellationToken).ConfigureAwait(false); + this.AssignTarget(variablePath, lastRow); + } + break; + } + + return default; + + static RecordValue BuildRecord(RecordType recordType, FormulaValue value) + { + return FormulaValue.NewRecordFromFields(recordType, GetValues()); + + IEnumerable GetValues() + { + foreach (NamedFormulaType fieldType in recordType.GetFieldTypes()) + { + if (value is RecordValue recordValue) + { + yield return new NamedValue(fieldType.Name, recordValue.GetField(fieldType.Name)); + } + else + { + yield return new NamedValue(fieldType.Name, value); + } + } + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs index 5c740da9d7..9a84f450eb 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs @@ -32,12 +32,12 @@ internal sealed class EditTableV2Executor(EditTableV2 model) : DeclarativeAction EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(addItemValue); RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), expressionResult.Value.ToFormulaValue()); await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); - this.AssignTarget(variablePath, tableValue); + this.AssignTarget(variablePath, newRecord); } else if (changeType is ClearItemsOperation) { await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false); - this.AssignTarget(variablePath, tableValue); + this.AssignTarget(variablePath, FormulaValue.NewBlank()); } else if (changeType is RemoveItemOperation removeItemOperation) { @@ -46,11 +46,26 @@ internal sealed class EditTableV2Executor(EditTableV2 model) : DeclarativeAction if (expressionResult.Value.ToFormulaValue() is TableValue removeItemTable) { await tableValue.RemoveAsync(removeItemTable?.Rows.Select(row => row.Value), all: true, cancellationToken).ConfigureAwait(false); + this.AssignTarget(variablePath, FormulaValue.NewBlank()); + } + } + else if (changeType is TakeLastItemOperation) + { + RecordValue? lastRow = tableValue.Rows.LastOrDefault()?.Value; + if (lastRow is not null) + { + await tableValue.RemoveAsync([lastRow], all: true, cancellationToken).ConfigureAwait(false); + this.AssignTarget(variablePath, lastRow); } } else if (changeType is TakeFirstItemOperation) { - this.AssignTarget(variablePath, tableValue.Rows.First().Value); // %%% TABLE OR RECORD ??? + RecordValue? firstRow = tableValue.Rows.FirstOrDefault()?.Value; + if (firstRow is not null) + { + await tableValue.RemoveAsync([firstRow], all: true, cancellationToken).ConfigureAwait(false); + this.AssignTarget(variablePath, firstRow); + } } return default; From de3ff8c1edae33d4c2dfbadec4d19900e905805e Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 19 Aug 2025 11:16:18 -0700 Subject: [PATCH 174/232] fcs --- .../StateSmokeTest.cs | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs index 1aeff7da3c..24ed29d1ad 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs @@ -103,17 +103,6 @@ public async Task Test_ConflictingWritesRaiseExceptionAsync() Assert.Equal(Value2, await manager.ReadStateAsync(sharedScope2, Key)); // Try to publish the updates - try - { - await manager.PublishUpdatesAsync(); - Assert.Fail("Expected InvalidOperationException due to conflicting writes."); - } - catch (InvalidOperationException) - { - } - catch (Exception ex) - { - Assert.Fail($"Expected InvalidOperationException, but got {ex.GetType().Name}."); - } + await Assert.ThrowsAsync(() => manager.PublishUpdatesAsync().AsTask()); } } From c1c02dd8aa7df49ce463a7f368e3d4393c660428 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 19 Aug 2025 12:11:42 -0700 Subject: [PATCH 175/232] Finalize data-types --- .../Extensions/FormulaValueExtensions.cs | 58 +++++++++++++------ 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs index 6c9f5cc0f0..a23db8a559 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -30,16 +30,28 @@ public static DataValue ToDataValue(this FormulaValue value) => VoidValue voidValue => voidValue.ToDataValue(), TableValue tableValue => tableValue.ToDataValue(), RecordValue recordValue => recordValue.ToDataValue(), - // %%% SUPPORT: DataType ??? - //ColorValue - //GuidValue guidValue => guidValue.ToDataValue(), - //BlobValue => - //ErrorValue => _ => throw new NotSupportedException($"Unsupported FormulaValue type: {value.GetType().Name}"), }; public static DataType GetDataType(this FormulaValue value) => - value.Type switch + value switch + { + null => DataType.Blank, + BooleanValue => DataType.Boolean, + DecimalValue => DataType.Number, + NumberValue => DataType.Float, + DateValue => DataType.Date, + DateTimeValue => DataType.DateTime, + TimeValue => DataType.Time, + StringValue => DataType.String, + BlankValue => DataType.Blank, + RecordValue recordValue => recordValue.Type.ToDataType(), + TableValue tableValue => tableValue.Type.ToDataType(), + _ => DataType.Unspecified, + }; + + public static DataType GetDataType(this FormulaType type) => + type switch { null => DataType.Blank, BooleanType => DataType.Boolean, @@ -50,14 +62,8 @@ public static DataType GetDataType(this FormulaValue value) => TimeType => DataType.Time, StringType => DataType.String, BlankType => DataType.Blank, - RecordType => DataType.EmptyRecord, - // %%% SUPPORT: DataType ??? - //TableType - //ColorValue - //GuidType => DataType.String, - //BlobValue => - //ErrorValue => - UnknownType => DataType.Unspecified, + RecordType recordType => recordType.ToDataType(), + TableType tableType => tableType.ToDataType(), _ => DataType.Unspecified, }; @@ -77,7 +83,6 @@ public static string Format(this FormulaValue value) => TableValue tableValue => tableValue.ToJson().ToJsonString(s_options), RecordValue recordValue => recordValue.ToJson().ToJsonString(s_options), ErrorValue errorValue => $"Error:{Environment.NewLine}{string.Join(Environment.NewLine, errorValue.Errors.Select(error => $"{error.MessageKey}: {error.Message}"))}", - //BlobValue blobValue => NO SPECIAL FORMATTING _ => $"[{value.GetType().Name}]", }; @@ -90,8 +95,6 @@ public static string Format(this FormulaValue value) => public static DataValue ToDataValue(this BlankValue _) => BlankDataValue.Blank(); public static DataValue ToDataValue(this VoidValue _) => BlankDataValue.Blank(); public static StringDataValue ToDataValue(this StringValue value) => StringDataValue.Create(value.Value); - //public static StringDataValue ToDataValue(this GuidValue value) => StringDataValue.Create(value.Value.ToString("N")); - //public static StringDataValue ToDataValue(this ColorValue value) => StringDataValue.Create(Enum.GetName(typeof(Color), value.Value)!); public static TableDataValue ToDataValue(this TableValue value) => TableDataValue.TableFromRecords(value.Rows.Select(row => row.Value.ToDataValue()).ToImmutableArray()); @@ -99,6 +102,26 @@ public static TableDataValue ToDataValue(this TableValue value) => public static RecordDataValue ToDataValue(this RecordValue value) => RecordDataValue.RecordFromFields(value.OriginalFields.Select(field => field.GetKeyValuePair()).ToImmutableArray()); + public static RecordDataType ToDataType(this RecordType record) + { + RecordDataType recordType = new(); + foreach (string fieldName in record.FieldNames) + { + recordType.Properties.Add(fieldName, PropertyInfo.Create(record.GetFieldType(fieldName).GetDataType())); + } + return recordType; + } + + public static TableDataType ToDataType(this TableType table) + { + TableDataType tableType = new(); + foreach (string fieldName in table.FieldNames) + { + tableType.Properties.Add(fieldName, PropertyInfo.Create(table.GetFieldType(fieldName).GetDataType())); + } + return tableType; + } + private static KeyValuePair GetKeyValuePair(this NamedValue value) => new(value.Name, value.Value.ToDataValue()); public static JsonNode ToJson(this FormulaValue value) => @@ -117,7 +140,6 @@ public static JsonNode ToJson(this FormulaValue value) => BlankValue blankValue => JsonValue.Create(string.Empty), //VoidValue voidValue => JsonValue.Create(), //ErrorValue errorValue => $"Error:{Environment.NewLine}{string.Join(Environment.NewLine, errorValue.Errors.Select(error => $"{error.MessageKey}: {error.Message}"))}", - //BlobValue blobValue => NO SPECIAL FORMATTING _ => $"[{value.GetType().Name}]", }; From 9d17d30241a1fee245bfc3e42d3078a405ffaecc Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 19 Aug 2025 12:22:17 -0700 Subject: [PATCH 176/232] Add unit-test --- .../DeclarativeWorkflowTest.cs | 1 + .../ClearAllVariablesExecutorTest.cs | 2 +- .../ParseValueExecutorTest.cs | 2 +- .../ResetVariableExecutorTest.cs | 2 +- .../SendActivityExecutorTest.cs | 2 +- .../SetTextVariableExecutorTest.cs | 2 +- .../SetVariableExecutorTest.cs | 2 +- .../WorkflowActionExecutorTest.cs | 2 +- .../Workflows/EditTable.yaml | 7 +++---- .../Workflows/EditTableV2.yaml | 16 ++++++++++++++++ 10 files changed, 27 insertions(+), 11 deletions(-) rename dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/{Execution => ObjectModel}/ClearAllVariablesExecutorTest.cs (96%) rename dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/{Execution => ObjectModel}/ParseValueExecutorTest.cs (97%) rename dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/{Execution => ObjectModel}/ResetVariableExecutorTest.cs (96%) rename dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/{Execution => ObjectModel}/SendActivityExecutorTest.cs (95%) rename dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/{Execution => ObjectModel}/SetTextVariableExecutorTest.cs (96%) rename dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/{Execution => ObjectModel}/SetVariableExecutorTest.cs (98%) rename dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/{Execution => ObjectModel}/WorkflowActionExecutorTest.cs (97%) create mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/EditTableV2.yaml diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index 7354b050c8..0e0efcb539 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -132,6 +132,7 @@ public async Task ConditionActionWithElse(int input, int expectedActions) [Theory] [InlineData("Single.yaml", 1, "end_all")] [InlineData("EditTable.yaml", 2, "edit_var")] + [InlineData("EditTableV2.yaml", 2, "edit_var")] [InlineData("ParseValue.yaml", 1, "parse_var")] [InlineData("SetTextVariable.yaml", 1, "set_text")] [InlineData("ClearAllVariables.yaml", 1, "clear_all")] diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ClearAllVariablesExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ClearAllVariablesExecutorTest.cs similarity index 96% rename from dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ClearAllVariablesExecutorTest.cs rename to dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ClearAllVariablesExecutorTest.cs index 49d97340e9..5eee7a31e4 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ClearAllVariablesExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ClearAllVariablesExecutorTest.cs @@ -6,7 +6,7 @@ using Microsoft.PowerFx.Types; using Xunit.Abstractions; -namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Execution; +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.ObjectModel; /// /// Tests for . diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ParseValueExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ParseValueExecutorTest.cs similarity index 97% rename from dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ParseValueExecutorTest.cs rename to dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ParseValueExecutorTest.cs index 4099486cf1..b22a49c01a 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ParseValueExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ParseValueExecutorTest.cs @@ -6,7 +6,7 @@ using Microsoft.PowerFx.Types; using Xunit.Abstractions; -namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Execution; +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.ObjectModel; /// /// Tests for . diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ResetVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ResetVariableExecutorTest.cs similarity index 96% rename from dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ResetVariableExecutorTest.cs rename to dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ResetVariableExecutorTest.cs index 2418582e3b..ca058cdc66 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/ResetVariableExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ResetVariableExecutorTest.cs @@ -6,7 +6,7 @@ using Microsoft.PowerFx.Types; using Xunit.Abstractions; -namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Execution; +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.ObjectModel; /// /// Tests for . diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs similarity index 95% rename from dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs rename to dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs index f3f60a2f18..0edefb14cf 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SendActivityExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs @@ -6,7 +6,7 @@ using Microsoft.Bot.ObjectModel; using Xunit.Abstractions; -namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Execution; +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.ObjectModel; /// /// Tests for . diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetTextVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetTextVariableExecutorTest.cs similarity index 96% rename from dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetTextVariableExecutorTest.cs rename to dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetTextVariableExecutorTest.cs index 4153feaa37..3cf9c2c85c 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetTextVariableExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetTextVariableExecutorTest.cs @@ -6,7 +6,7 @@ using Microsoft.PowerFx.Types; using Xunit.Abstractions; -namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Execution; +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.ObjectModel; /// /// Tests for . diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetVariableExecutorTest.cs similarity index 98% rename from dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetVariableExecutorTest.cs rename to dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetVariableExecutorTest.cs index c534632987..b282c46feb 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/SetVariableExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetVariableExecutorTest.cs @@ -6,7 +6,7 @@ using Microsoft.PowerFx.Types; using Xunit.Abstractions; -namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Execution; +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.ObjectModel; /// /// Tests for . diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs similarity index 97% rename from dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs rename to dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs index f7f96c0b36..d20c3db8e5 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Execution/WorkflowActionExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs @@ -11,7 +11,7 @@ using Microsoft.PowerFx.Types; using Xunit.Abstractions; -namespace Microsoft.Agents.Workflows.Declarative.UnitTests.Execution; +namespace Microsoft.Agents.Workflows.Declarative.UnitTests.ObjectModel; /// /// Base test class for implementations. diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/EditTable.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/EditTable.yaml index 7ecda64328..8f6910a7b5 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/EditTable.yaml +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/EditTable.yaml @@ -8,9 +8,8 @@ beginDialog: id: set_var variable: Topic.MyTable value: =[{id: 3}] - - kind: EditTableV2 + - kind: EditTable id: edit_var itemsVariable: Topic.MyTable - changeType: - kind: AddItemOperation - value: ={id: 7} + changeType: Add + value: ={id: 7} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/EditTableV2.yaml b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/EditTableV2.yaml new file mode 100644 index 0000000000..7ecda64328 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Workflows/EditTableV2.yaml @@ -0,0 +1,16 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: my_workflow + type: Message + actions: + - kind: SetVariable + id: set_var + variable: Topic.MyTable + value: =[{id: 3}] + - kind: EditTableV2 + id: edit_var + itemsVariable: Topic.MyTable + changeType: + kind: AddItemOperation + value: ={id: 7} From d9a178fe7506fbc5db4ce3f33fe0511de55767e9 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 19 Aug 2025 12:37:20 -0700 Subject: [PATCH 177/232] Debug cleanup --- .../HttpInterceptHandler.cs | 4 -- .../Workflows/Workflows_Declarative.cs | 53 +++++++++---------- .../Interpreter/DeclarativeActionExecutor.cs | 1 + .../ObjectModel/ClearAllVariablesExecutor.cs | 21 ++++++-- 4 files changed, 43 insertions(+), 36 deletions(-) diff --git a/dotnet/demos/DeclarativeWorkflow/HttpInterceptHandler.cs b/dotnet/demos/DeclarativeWorkflow/HttpInterceptHandler.cs index 12c997ffee..2ae97487f7 100644 --- a/dotnet/demos/DeclarativeWorkflow/HttpInterceptHandler.cs +++ b/dotnet/demos/DeclarativeWorkflow/HttpInterceptHandler.cs @@ -3,7 +3,6 @@ #if NET using System; -using System.Diagnostics; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -22,15 +21,12 @@ protected override async Task SendAsync(HttpRequestMessage HttpResponseMessage response = await base.SendAsync(request, cancellationToken); // Intercept and modify the response - Debug.WriteLine($"{request.Method} {request.RequestUri}"); string? responseContent = null; if (response.Content != null) { responseContent = await response.Content.ReadAsStringAsync(cancellationToken); response.Content = new StringContent(responseContent); - - Debug.WriteLine($"API:{Environment.NewLine}" + responseContent); } if (this.OnIntercept is not null) diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index e8194c59ca..e45c195e49 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Diagnostics; using Azure.Identity; using Microsoft.Agents.Orchestration; using Microsoft.Agents.Workflows; @@ -21,42 +20,42 @@ public class Workflows_Declarative(ITestOutputHelper output) : OrchestrationSamp //[InlineData("testTopic")] public async Task RunWorkflow(string fileName) { - Debug.WriteLine("WORKFLOW INIT\n"); + Console.WriteLine("WORKFLOW INIT\n"); - ////////////////////////////////////////////////////// - // - // HOW TO: Create a workflow from a YAML file. - // - using StreamReader yamlReader = File.OpenText(@$"{nameof(Workflows)}\{fileName}.yaml"); - // - // DeclarativeWorkflowContext provides the components for workflow execution. - // - DeclarativeWorkflowOptions workflowContext = - new() - { - LoggerFactory = this.LoggerFactory, - ProjectEndpoint = Throw.IfNull(TestConfiguration.AzureAI.Endpoint), - ProjectCredentials = new AzureCliCredential(), - }; - // - // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. - // - Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); - // - ////////////////////////////////////////////////////// + ////////////////////////////////////////////////////// + // + // HOW TO: Create a workflow from a YAML file. + // + using StreamReader yamlReader = File.OpenText(@$"{nameof(Workflows)}\{fileName}.yaml"); + // + // DeclarativeWorkflowContext provides the components for workflow execution. + // + DeclarativeWorkflowOptions workflowContext = + new() + { + LoggerFactory = this.LoggerFactory, + ProjectEndpoint = Throw.IfNull(TestConfiguration.AzureAI.Endpoint), + ProjectCredentials = new AzureCliCredential(), + }; + // + // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. + // + Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); + // + ////////////////////////////////////////////////////// - Debug.WriteLine("\nWORKFLOW INVOKE\n"); + Console.WriteLine("\nWORKFLOW INVOKE\n"); StreamingRun run = await InProcessExecution.StreamAsync(workflow, ""); await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { if (evt is ExecutorInvokeEvent executorInvoked) { - Debug.WriteLine($"!!! ENTER #{executorInvoked.ExecutorId}"); + Console.WriteLine($"!!! ENTER #{executorInvoked.ExecutorId}"); } else if (evt is ExecutorCompleteEvent executorComplete) { - Debug.WriteLine($"!!! EXIT #{executorComplete.ExecutorId}"); + Console.WriteLine($"!!! EXIT #{executorComplete.ExecutorId}"); } else if (evt is DeclarativeWorkflowMessageEvent messageEvent) { @@ -77,6 +76,6 @@ public async Task RunWorkflow(string fileName) } } - Debug.WriteLine("\nWORKFLOW DONE"); + Console.WriteLine("\nWORKFLOW DONE"); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs index 14ed631d10..beb01e68ca 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -93,6 +93,7 @@ public async ValueTask HandleAsync(ExecutionResultMessage message, IWorkflowCont protected void AssignTarget(PropertyPath targetPath, FormulaValue result) { this.State.Set(targetPath, result); + #if DEBUG string? resultValue = result.Format(); string valuePosition = (resultValue?.IndexOf('\n') ?? -1) >= 0 ? Environment.NewLine : " "; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs index e3554c3298..2e863d1230 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Interpreter; @@ -14,16 +15,16 @@ internal sealed class ClearAllVariablesExecutor(ClearAllVariables model) : Decla { EvaluationResult variablesResult = this.State.ExpressionEngine.GetValue(this.Model.Variables); - variablesResult.Value.Handle(new ScopeHandler(this.State)); + variablesResult.Value.Handle(new ScopeHandler(this.Id, this.State)); return default; } - private sealed class ScopeHandler(DeclarativeWorkflowState state) : IEnumVariablesToClearHandler + private sealed class ScopeHandler(string executorId, DeclarativeWorkflowState state) : IEnumVariablesToClearHandler { public void HandleAllGlobalVariables() { - state.Clear(VariableScopeNames.Global); + this.ClearAll(VariableScopeNames.Global); } public void HandleConversationHistory() @@ -33,7 +34,7 @@ public void HandleConversationHistory() public void HandleConversationScopedVariables() { - state.Clear(VariableScopeNames.Topic); + this.ClearAll(VariableScopeNames.Topic); } public void HandleUnknownValue() @@ -43,7 +44,17 @@ public void HandleUnknownValue() public void HandleUserScopedVariables() { - state.Clear(VariableScopeNames.Environment); // %%% DECISION: Is this correct? If not, what? + this.ClearAll(VariableScopeNames.Environment); // %%% DECISION: Is this correct? If not, what? + } + + private void ClearAll(string scope) + { + state.Clear(VariableScopeNames.Global); + Debug.WriteLine( + $""" + !!! CLEAR {this.GetType().Name} [{executorId}] + SCOPE: {VariableScopeNames.Global} + """); } } } From 933b2e6f674ede32a298d0723bb4cbe8dc0c9331 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 19 Aug 2025 14:08:14 -0700 Subject: [PATCH 178/232] Bug fixes --- dotnet/agent-framework-dotnet.slnx | 1 + .../Properties/launchSettings.json | 4 ++ .../Workflows/Expression.CountIf.yaml | 25 ++++++++++ .../Workflows/Expression.CountIfType.yaml | 48 +++++++++++++++++++ .../Workflows/Expression.DropColumns.yaml | 39 +++++++++++++++ .../Workflows/Expression.ForAll.yaml | 39 +++++++++++++++ .../Workflows/Workflows_Declarative.cs | 6 ++- .../Workflows/testExpression.yaml | 47 ------------------ .../Extensions/DataValueExtensions.cs | 25 ++++++++-- .../ObjectModel/ClearAllVariablesExecutor.cs | 4 +- .../DeepResearch.yaml | 2 - workflows/MathChat.yaml | 6 +-- 12 files changed, 186 insertions(+), 60 deletions(-) create mode 100644 dotnet/samples/GettingStarted/Workflows/Expression.CountIf.yaml create mode 100644 dotnet/samples/GettingStarted/Workflows/Expression.CountIfType.yaml create mode 100644 dotnet/samples/GettingStarted/Workflows/Expression.DropColumns.yaml create mode 100644 dotnet/samples/GettingStarted/Workflows/Expression.ForAll.yaml delete mode 100644 dotnet/samples/GettingStarted/Workflows/testExpression.yaml rename dotnet/samples/GettingStarted/Workflows/deepResearch.yaml => workflows/DeepResearch.yaml (99%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index c579199313..a10dda123d 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -19,6 +19,7 @@ + diff --git a/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json b/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json index 6338d2896d..03e5809fcb 100644 --- a/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json +++ b/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json @@ -15,6 +15,10 @@ "MathChat": { "commandName": "Project", "commandLineArgs": "\"MathChat.yaml\" \"What is the formula for Fibonacci sequence?\"" + }, + "Research": { + "commandName": "Project", + "commandLineArgs": "\"DeepResearch.yaml\" \"What is the closest bus-stop to Ishoni grill in Seattle?\"" } } } \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/Expression.CountIf.yaml b/dotnet/samples/GettingStarted/Workflows/Expression.CountIf.yaml new file mode 100644 index 0000000000..a21e785ab6 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Expression.CountIf.yaml @@ -0,0 +1,25 @@ +kind: AdaptiveDialog +beginDialog: + + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + + - kind: SetVariable + id: setVariable_list + variable: Topic.TestList + value: =["zaz", "z3z", "zcz", "zdz", "zez", "zfz"] + + - kind: SendActivity + id: sendActivity_list + activity: "{Topic.TestList}" + + - kind: SetVariable + id: setVariable_result + variable: Topic.TestResult + value: =CountIf(Topic.TestList, !IsBlank(Find("e", Value))) + + - kind: SendActivity + id: sendActivity_result + activity: "{Topic.TestResult}" diff --git a/dotnet/samples/GettingStarted/Workflows/Expression.CountIfType.yaml b/dotnet/samples/GettingStarted/Workflows/Expression.CountIfType.yaml new file mode 100644 index 0000000000..673669d23b --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Expression.CountIfType.yaml @@ -0,0 +1,48 @@ +kind: AdaptiveDialog +beginDialog: + + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + # - kind: SetVariable + # id: setVariable1 + # variable: Topic.TestList + # value: ["zaz", "zbz", "zcz", "zdz", "zez", "zfz"] + + # - kind: ParseValue + # id: setVariable2 + # displayName: Parse ledger response + # variable: Topic.TestList + # valueType: + # kind: Record + # properties: + # key: Number + # value: |- + # =[{"Key": 1}, {"Key": 2}] + + - kind: SetVariable + id: setVariable_list + variable: Topic.TestList + value: |- + =[ + { key: 1 }, + { key: 2 } + ] + + - kind: SendActivity + id: sendActivity_list + activity: "{Topic.TestList}" + + - kind: SetVariable + id: setVariable_result + variable: Topic.TestResult + value: =CountIf(Topic.TestList, key > 1) + #value: =Find("e", "abcdefg") + #value: =CountIf(Topic.TestList, 1) + #value: =CountIf(Topic.TestList, !IsBlank(Find("e", Value))) + #value: =!IsBlank(Topic.TestList) + + - kind: SendActivity + id: sendActivity_result + activity: "{Topic.TestResult}" diff --git a/dotnet/samples/GettingStarted/Workflows/Expression.DropColumns.yaml b/dotnet/samples/GettingStarted/Workflows/Expression.DropColumns.yaml new file mode 100644 index 0000000000..9735b99a34 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Expression.DropColumns.yaml @@ -0,0 +1,39 @@ +kind: AdaptiveDialog +beginDialog: + + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + + - kind: SetVariable + id: setVariable_list + variable: Topic.AgentsTable + value: |- + =[ + { + name: "WeatherAgent", + description: "Able to retrieve weather information", + schema: "cr36e_agentRd2yAT.topic.Deterministic" + }, + { + name: "WebAgent", + description: "Able to perform generic websearches", + schema: "cr36e_agentRd2yAT.topic.WebSearch" + } + ] + + - kind: SendActivity + id: sendActivity_list + activity: "{Topic.AgentsTable}" + + - kind: SetVariable + id: setVariable_names + displayName: Get all names + variable: Topic.AgentNames + value: =DropColumns(Topic.AgentsTable, description, schema) + + + - kind: SendActivity + id: sendActivity_names + activity: "{Topic.AgentNames}" diff --git a/dotnet/samples/GettingStarted/Workflows/Expression.ForAll.yaml b/dotnet/samples/GettingStarted/Workflows/Expression.ForAll.yaml new file mode 100644 index 0000000000..7aedd57dc0 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Expression.ForAll.yaml @@ -0,0 +1,39 @@ +kind: AdaptiveDialog +beginDialog: + + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + + - kind: SetVariable + id: setVariable_list + variable: Topic.AgentsTable + value: |- + =[ + { + name: "WeatherAgent", + description: "Able to retrieve weather information", + schema: "cr36e_agentRd2yAT.topic.Deterministic" + }, + { + name: "WebAgent", + description: "Able to perform generic websearches", + schema: "cr36e_agentRd2yAT.topic.WebSearch" + } + ] + + - kind: SendActivity + id: sendActivity_list + activity: "{Topic.AgentsTable}" + + - kind: SetVariable + id: setVariable_names + displayName: Get all names + variable: Topic.AgentInfo + value: "=Concat(ForAll(Topic.AgentsTable, name & $\": \" & description), Value, \".\\n\\n\")" + + + - kind: SendActivity + id: sendActivity_info + activity: "{Topic.AgentInfo}" diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index e45c195e49..299787c59d 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -16,8 +16,10 @@ namespace Workflows; public class Workflows_Declarative(ITestOutputHelper output) : OrchestrationSample(output) { [Theory] - [InlineData("testExpression")] - //[InlineData("testTopic")] + [InlineData("Expression.CountIf")] + [InlineData("Expression.CountIfType")] + [InlineData("Expression.DropColumns")] + [InlineData("Expression.ForAll")] public async Task RunWorkflow(string fileName) { Console.WriteLine("WORKFLOW INIT\n"); diff --git a/dotnet/samples/GettingStarted/Workflows/testExpression.yaml b/dotnet/samples/GettingStarted/Workflows/testExpression.yaml deleted file mode 100644 index 35151ee25d..0000000000 --- a/dotnet/samples/GettingStarted/Workflows/testExpression.yaml +++ /dev/null @@ -1,47 +0,0 @@ -kind: AdaptiveDialog -beginDialog: - kind: OnActivity - id: activity_xyz123 - type: Message - actions: - # - kind: SetVariable - # id: setVariable1 - # variable: Topic.TestList - # value: ["zaz", "zbz", "zcz", "zdz", "zez", "zfz"] - - - kind: ParseValue - id: setVariable2 - displayName: Parse ledger response - variable: Topic.TestList - valueType: - kind: Record - properties: - key: Number - value: [{"Key": 1}, {"Key": 2}] - - # - kind: SetVariable - # id: setVariable2 - # variable: Topic.TestList - # value: [{"Key": 1}, {"Key": 2}] - - - kind: SetVariable - id: setVariable3 - variable: Topic.Result3 - value: =CountIf(Topic.TestList, Key > 1) - #value: =Find("e", "abcdefg") - #value: =CountIf(Topic.TestList, 1) - #value: =CountIf(Topic.TestList, !IsBlank(Find("e", Value))) -# value: =!IsBlank(Topic.TestList) - -# value: =CountIf(Topic.TestList, !IsBlank(Find("e", Value))) > 0 - #value: =CountIf(Topic.TestList, 1) -# value: =!IsBlank(Topic.TestList) - - # - kind: SendActivity - # id: sendActivity2 - # activity: "Result (CountIf): {Topic.TestResult}" - - - - kind: SendActivity - id: sendActivity3 - activity: "Result (Find): {Topic.Result3}" diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs index f9533ca979..046f3d7bec 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs @@ -27,10 +27,27 @@ public static FormulaValue ToFormulaValue(this DataValue? value) => tableValue.Values.Select(value => value.ToRecordValue())), RecordDataValue recordValue => recordValue.ToRecordValue(), OptionDataValue optionValue => FormulaValue.New(optionValue.Value.Value), - FileDataValue => FormulaValue.NewError(new Microsoft.PowerFx.ExpressionError { Message = $"Unsupported literal type: {nameof(FileDataValue)}" }), _ => FormulaValue.NewError(new Microsoft.PowerFx.ExpressionError { Message = $"Unknown literal type: {value.GetType().Name}" }), }; + public static FormulaType ToFormulaType(this DataValue? value) => + value switch + { + null => FormulaType.Blank, + BlankDataValue => FormulaType.Blank, + BooleanDataValue => FormulaType.Boolean, + NumberDataValue numberValue => FormulaType.Number, + FloatDataValue floatValue => FormulaType.Decimal, + StringDataValue stringValue => FormulaType.String, + DateTimeDataValue dateTimeValue => FormulaType.DateTime, + DateDataValue dateValue => FormulaType.Date, + TimeDataValue timeValue => FormulaType.Time, + TableDataValue tableValue => tableValue.Values.FirstOrDefault()?.ParseRecordType() ?? RecordType.Empty(), + RecordDataValue recordValue => recordValue.ParseRecordType(), + OptionDataValue optionValue => FormulaType.String, + _ => FormulaType.Unknown, + }; + public static FormulaType ToFormulaType(this DataType? type) => type switch { @@ -58,17 +75,17 @@ public static RecordType ToRecordType(this RecordDataType record) RecordType recordType = RecordType.Empty(); foreach (KeyValuePair property in record.Properties) { - recordType.Add(property.Key, property.Value.Type.ToFormulaType()); + recordType = recordType.Add(property.Key, property.Value.Type.ToFormulaType()); } return recordType; } - public static RecordType ParseRecordType(this RecordDataValue record) + private static RecordType ParseRecordType(this RecordDataValue record) { RecordType recordType = RecordType.Empty(); foreach (KeyValuePair property in record.Properties) { - recordType.Add(property.Key, property.Value.GetDataType().ToFormulaType()); + recordType = recordType.Add(property.Key, property.Value.ToFormulaType()); } return recordType; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs index 2e863d1230..46113dc42a 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs @@ -49,11 +49,11 @@ public void HandleUserScopedVariables() private void ClearAll(string scope) { - state.Clear(VariableScopeNames.Global); + state.Clear(scope); Debug.WriteLine( $""" !!! CLEAR {this.GetType().Name} [{executorId}] - SCOPE: {VariableScopeNames.Global} + SCOPE: {scope} """); } } diff --git a/dotnet/samples/GettingStarted/Workflows/deepResearch.yaml b/workflows/DeepResearch.yaml similarity index 99% rename from dotnet/samples/GettingStarted/Workflows/deepResearch.yaml rename to workflows/DeepResearch.yaml index 4763cb1751..dbbe7e76d4 100644 --- a/dotnet/samples/GettingStarted/Workflows/deepResearch.yaml +++ b/workflows/DeepResearch.yaml @@ -1,5 +1,3 @@ -# TaskDialog -# AgentDialog kind: AdaptiveDialog beginDialog: kind: OnActivity diff --git a/workflows/MathChat.yaml b/workflows/MathChat.yaml index 3baa23b886..08ef051e45 100644 --- a/workflows/MathChat.yaml +++ b/workflows/MathChat.yaml @@ -16,7 +16,7 @@ beginDialog: value: 0 - kind: AnswerQuestionWithAI - id: asst_Tq5lpaIHgIbqQSKIpGxNq6A5 # // %%% HAXX + id: asst_jCrWfBd1XrfVXMtpbfyVoIHC # // %%% HAXX displayName: Student userInput: =Topic.Project @@ -30,7 +30,7 @@ beginDialog: value: =Blank() - kind: AnswerQuestionWithAI - id: asst_0d4uQ7HiFCfRXEuiQkspCx2T # // %%% HAXX + id: asst_aArB2g4tFWOTcgmUua062wjM # // %%% HAXX displayName: Teacher userInput: ="" # // %%% HAXX @@ -48,4 +48,4 @@ beginDialog: - kind: GotoAction id: goto_student_agent - actionId: asst_Tq5lpaIHgIbqQSKIpGxNq6A5 + actionId: asst_jCrWfBd1XrfVXMtpbfyVoIHC From aa134ee6d71abe27635d814fde27ad00309e0194 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Wed, 20 Aug 2025 09:10:12 -0700 Subject: [PATCH 179/232] Demo progress --- dotnet/demos/DeclarativeWorkflow/Program.cs | 8 +- .../Properties/launchSettings.json | 2 +- .../Workflows/Workflows_Declarative.cs | 10 +- .../DeclarativeWorkflowContextExtensions.cs | 4 +- .../Interpreter/DeclarativeWorkflowModel.cs | 2 +- .../AnswerQuestionWithAIExecutor.cs | 22 ++- .../ObjectModel/ParseValueExecutor.cs | 6 +- workflows/DeepResearch.yaml | 134 +++++------------- workflows/MathChat.yaml | 17 ++- workflows/Question.yaml | 7 +- 10 files changed, 89 insertions(+), 123 deletions(-) diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index df4fe881d8..4d299cdcae 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -48,7 +48,7 @@ public static async Task Main(string[] args) DeclarativeWorkflowOptions workflowContext = new() { - HttpClient = customClient, + //HttpClient = customClient, // Uncomment to use custom HTTP client LoggerFactory = NullLoggerFactory.Instance, ProjectEndpoint = Throw.IfNull(config["AzureAI:Endpoint"]), ProjectCredentials = new AzureCliCredential(), @@ -93,7 +93,9 @@ public static async Task Main(string[] args) if (messageId is not null) { Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine($"#{messageId}:"); + Console.Write("RESPONSE:"); + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" [{messageId}]"); } } try @@ -113,6 +115,8 @@ public static async Task Main(string[] args) Console.WriteLine(); if (messageEvent.Data.MessageId is null) { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("ACTIVITY:"); Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine(messageEvent.Data?.Text.Trim()); } diff --git a/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json b/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json index 03e5809fcb..0b93cd09a4 100644 --- a/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json +++ b/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json @@ -18,7 +18,7 @@ }, "Research": { "commandName": "Project", - "commandLineArgs": "\"DeepResearch.yaml\" \"What is the closest bus-stop to Ishoni grill in Seattle?\"" + "commandLineArgs": "\"DeepResearch.yaml\" \"What is the closest bus-stop to ISHONI YAKINIKU in Seattle?\"" } } } \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index 299787c59d..c34ce7e547 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -51,15 +51,7 @@ public async Task RunWorkflow(string fileName) StreamingRun run = await InProcessExecution.StreamAsync(workflow, ""); await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is ExecutorInvokeEvent executorInvoked) - { - Console.WriteLine($"!!! ENTER #{executorInvoked.ExecutorId}"); - } - else if (evt is ExecutorCompleteEvent executorComplete) - { - Console.WriteLine($"!!! EXIT #{executorComplete.ExecutorId}"); - } - else if (evt is DeclarativeWorkflowMessageEvent messageEvent) + if (evt is DeclarativeWorkflowMessageEvent messageEvent) { if (messageEvent.Data.MessageId is null) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowContextExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowContextExtensions.cs index 6d7643a85c..11be4eecee 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowContextExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowContextExtensions.cs @@ -9,8 +9,10 @@ namespace Microsoft.Agents.Workflows.Declarative.Extensions; internal static class DeclarativeWorkflowContextExtensions { + private const int DefaultMaximumExpressionLength = 10000; + public static RecalcEngine CreateRecalcEngine(this DeclarativeWorkflowOptions context) => - RecalcEngineFactory.Create(context.MaximumExpressionLength, context.MaximumCallDepth); + RecalcEngineFactory.Create(context.MaximumExpressionLength ?? DefaultMaximumExpressionLength, context.MaximumCallDepth); public static PersistentAgentsClient CreateClient(this DeclarativeWorkflowOptions context) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowModel.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowModel.cs index 9809038f1e..ff674e28f0 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowModel.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowModel.cs @@ -104,7 +104,7 @@ private ModelNode DefineNode(Executor executor, ModelNode? parentNode = null, Ty { ModelNode stepNode = new(executor, parentNode, executorType, completionHandler); - this.Nodes[stepNode.Id] = stepNode; + this.Nodes.Add(stepNode.Id, stepNode); return stepNode; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs index 905dd77b6e..88a8ea3fab 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs @@ -57,7 +57,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P List agentResponseUpdates = []; await foreach (AgentRunResponseUpdate update in agentUpdates.ConfigureAwait(false)) { - if (messageId is null) + if (messageId is null && this.Model.AutoSend) // %%% HAXX - EVENTING { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine("STREAM: BEGIN"); @@ -67,17 +67,27 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P agentResponseUpdates.Add(update); conversationId ??= ((ChatResponseUpdate)update.RawRepresentation!).ConversationId; messageId ??= update.MessageId; - await context.AddEventAsync(new DeclarativeWorkflowStreamEvent(update)).ConfigureAwait(false); + if (this.Model.AutoSend) + { + await context.AddEventAsync(new DeclarativeWorkflowStreamEvent(update)).ConfigureAwait(false); + } } - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine("STREAM: COMPLETE"); - Console.ResetColor(); + if (this.Model.AutoSend) // %%% HAXX - EVENTING + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("STREAM: COMPLETE"); + Console.ResetColor(); + } AgentRunResponse agentResponse = agentResponseUpdates.ToAgentRunResponse(); ChatMessage response = agentResponse.Messages.Last(); // %%% DECISION: Is last sufficient? (probably not) - await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(response, agentResponse.Usage)).ConfigureAwait(false); + this.State.Set(VariableScopeNames.System, "LastMessage", response.ToRecordValue()); + if (this.Model.AutoSend) + { + await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(response, agentResponse.Usage)).ConfigureAwait(false); + } this.AssignTarget(PropertyPath.FromSegments(VariableScopeNames.System, "ConversationId"), FormulaValue.New(conversationId)); // %%% HAXX: SYSTEM THREAD diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs index 1006041674..d5942947d8 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs @@ -25,7 +25,11 @@ internal sealed class ParseValueExecutor(ParseValue model) : FormulaValue? parsedResult = null; - if (expressionResult.Value is StringDataValue stringValue) + if (expressionResult.Value is RecordDataValue recordValue) + { + parsedResult = recordValue.ToFormulaValue(); + } + else if (expressionResult.Value is StringDataValue stringValue) // %%% NEEDED ??? { if (string.IsNullOrWhiteSpace(stringValue.Value)) { diff --git a/workflows/DeepResearch.yaml b/workflows/DeepResearch.yaml index dbbe7e76d4..013f30b369 100644 --- a/workflows/DeepResearch.yaml +++ b/workflows/DeepResearch.yaml @@ -2,44 +2,31 @@ kind: AdaptiveDialog beginDialog: kind: OnActivity id: activity_xyz123 - condition: =Global.OrchestratorRunning <> true type: Message actions: - kind: SetVariable id: setVariable_aASlmF displayName: List all available agents for this orchestrator - variable: Topic.AgentToSchemaMapping + variable: Topic.AvailableAgents value: |- =[ { name: "WeatherAgent", description: "Able to retrieve weather information", - schema: "cr36e_agentRd2yAT.topic.Deterministic" + agentid: "// %%% TODO" }, { name: "WebAgent", description: "Able to perform generic websearches", - schema: "cr36e_agentRd2yAT.topic.WebSearch" + agentid: "// %%% TODO" } ] - - kind: SetVariable - id: setVariable_u4cBtN - displayName: Get all names - variable: Topic.AvailableAgents - value: =DropColumns(Topic.AgentToSchemaMapping, description, schema) - - kind: SetVariable id: setVariable_V6yEbo displayName: Get a summary of all the agents for use in prompts variable: Topic.TeamDescription - value: "=Concat(ForAll(Topic.AgentToSchemaMapping, name & $\": \" & description), Value, \".\\n\\n\")" - - - kind: SetVariable - id: setVariable_eLmgKQ - displayName: Toggle Orchestration Enabled Flag - variable: Global.OrchestratorRunning - value: =true + value: "=Concat(ForAll(Topic.AvailableAgents, $\"- \" & name & $\": \" & description), Value, \"\n\")" - kind: SetVariable id: setVariable_NZ2u0l @@ -58,13 +45,6 @@ beginDialog: variable: Topic.StallCount value: =0 - - kind: SendActivity - id: sendActivity_yFsbRz - activity: |- - Creating a Task Ledger, defining a plan, based on the following ask: - - {Topic.NewTask} - - kind: SetVariable id: setVariable_s8hR6q variable: Topic.ContextHistory @@ -91,8 +71,12 @@ beginDialog: DO NOT include any other headings or sections in your response. DO NOT list next steps or plans until asked to do so."] + - kind: SendActivity + id: sendActivity_yFsbRy + activity: Analyzing facts... + - kind: AnswerQuestionWithAI - id: question_wEJ123 + id: asst_UDoMUw9SuPCq33HqH95YPT69 displayName: Get Facts Prompt autoSend: false variable: Topic.TaskFacts @@ -106,8 +90,12 @@ beginDialog: kind: AddItemOperation value: =Topic.TaskFacts + - kind: SendActivity + id: sendActivity_yFsbRz + activity: Creating a plan... + - kind: AnswerQuestionWithAI - id: question_wEJ456 + id: asst_DsBaJUelt99csnZV4fcE0Qos displayName: Create a Plan Prompt autoSend: false variable: Topic.Plan @@ -117,7 +105,6 @@ beginDialog: " & Topic.TeamDescription & " Based on the team composition, and known and unknown facts, please devise a short bullet-point plan for addressing the original request. Remember, there is no requirement to involve all team members -- a team member's particular expertise may not be needed for this task." - additionalInstructions: =Concat(Topic.ContextHistory, Value, \".\\n\\n\") - kind: SetVariable id: setVariable_Kk2LDL @@ -138,13 +125,12 @@ beginDialog: Here is an initial fact sheet to consider: - " & Topic.TaskFacts &" + " & Topic.TaskFacts.Text &" Here is the plan to follow as best as possible: " - & Topic.Plan - + & Topic.Plan.Text ] - kind: SendActivity @@ -152,10 +138,10 @@ beginDialog: activity: "{First(Topic.ContextHistory).Value}" - kind: AnswerQuestionWithAI - id: sendActivity_YhpNE8 + id: asst_o3BQkfUzEGXMNzVY5X54yHhq displayName: Progress Ledger Prompt autoSend: false - variable: Topic.ProgressLedgerUpdateString + variable: Topic.ProgressLedgerUpdate userInput: |- =" Recall we are working on the following request: @@ -199,10 +185,9 @@ beginDialog: }} }} " - additionalInstructions: =Concat(Topic.ContextHistory, Value, ".\\n\\n") - kind: ParseValue - id: rNZtlV + id: parse_rNZtlV displayName: Parse ledger response variable: Topic.TypedProgressLedger valueType: @@ -243,13 +228,13 @@ beginDialog: answer: String reason: String - value: =Topic.ProgressLedgerUpdateString + value: =Topic.ProgressLedgerUpdate.Text - kind: SendActivity id: sendActivity_1GMmNq activity: |- Progress Ledger response: - {Topic.ProgressLedgerUpdateString} + {Topic.ProgressLedgerUpdate.Text} - kind: ConditionGroup id: conditionGroup_mVIecC @@ -259,7 +244,7 @@ beginDialog: displayName: If Done actions: - kind: AnswerQuestionWithAI - id: sendActivity_Pkkmpq + id: asst_Of5U92uEjhiB5CsaUDW5LIX6 displayName: Generate Response variable: Topic.FinalResponse userInput: |- @@ -274,19 +259,13 @@ beginDialog: Based on the information gathered, provide the final answer to the original request. The answer should be phrased as if you were speaking to the user. " - additionalInstructions: =Concat(Topic.ContextHistory, Value, \".\\n\\n\") - kind: SendActivity id: sendActivity_fpaNL9 activity: Done with Task! - - kind: SetVariable - id: setVariable_H2GWZ4 - variable: Global.OrchestratorRunning - value: =false - - kind: EndConversation - id: SVoNSV + id: end_SVoNSV - id: conditionItem_yiqund condition: =Topic.TypedProgressLedger.is_in_loop.answer || Not(Topic.TypedProgressLedger.is_progress_being_made.answer) @@ -316,7 +295,7 @@ beginDialog: activity: We tried to re-task 3 times. Short-Circuiting - kind: EndConversation - id: GHVrFh + id: end_GHVrFh - kind: AnswerQuestionWithAI id: question_wFJ123 @@ -333,7 +312,6 @@ beginDialog: Here is the old fact sheet: " & Topic.TaskFacts - additionalInstructions: =Concat(Topic.ContextHistory, Value, ".\\n\\n") - kind: AnswerQuestionWithAI id: question_uEJ456 @@ -344,7 +322,6 @@ beginDialog: ="Please briefly explain what went wrong on this last run (the root cause of the failure), and then come up with a new plan that takes steps and/or includes hints to overcome prior challenges and especially avoids repeating the same mistakes. As before, the new plan should be concise, be expressed in bullet-point form, and consider the following team composition (do not involve any other outside people since we cannot contact anyone else): " & Topic.TeamDescription - additionalInstructions: =Concat(Topic.ContextHistory, Value, ".\\n\\n") - kind: EditTableV2 id: editTableV2_jW7tmM @@ -394,8 +371,8 @@ beginDialog: {Last(Topic.ContextHistory).Value} - kind: GotoAction - id: LzfJ8u - actionId: sendActivity_YhpNE8 + id: goto_LzfJ8u + actionId: asst_o3BQkfUzEGXMNzVY5X54yHhq elseActions: - kind: SetVariable @@ -410,64 +387,21 @@ beginDialog: condition: =CountRows(Search(Topic.AvailableAgents, Topic.TypedProgressLedger.next_speaker.answer, name)) > 0 displayName: If next Agent tool Exists actions: - - kind: SetVariable - id: setVariable_TdKfOn - displayName: Set Current Goal for Sub-Agent - variable: Global.AgentGoal - value: =Topic.TypedProgressLedger.instruction_or_question.answer - - kind: SetVariable - id: setVariable_C2AoCu - displayName: Reset Output - variable: Global.AgentResponse - value: "\"\"" - - - kind: BeginDialog - id: fqLWPt - displayName: Invoke Agent - input: {} - dialog: =First(Search(Topic.AgentToSchemaMapping, Topic.TypedProgressLedger.next_speaker.answer, name)).schema - output: {} - - - kind: EditTableV2 - id: editTableV2_fhfYJi - displayName: Add agent response to context - itemsVariable: Topic.ContextHistory - changeType: - kind: AddItemOperation - value: |- - ="Agent: " & Topic.TypedProgressLedger.next_speaker.answer & " - - Question or Instruction: - - " - & Topic.TypedProgressLedger.instruction_or_question.answer & - " - - Agent Response: - - " - & Global.AgentResponse - - - kind: SendActivity - id: sendActivity_MjWETC - activity: |- - Agent invoked: - {Last(Topic.ContextHistory).Value} + - kind: AnswerQuestionWithAI + id: asst_orsBf06Bxz9B1hjVjiiQoPqf + displayName: Get agent response + variable: Topic.AgentResponse + userInput: =Topic.TypedProgressLedger.instruction_or_question.answer - kind: GotoAction - id: 76Hne8 - actionId: sendActivity_YhpNE8 + id: goto_76Hne8 + actionId: asst_o3BQkfUzEGXMNzVY5X54yHhq elseActions: - kind: SendActivity id: sendActivity_BhcsI7 activity: Redirecting to unknown agent - - kind: SetVariable - id: setVariable_H2GW44 - variable: Global.OrchestratorRunning - value: =false - - kind: EndConversation - id: 8nXE8H + id: end_8nXE8H diff --git a/workflows/MathChat.yaml b/workflows/MathChat.yaml index 08ef051e45..64e96042c1 100644 --- a/workflows/MathChat.yaml +++ b/workflows/MathChat.yaml @@ -42,10 +42,25 @@ beginDialog: - kind: ConditionGroup id: check_completion conditions: - - condition: =Topic.TurnCount < 4 + + - condition: =!IsBlank(Find("congratulations", Lower(System.LastMessage.Text))) + id: check_turn_done + actions: + + - kind: SendActivity + id: sendActivity_done + activity: GOLD STAR! + + - condition: =Topic.TurnCount < 8 id: check_turn_count actions: - kind: GotoAction id: goto_student_agent actionId: asst_jCrWfBd1XrfVXMtpbfyVoIHC + + elseActions: + + - kind: SendActivity + id: sendActivity_tired + activity: Let's try again later... diff --git a/workflows/Question.yaml b/workflows/Question.yaml index e10ed051f1..e2c36337e3 100644 --- a/workflows/Question.yaml +++ b/workflows/Question.yaml @@ -4,7 +4,12 @@ beginDialog: id: workflow_demo actions: - # Use AI to answer the question + # Respond with input + - kind: SendActivity + id: sendActivity_demo + activity: "Working..." + + # Use AI to answer the question - kind: AnswerQuestionWithAI id: asst_orsBf06Bxz9B1hjVjiiQoPqf variable: Topic.Answer From 6862393617eb25f64f67c27177180d3b722aa25b Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Wed, 20 Aug 2025 09:14:51 -0700 Subject: [PATCH 180/232] Sample clean-up --- .../Workflows/Expression.CountIfType.yaml | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/dotnet/samples/GettingStarted/Workflows/Expression.CountIfType.yaml b/dotnet/samples/GettingStarted/Workflows/Expression.CountIfType.yaml index 673669d23b..34da3d8f66 100644 --- a/dotnet/samples/GettingStarted/Workflows/Expression.CountIfType.yaml +++ b/dotnet/samples/GettingStarted/Workflows/Expression.CountIfType.yaml @@ -5,21 +5,6 @@ beginDialog: id: activity_xyz123 type: Message actions: - # - kind: SetVariable - # id: setVariable1 - # variable: Topic.TestList - # value: ["zaz", "zbz", "zcz", "zdz", "zez", "zfz"] - - # - kind: ParseValue - # id: setVariable2 - # displayName: Parse ledger response - # variable: Topic.TestList - # valueType: - # kind: Record - # properties: - # key: Number - # value: |- - # =[{"Key": 1}, {"Key": 2}] - kind: SetVariable id: setVariable_list @@ -38,10 +23,6 @@ beginDialog: id: setVariable_result variable: Topic.TestResult value: =CountIf(Topic.TestList, key > 1) - #value: =Find("e", "abcdefg") - #value: =CountIf(Topic.TestList, 1) - #value: =CountIf(Topic.TestList, !IsBlank(Find("e", Value))) - #value: =!IsBlank(Topic.TestList) - kind: SendActivity id: sendActivity_result From 4df3dc096e1d51ea727ce63bf04c6c3ddbe83787 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Wed, 20 Aug 2025 14:03:36 -0700 Subject: [PATCH 181/232] Update samples --- .../DeclarativeWorkflowBuilder.cs | 5 +++-- workflows/DeepResearch.yaml | 5 +++-- workflows/HelloWorld.yaml | 1 + workflows/Question.yaml | 1 + 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 249eb5a33a..6b0b05d5a3 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -31,10 +31,11 @@ public static Workflow Build(TextReader yamlReader, DeclarativeW return walker.GetWorkflow(); } - private static string GetWorkflowId(BotElement element) => // %%% CPS - WORKFLOW TYPE + private static string? GetWorkflowId(BotElement element) => // %%% CPS - WORKFLOW TYPE element switch { - AdaptiveDialog adaptiveDialog => adaptiveDialog.BeginDialog?.Id.Value ?? throw new UnknownActionException("Undefined dialog"), + AdaptiveDialog adaptiveDialog => adaptiveDialog.BeginDialog?.Id.Value, + DialogAction actionDialog => actionDialog.Id.Value, _ => throw new UnknownActionException($"Unsupported root element: {element.GetType().Name}."), }; } diff --git a/workflows/DeepResearch.yaml b/workflows/DeepResearch.yaml index 013f30b369..70445fa372 100644 --- a/workflows/DeepResearch.yaml +++ b/workflows/DeepResearch.yaml @@ -1,9 +1,10 @@ kind: AdaptiveDialog beginDialog: + kind: OnActivity - id: activity_xyz123 - type: Message + id: workflow_demo actions: + - kind: SetVariable id: setVariable_aASlmF displayName: List all available agents for this orchestrator diff --git a/workflows/HelloWorld.yaml b/workflows/HelloWorld.yaml index 960e1f27bd..15f927abc8 100644 --- a/workflows/HelloWorld.yaml +++ b/workflows/HelloWorld.yaml @@ -1,5 +1,6 @@ kind: AdaptiveDialog beginDialog: + kind: OnActivity id: workflow_demo actions: diff --git a/workflows/Question.yaml b/workflows/Question.yaml index e2c36337e3..67d2389616 100644 --- a/workflows/Question.yaml +++ b/workflows/Question.yaml @@ -1,5 +1,6 @@ kind: AdaptiveDialog beginDialog: + kind: OnActivity id: workflow_demo actions: From e17659b97853a58f993b3eba9f5352dd637cb101 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 21 Aug 2025 15:41:38 -0700 Subject: [PATCH 182/232] Sample updates --- dotnet/agent-framework-dotnet.slnx | 1 + .../{ => Http}/HttpInterceptHandler.cs | 22 +-- .../Http/HttpInterceptor.cs | 31 ++++ .../Http/InterceptStream.cs | 36 +++++ dotnet/demos/DeclarativeWorkflow/Program.cs | 150 ++++++++++-------- .../Properties/launchSettings.json | 2 +- dotnet/demos/QueryWorkflow/Program.cs | 107 +++++++++++++ .../Properties/launchSettings.json | 12 ++ .../demos/QueryWorkflow/QueryWorkflow.csproj | 27 ++++ .../Workflows/Expression.CountIfType.yaml | 11 +- .../Workflows/Workflows_Declarative.cs | 3 +- .../DeclarativeWorkflowBuilder.cs | 11 +- .../DeclarativeWorkflowInvokeEvent.cs | 14 ++ .../DeclarativeWorkflowOptions.cs | 9 +- ...> DeclarativeWorkflowOptionsExtensions.cs} | 6 +- .../Interpreter/DeclarativeActionExecutor.cs | 16 +- .../DeclarativeWorkflowExecutor.cs | 1 + .../AnswerQuestionWithAIExecutor.cs | 44 +++-- .../ObjectModel/ClearAllVariablesExecutor.cs | 4 +- .../ObjectModel/ResetVariableExecutor.cs | 4 +- .../DeclarativeWorkflowContextTest.cs | 20 +-- .../DeclarativeWorkflowTest.cs | 4 +- workflows/Question.yaml | 3 +- 23 files changed, 385 insertions(+), 153 deletions(-) rename dotnet/demos/DeclarativeWorkflow/{ => Http}/HttpInterceptHandler.cs (56%) create mode 100644 dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptor.cs create mode 100644 dotnet/demos/DeclarativeWorkflow/Http/InterceptStream.cs create mode 100644 dotnet/demos/QueryWorkflow/Program.cs create mode 100644 dotnet/demos/QueryWorkflow/Properties/launchSettings.json create mode 100644 dotnet/demos/QueryWorkflow/QueryWorkflow.csproj create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowInvokeEvent.cs rename dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/{DeclarativeWorkflowContextExtensions.cs => DeclarativeWorkflowOptionsExtensions.cs} (78%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index a10dda123d..e7c249b25c 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -8,6 +8,7 @@ + diff --git a/dotnet/demos/DeclarativeWorkflow/HttpInterceptHandler.cs b/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptHandler.cs similarity index 56% rename from dotnet/demos/DeclarativeWorkflow/HttpInterceptHandler.cs rename to dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptHandler.cs index 2ae97487f7..201b8f835d 100644 --- a/dotnet/demos/DeclarativeWorkflow/HttpInterceptHandler.cs +++ b/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptHandler.cs @@ -4,8 +4,10 @@ using System; using System.Net.Http; +using System.Text; using System.Threading; using System.Threading.Tasks; +using DeclarativeWorkflow; namespace Demo.DeclarativeWorkflow; @@ -21,21 +23,23 @@ protected override async Task SendAsync(HttpRequestMessage HttpResponseMessage response = await base.SendAsync(request, cancellationToken); // Intercept and modify the response - string? responseContent = null; if (response.Content != null) { - responseContent = await response.Content.ReadAsStringAsync(cancellationToken); - - response.Content = new StringContent(responseContent); + response.Content = new StreamContent(new InterceptStream(await response.Content.ReadAsStreamAsync(cancellationToken), OnResponse)); } - if (this.OnIntercept is not null) + return response; + + void OnResponse(byte[] buffer, int offset, int length) { - // Invoke the intercept callback if it is set - await this.OnIntercept(new HttpResponseIntercept(request.Method, request.RequestUri, responseContent)).ConfigureAwait(false); + if (this.OnIntercept is not null) + { + Encoding.UTF8.GetString(buffer, 0, length); + string responseContent = Encoding.UTF8.GetString(buffer, offset, length); + // Invoke the intercept callback if it is set + ValueTask task = this.OnIntercept(new HttpResponseIntercept(request.Method, request.RequestUri, responseContent)); // %%% HAXX: CHANNEL (Lighter) + } } - - return response; } } diff --git a/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptor.cs b/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptor.cs new file mode 100644 index 0000000000..b89f606968 --- /dev/null +++ b/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptor.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Threading.Tasks; +using Demo.DeclarativeWorkflow; + +namespace DeclarativeWorkflow; + +internal sealed class HttpInterceptor(StreamWriter eventWriter) +{ + private string? _lastRequest; + + public async ValueTask OnResponseAsync(HttpResponseIntercept intercept) + { + string currentRequest = $"{intercept.RequestMethod} {intercept.RequestUri}"; + + if (currentRequest != this._lastRequest) + { + this._lastRequest = currentRequest; + await eventWriter.WriteLineAsync($"{Environment.NewLine}{intercept.RequestMethod} {intercept.RequestUri}"); + } + + if (intercept.ResponseContent is not null) + { + await eventWriter.WriteAsync(intercept.ResponseContent); + } + + await eventWriter.FlushAsync(); + } +} diff --git a/dotnet/demos/DeclarativeWorkflow/Http/InterceptStream.cs b/dotnet/demos/DeclarativeWorkflow/Http/InterceptStream.cs new file mode 100644 index 0000000000..35f392e443 --- /dev/null +++ b/dotnet/demos/DeclarativeWorkflow/Http/InterceptStream.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; + +namespace DeclarativeWorkflow; + +internal sealed class InterceptStream(Stream source, Action callback) : Stream +{ + public override bool CanRead => source.CanRead; + + public override bool CanSeek => source.CanSeek; + + public override bool CanWrite => source.CanWrite; + + public override long Length => source.Length; + + public override long Position { get => source.Position; set => source.Position = value; } + + public override void Flush() => source.Flush(); + + public override int Read(byte[] buffer, int offset, int count) + { + int actual = source.Read(buffer, offset, count); + + callback.Invoke(buffer, offset, actual); + + return actual; + } + + public override long Seek(long offset, SeekOrigin origin) => source.Seek(offset, origin); + + public override void SetLength(long value) => source.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => source.Write(buffer, offset, count); +} diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index 4d299cdcae..55ae22a078 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -1,21 +1,32 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; using System.Reflection; using System.Threading.Tasks; +using Azure.AI.Agents.Persistent; using Azure.Identity; +using DeclarativeWorkflow; using Microsoft.Agents.Workflows; using Microsoft.Agents.Workflows.Declarative; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.Diagnostics; namespace Demo.DeclarativeWorkflow; +/// +/// HOW TO: Create a workflow from a declartive (yaml based) definition. +/// +/// +/// Provide the path to the workflow definition file as the first argument. +/// All other arguments are intepreted as a queue of inputs. +/// When no input is queued, interactive input is requested from the console. +/// internal static class Program { private const string DefaultWorkflow = "HelloWorld.yaml"; @@ -30,57 +41,62 @@ public static async Task Main(string[] args) // Create custom HTTP client with intercept handler await using StreamWriter eventWriter = new(HttpEventFileName, append: false); - using HttpClient customClient = new(new HttpInterceptHandler() { OnIntercept = OnHttpIntercept, CheckCertificateRevocationList = true }, disposeHandler: true); + HttpInterceptor interceptor = new(eventWriter); + using HttpClient customClient = new(new HttpInterceptHandler() { OnIntercept = interceptor.OnResponseAsync, CheckCertificateRevocationList = true }, disposeHandler: true); + PersistentAgentsClient client = new(Throw.IfNull(config["AzureAI:Endpoint"]), new AzureCliCredential()); // Read and parse the declarative workflow. Notify("PROCESS INIT"); Stopwatch timer = Stopwatch.StartNew(); - - ////////////////////////////////////////////////////// - // - // HOW TO: Create a workflow from a YAML file. - // using StreamReader yamlReader = File.OpenText(workflowFile); - // + // DeclarativeWorkflowContext provides the components for workflow execution. - // DeclarativeWorkflowOptions workflowContext = - new() + new(projectEndpoint: Throw.IfNull(config["AzureAI:Endpoint"])) { - //HttpClient = customClient, // Uncomment to use custom HTTP client - LoggerFactory = NullLoggerFactory.Instance, - ProjectEndpoint = Throw.IfNull(config["AzureAI:Endpoint"]), + HttpClient = customClient, // Uncomment to use custom HTTP client ProjectCredentials = new AzureCliCredential(), }; - // + // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. - // Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); - // - ////////////////////////////////////////////////////// Notify($"\nPROCESS DEFINED: {timer.Elapsed}"); Notify("\nPROCESS INVOKE"); - ////////////////////////////////////////////// // Run the workflow, just like any other workflow + string input = GetWorkflowInput(args); + StreamingRun run = await InProcessExecution.StreamAsync(workflow, input); + await MonitorWorkflowRunAsync(run, client); + + Notify("\nPROCESS DONE"); + } + + private readonly static Dictionary s_nameCache = []; + + private static async Task MonitorWorkflowRunAsync(StreamingRun run, PersistentAgentsClient client) + { string? messageId = null; - StreamingRun run = await InProcessExecution.StreamAsync(workflow, GetWorkflowInput(args)); + await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { if (evt is ExecutorInvokeEvent executorInvoked) { - Debug.WriteLine($"!!! ENTER #{executorInvoked.ExecutorId}"); + Debug.WriteLine($"STEP ENTER #{executorInvoked.ExecutorId}"); } else if (evt is ExecutorCompleteEvent executorComplete) { - Debug.WriteLine($"!!! EXIT #{executorComplete.ExecutorId}"); + Debug.WriteLine($"STEP EXIT #{executorComplete.ExecutorId}"); } else if (evt is ExecutorFailureEvent executorFailure) { - Debug.WriteLine($"!!! ERROR #{executorFailure.ExecutorId}: {executorFailure.Data?.Message ?? "Unknown"}"); + Debug.WriteLine($"STEP ERROR #{executorFailure.ExecutorId}: {executorFailure.Data?.Message ?? "Unknown"}"); + } + else if (evt is DeclarativeWorkflowInvokeEvent invokeEvent) + { + Debug.WriteLine($"CONVERSATION: {invokeEvent.Data}"); } else if (evt is DeclarativeWorkflowStreamEvent streamEvent) { @@ -88,12 +104,22 @@ public static async Task Main(string[] args) { messageId = streamEvent.Data.MessageId; - Console.WriteLine(); - if (messageId is not null) { + string? agentId = streamEvent.Data.AuthorName; + if (agentId is not null) + { + if (!s_nameCache.TryGetValue(agentId, out string? realName)) + { + PersistentAgent agent = await client.Administration.GetAgentAsync(agentId); + s_nameCache[agentId] = agent.Name; + realName = agent.Name; + } + agentId = realName; + } + agentId ??= nameof(ChatRole.Assistant); Console.ForegroundColor = ConsoleColor.Cyan; - Console.Write("RESPONSE:"); + Console.Write($"\n{agentId.ToUpperInvariant()}:"); Console.ForegroundColor = ConsoleColor.DarkGray; Console.WriteLine($" [{messageId}]"); } @@ -135,67 +161,53 @@ public static async Task Main(string[] args) } } } - ////////////////////////////////////////////// + } - Notify("\nPROCESS DONE"); + private static string GetWorkflowFile(string[] args) + { + string workflowFile = args.FirstOrDefault() ?? DefaultWorkflow; - string GetWorkflowInput(string[] args) + if (!File.Exists(workflowFile) && !Path.IsPathFullyQualified(workflowFile)) { - string? input = GetWorkflowInputs(args).FirstOrDefault(); + workflowFile = Path.Combine(@"..\..\..\..\..\..\Workflows", workflowFile); + } - try - { - Console.ForegroundColor = ConsoleColor.DarkGreen; + if (!File.Exists(workflowFile)) + { + throw new InvalidOperationException($"Unable to locate workflow: {Path.GetFullPath(workflowFile)}."); + } - Console.Write("\nINPUT: "); + return workflowFile; + } - Console.ForegroundColor = ConsoleColor.White; + private static string GetWorkflowInput(string[] args) + { + string? input = GetWorkflowInputs(args).FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(input)) - { - Console.WriteLine(input); - return input; - } - while (string.IsNullOrWhiteSpace(input)) - { - input = Console.ReadLine(); - } + try + { + Console.ForegroundColor = ConsoleColor.DarkGreen; - Console.WriteLine(); + Console.Write("\nINPUT: "); - return input.Trim(); - } - finally + Console.ForegroundColor = ConsoleColor.White; + + if (!string.IsNullOrWhiteSpace(input)) { - Console.ResetColor(); + Console.WriteLine(input); + return input; } - } - - ValueTask OnHttpIntercept(HttpResponseIntercept intercept) - { - eventWriter.WriteLine($"{intercept.RequestMethod} {intercept.RequestUri}"); - if (intercept.ResponseContent is not null) + while (string.IsNullOrWhiteSpace(input)) { - eventWriter.WriteLine($"API:{Environment.NewLine}" + intercept.ResponseContent); + input = Console.ReadLine(); } - return default; - } - } - private static string GetWorkflowFile(string[] args) - { - string workflowFile = args.FirstOrDefault() ?? DefaultWorkflow; - if (!File.Exists(workflowFile) && !Path.IsPathFullyQualified(workflowFile)) - { - workflowFile = Path.Combine(@"..\..\..\..\..\..\Workflows", workflowFile); + return input.Trim(); } - - if (!File.Exists(workflowFile)) + finally { - throw new InvalidOperationException($"Unable to locate workflow: {Path.GetFullPath(workflowFile)}."); + Console.ResetColor(); } - - return workflowFile; } private static string[] GetWorkflowInputs(string[] args) diff --git a/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json b/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json index 0b93cd09a4..2ccded419a 100644 --- a/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json +++ b/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json @@ -14,7 +14,7 @@ }, "MathChat": { "commandName": "Project", - "commandLineArgs": "\"MathChat.yaml\" \"What is the formula for Fibonacci sequence?\"" + "commandLineArgs": "\"MathChat.yaml\" \"How would you compute the value of PI?\"" }, "Research": { "commandName": "Project", diff --git a/dotnet/demos/QueryWorkflow/Program.cs b/dotnet/demos/QueryWorkflow/Program.cs new file mode 100644 index 0000000000..e048d6df6d --- /dev/null +++ b/dotnet/demos/QueryWorkflow/Program.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Azure.AI.Agents.Persistent; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Shared.Diagnostics; + +namespace Demo.DeclarativeWorkflow; + +internal static class Program +{ + public static async Task Main(string[] args) + { + string workflowId = GetWorkflowId(); + + // Load configuration and create kernel with Azure OpenAI Chat Completion service + IConfiguration config = InitializeConfig(); + Dictionary nameCache = []; + PersistentAgentsClient client = new(Throw.IfNull(config["AzureAI:Endpoint"]), new AzureCliCredential()); + + await foreach (PersistentThreadMessage message in client.Messages.GetMessagesAsync(workflowId, order: ListSortOrder.Ascending)) + { + Task>? runTask = null; + if (message.RunId is not null) + { + runTask = client.Runs.GetRunAsync(workflowId, message.RunId); + } + try + { + string? agentName = $"{message.Role}"; + if (message.AssistantId is not null) + { + if (!nameCache.TryGetValue(message.AssistantId, out agentName)) + { + PersistentAgent agent = await client.Administration.GetAgentAsync(message.AssistantId); + nameCache[message.AssistantId] = agent.Name; + agentName = agent.Name; + } + } + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write($"\n{agentName.ToUpperInvariant()}:"); + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" [{message.Id}]"); + + Console.ForegroundColor = message.Role == MessageRole.User ? ConsoleColor.White : ConsoleColor.Gray; + Console.WriteLine(message.ContentItems.OfType().FirstOrDefault()?.Text); + Console.ForegroundColor = ConsoleColor.DarkGray; + + if (runTask is not null) + { + ThreadRun messageRun = await runTask; + Console.WriteLine($"[Tokens Total: {messageRun.Usage.TotalTokens}, Input: {messageRun.Usage.PromptTokens}, Output: {messageRun.Usage.CompletionTokens}]"); + } + } + finally + { + Console.ResetColor(); + } + } + + string GetWorkflowId() + { + string? workflowId = args.FirstOrDefault(); + if (string.IsNullOrWhiteSpace(workflowId)) + { + workflowId = Console.ReadLine()?.Trim(); + } + if (!string.IsNullOrWhiteSpace(workflowId)) + { + try + { + Console.ForegroundColor = ConsoleColor.Cyan; + + Console.Write("\nWORKFLOW: "); + + Console.ForegroundColor = ConsoleColor.Yellow; + + if (!string.IsNullOrWhiteSpace(workflowId)) + { + Console.WriteLine(workflowId); + return workflowId; + } + + Console.WriteLine(); + + return workflowId.Trim(); + } + finally + { + Console.ResetColor(); + } + } + throw new ArgumentException("Workflow ID is required."); + } + } + + // Load configuration from user-secrets + private static IConfigurationRoot InitializeConfig() => + new ConfigurationBuilder() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .Build(); +} diff --git a/dotnet/demos/QueryWorkflow/Properties/launchSettings.json b/dotnet/demos/QueryWorkflow/Properties/launchSettings.json new file mode 100644 index 0000000000..54f20286bb --- /dev/null +++ b/dotnet/demos/QueryWorkflow/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Default": { + "commandName": "Project", + "commandLineArgs": "thread_eX1ZSWvK10TdAT9m0ag9SqMW" + }, + "Interactive": { + "commandName": "Project", + "commandLineArgs": "" + } + } +} \ No newline at end of file diff --git a/dotnet/demos/QueryWorkflow/QueryWorkflow.csproj b/dotnet/demos/QueryWorkflow/QueryWorkflow.csproj new file mode 100644 index 0000000000..eb73b2fbff --- /dev/null +++ b/dotnet/demos/QueryWorkflow/QueryWorkflow.csproj @@ -0,0 +1,27 @@ + + + + Exe + net9.0 + net9.0 + $(ProjectsDebugTargetFrameworks) + 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 + + + + true + + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/Workflows/Expression.CountIfType.yaml b/dotnet/samples/GettingStarted/Workflows/Expression.CountIfType.yaml index 34da3d8f66..13c2c04f8d 100644 --- a/dotnet/samples/GettingStarted/Workflows/Expression.CountIfType.yaml +++ b/dotnet/samples/GettingStarted/Workflows/Expression.CountIfType.yaml @@ -9,10 +9,14 @@ beginDialog: - kind: SetVariable id: setVariable_list variable: Topic.TestList + # =[ + # { key: 1 }, + # { key: 2 } + # ] value: |- =[ - { key: 1 }, - { key: 2 } + { key: "a" }, + { key: "b" } ] - kind: SendActivity @@ -22,7 +26,8 @@ beginDialog: - kind: SetVariable id: setVariable_result variable: Topic.TestResult - value: =CountIf(Topic.TestList, key > 1) + #value: =CountIf(Topic.TestList, key > 1) + value: =CountIf(Topic.TestList, key = "b") - kind: SendActivity id: sendActivity_result diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index c34ce7e547..6d34fb18e0 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -33,10 +33,9 @@ public async Task RunWorkflow(string fileName) // DeclarativeWorkflowContext provides the components for workflow execution. // DeclarativeWorkflowOptions workflowContext = - new() + new(Throw.IfNull(TestConfiguration.AzureAI.Endpoint)) { LoggerFactory = this.LoggerFactory, - ProjectEndpoint = Throw.IfNull(TestConfiguration.AzureAI.Endpoint), ProjectCredentials = new AzureCliCredential(), }; // diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 6b0b05d5a3..6c0940f620 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -16,26 +16,25 @@ public static class DeclarativeWorkflowBuilder /// Builds a process from the provided YAML definition of a CPS Topic ObjectModel. /// /// The reader that provides the workflow object model YAML. - /// The execution context for the workflow. + /// The execution context for the workflow. /// The that corresponds with the YAML object model. - public static Workflow Build(TextReader yamlReader, DeclarativeWorkflowOptions context) where TInput : notnull + public static Workflow Build(TextReader yamlReader, DeclarativeWorkflowOptions options) where TInput : notnull { BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new UnknownActionException("Unable to parse workflow."); string rootId = WorkflowActionVisitor.RootId(GetWorkflowId(rootElement)); DeclarativeWorkflowExecutor rootExecutor = new(rootId); - WorkflowActionVisitor visitor = new(rootExecutor, context); + WorkflowActionVisitor visitor = new(rootExecutor, options); WorkflowElementWalker walker = new(rootElement, visitor); return walker.GetWorkflow(); } - private static string? GetWorkflowId(BotElement element) => // %%% CPS - WORKFLOW TYPE + private static string GetWorkflowId(BotElement element) => // %%% CPS - WORKFLOW TYPE element switch { - AdaptiveDialog adaptiveDialog => adaptiveDialog.BeginDialog?.Id.Value, - DialogAction actionDialog => actionDialog.Id.Value, + AdaptiveDialog adaptiveDialog => adaptiveDialog.BeginDialog?.Id.Value ?? throw new UnknownActionException("Undefined dialog"), _ => throw new UnknownActionException($"Unsupported root element: {element.GetType().Name}."), }; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowInvokeEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowInvokeEvent.cs new file mode 100644 index 0000000000..f1f883f0dc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowInvokeEvent.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Event that represents a message produced by a declarative workflow. +/// +public class DeclarativeWorkflowInvokeEvent(string conversationId) : DeclarativeWorkflowEvent(conversationId) +{ + /// + /// The converation ID associated with the workflow. + /// + public new string Data => conversationId; +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs index 01375e9031..195c6535f2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs @@ -11,14 +11,17 @@ namespace Microsoft.Agents.Workflows.Declarative; /// /// Configuration options for workflow execution. /// -public sealed class DeclarativeWorkflowOptions +public sealed class DeclarativeWorkflowOptions(string projectEndpoint) { - internal static DeclarativeWorkflowOptions Default { get; } = new(); + /// + /// Optionally identifies a continued workflow conversation. + /// + public string? ConversationId { get; init; } /// /// Defines the endpoint for the Foundry project. /// - public string ProjectEndpoint { get; init; } = string.Empty; + public string ProjectEndpoint { get; } = projectEndpoint; /// /// Defines the credentials that authorize access to the Foundry project. diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowContextExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowOptionsExtensions.cs similarity index 78% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowContextExtensions.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowOptionsExtensions.cs index 11be4eecee..fcd702192f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowContextExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowOptionsExtensions.cs @@ -7,12 +7,12 @@ namespace Microsoft.Agents.Workflows.Declarative.Extensions; -internal static class DeclarativeWorkflowContextExtensions +internal static class DeclarativeWorkflowOptionsExtensions { private const int DefaultMaximumExpressionLength = 10000; - public static RecalcEngine CreateRecalcEngine(this DeclarativeWorkflowOptions context) => - RecalcEngineFactory.Create(context.MaximumExpressionLength ?? DefaultMaximumExpressionLength, context.MaximumCallDepth); + public static RecalcEngine CreateRecalcEngine(this DeclarativeWorkflowOptions? context) => + RecalcEngineFactory.Create(context?.MaximumExpressionLength ?? DefaultMaximumExpressionLength, context?.MaximumCallDepth); public static PersistentAgentsClient CreateClient(this DeclarativeWorkflowOptions context) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs index beb01e68ca..4df1f2d872 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -49,7 +49,7 @@ protected WorkflowActionExecutor(DialogAction model) internal ILogger Logger { get; set; } = NullLogger.Instance; - internal DeclarativeWorkflowOptions Options { get; set; } = DeclarativeWorkflowOptions.Default; + internal DeclarativeWorkflowOptions? Options { get; set; } protected DeclarativeWorkflowState State { @@ -62,7 +62,7 @@ public async ValueTask HandleAsync(ExecutionResultMessage message, IWorkflowCont { if (this.Model.Disabled) { - Debug.WriteLine($"!!! DISABLED {this.GetType().Name} [{this.Id}]"); + Debug.WriteLine($"DISABLED {this.GetType().Name} [{this.Id}]"); return; } @@ -76,14 +76,14 @@ public async ValueTask HandleAsync(ExecutionResultMessage message, IWorkflowCont await context.SetScopedStateAsync(scopes, default).ConfigureAwait(false); await context.SendMessageAsync(new ExecutionResultMessage(this.Id, result)).ConfigureAwait(false); } - catch (WorkflowExecutionException) + catch (WorkflowExecutionException exception) { - Debug.WriteLine($"*** STEP [{this.Id}] ERROR - Action failure"); + Debug.WriteLine($"ERROR [{this.Id}] {exception.GetType().Name}\n{exception.Message}"); throw; } catch (Exception exception) { - Debug.WriteLine($"*** STEP [{this.Id}] ERROR - {exception.GetType().Name}\n{exception.Message}"); + Debug.WriteLine($"ERROR [{this.Id}] {exception.GetType().Name}\n{exception.Message}"); throw new WorkflowExecutionException($"Unhandled workflow failure - #{this.Id} ({this.Model.GetType().Name})", exception); } } @@ -99,9 +99,9 @@ protected void AssignTarget(PropertyPath targetPath, FormulaValue result) string valuePosition = (resultValue?.IndexOf('\n') ?? -1) >= 0 ? Environment.NewLine : " "; Debug.WriteLine( $""" - !!! ASSIGN {this.GetType().Name} [{this.Id}] - NAME: {targetPath.Format()} - VALUE:{valuePosition}{result.Format()} ({result.GetType().Name}) + STATE: {this.GetType().Name} [{this.Id}] + NAME: {targetPath.Format()} + VALUE:{valuePosition}{result.Format()} ({result.GetType().Name}) """); #endif } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs index 2a1f591fa1..2e7a12dd8b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs @@ -31,6 +31,7 @@ public async ValueTask HandleAsync(TInput message, IWorkflowContext context) WorkflowScopes scopes = await context.GetScopedStateAsync(default).ConfigureAwait(false); scopes.Set("LastMessage", VariableScopeNames.System, input.ToRecordValue()); + scopes.Set("Activity", VariableScopeNames.System, new ChatMessage(ChatRole.User, string.Empty).ToRecordValue()); await context.SetScopedStateAsync(scopes, default).ConfigureAwait(false); await context.SendMessageAsync(new ExecutionResultMessage(this.Id)).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs index 88a8ea3fab..0a553e92a2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -23,7 +22,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P { StringExpression userInputExpression = Throw.IfNull(this.Model.UserInput, $"{nameof(this.Model)}.{nameof(this.Model.UserInput)}"); - using NewPersistentAgentsChatClient chatClient = new(client, this.Id); // %%% HAXX - AGENT ID + using NewPersistentAgentsChatClient chatClient = new(client, Throw.IfNull(this.Model.DisplayName)); // %%% HAXX - AGENT ID in "DisplayName" ChatClientAgent agent = new(chatClient); string? userInput = null; @@ -40,32 +39,31 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P Instructions = this.State.Format(this.Model.AdditionalInstructions) ?? string.Empty, }); - AgentThread? thread = null; - FormulaValue conversationValue = this.State.Get(VariableScopeNames.System, "ConversationId"); // %%% HAXX: SYSTEM THREAD + FormulaValue conversationValue = this.State.Get(VariableScopeNames.System, this.Model.AutoSend ? "ConversationId" : "InternalId"); + string conversationId; if (conversationValue is StringValue stringValue) { - thread = new AgentThread() { ConversationId = stringValue.Value }; + conversationId = stringValue.Value; } + else + { + PersistentAgentThread thread = await client.Threads.CreateThreadAsync(cancellationToken: default).ConfigureAwait(false); + conversationId = thread.Id; + } + + await context.AddEventAsync(new DeclarativeWorkflowInvokeEvent(conversationId)).ConfigureAwait(false); + AgentThread agentThread = new() { ConversationId = conversationId }; IAsyncEnumerable agentUpdates = - !string.IsNullOrWhiteSpace(userInput) ? - agent.RunStreamingAsync(userInput, thread, options, cancellationToken) : - agent.RunStreamingAsync(thread, options, cancellationToken); + !string.IsNullOrWhiteSpace(userInput) ? + agent.RunStreamingAsync(userInput, agentThread, options, cancellationToken) : + agent.RunStreamingAsync(agentThread, options, cancellationToken); - string? conversationId = null; string? messageId = null; List agentResponseUpdates = []; await foreach (AgentRunResponseUpdate update in agentUpdates.ConfigureAwait(false)) { - if (messageId is null && this.Model.AutoSend) // %%% HAXX - EVENTING - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine("STREAM: BEGIN"); - Console.ResetColor(); - } - agentResponseUpdates.Add(update); - conversationId ??= ((ChatResponseUpdate)update.RawRepresentation!).ConversationId; messageId ??= update.MessageId; if (this.Model.AutoSend) { @@ -73,13 +71,6 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P } } - if (this.Model.AutoSend) // %%% HAXX - EVENTING - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine("STREAM: COMPLETE"); - Console.ResetColor(); - } - AgentRunResponse agentResponse = agentResponseUpdates.ToAgentRunResponse(); ChatMessage response = agentResponse.Messages.Last(); // %%% DECISION: Is last sufficient? (probably not) @@ -89,7 +80,10 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(response, agentResponse.Usage)).ConfigureAwait(false); } - this.AssignTarget(PropertyPath.FromSegments(VariableScopeNames.System, "ConversationId"), FormulaValue.New(conversationId)); // %%% HAXX: SYSTEM THREAD + if (conversationValue is not StringValue) + { + this.AssignTarget(PropertyPath.FromSegments(VariableScopeNames.System, this.Model.AutoSend ? "ConversationId" : "InternalId"), FormulaValue.New(conversationId)); // %%% HAXX: INTERNAL THREAD + } PropertyPath? variablePath = this.Model.Variable?.Path; if (variablePath is not null) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs index 46113dc42a..5612b43fed 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs @@ -52,8 +52,8 @@ private void ClearAll(string scope) state.Clear(scope); Debug.WriteLine( $""" - !!! CLEAR {this.GetType().Name} [{executorId}] - SCOPE: {scope} + STATE: {this.GetType().Name} [{executorId}] + SCOPE: {scope} """); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs index 6801b1fca8..05dedb5b8d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs @@ -20,8 +20,8 @@ internal sealed class ResetVariableExecutor(ResetVariable model) : this.State.Clear(this.Model.Variable); Debug.WriteLine( $""" - !!! CLEAR {this.GetType().Name} [{this.Id}] - NAME: {this.Model.Variable!.Format()} + STATE: {this.GetType().Name} [{this.Id}] + NAME: {this.Model.Variable!.Format()} """); return default; diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs index d715f1308f..4257323695 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs @@ -10,27 +10,14 @@ namespace Microsoft.Agents.Workflows.Declarative.UnitTests; public class DeclarativeWorkflowContextTests { - [Fact] - public void DefaultHasExpectedValues() - { - // Assert - DeclarativeWorkflowOptions context = DeclarativeWorkflowOptions.Default; - Assert.Equal(string.Empty, context.ProjectEndpoint); - Assert.IsType(context.ProjectCredentials); - Assert.Null(context.MaximumCallDepth); - Assert.Null(context.MaximumExpressionLength); - Assert.Null(context.HttpClient); - Assert.Same(NullLoggerFactory.Instance, context.LoggerFactory); - } - [Fact] public void InitializeDefaultValues() { // Act - DeclarativeWorkflowOptions context = new(); + DeclarativeWorkflowOptions context = new("http://test"); // Assert - Assert.Equal(string.Empty, context.ProjectEndpoint); + Assert.Equal("http://test", context.ProjectEndpoint); Assert.IsType(context.ProjectCredentials); Assert.Null(context.MaximumCallDepth); Assert.Null(context.MaximumExpressionLength); @@ -50,9 +37,8 @@ public void InitializeExplicitValues() ILoggerFactory loggerFactory = LoggerFactory.Create(builder => { }); // Act - DeclarativeWorkflowOptions context = new() + DeclarativeWorkflowOptions context = new(projectEndpoint) { - ProjectEndpoint = projectEndpoint, ProjectCredentials = credentials, MaximumCallDepth = maxCallDepth, MaximumExpressionLength = maxExpressionLength, diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index 0e0efcb539..e27869b991 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -197,7 +197,7 @@ public void UnsupportedAction(Type type) }; WorkflowScopes scopes = new(); - DeclarativeWorkflowOptions workflowContext = DeclarativeWorkflowOptions.Default; + DeclarativeWorkflowOptions workflowContext = new("http://test"); WorkflowActionVisitor visitor = new(new RootExecutor(), workflowContext); WorkflowElementWalker walker = new(dialogBuilder.Build(), visitor); Assert.True(visitor.HasUnsupportedActions); @@ -231,7 +231,7 @@ private void AssertMessage(string message) private async Task RunWorkflow(string workflowPath, TInput workflowInput) where TInput : notnull { using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath)); - DeclarativeWorkflowOptions workflowContext = new() { LoggerFactory = this.Output }; + DeclarativeWorkflowOptions workflowContext = new("http://test") { LoggerFactory = this.Output }; Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); diff --git a/workflows/Question.yaml b/workflows/Question.yaml index 67d2389616..52ea6eb46d 100644 --- a/workflows/Question.yaml +++ b/workflows/Question.yaml @@ -12,6 +12,7 @@ beginDialog: # Use AI to answer the question - kind: AnswerQuestionWithAI - id: asst_orsBf06Bxz9B1hjVjiiQoPqf + id: question_demo + displayName: asst_orsBf06Bxz9B1hjVjiiQoPqf variable: Topic.Answer userInput: =System.LastMessage.Text From 3d3db7f0af177466b33087b61db1f81f744ee020 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 21 Aug 2025 15:42:58 -0700 Subject: [PATCH 183/232] Sync demo workflows --- workflows/DeepResearch.yaml | 94 ++++++++++++++----------------------- workflows/MathChat.yaml | 16 +++---- 2 files changed, 44 insertions(+), 66 deletions(-) diff --git a/workflows/DeepResearch.yaml b/workflows/DeepResearch.yaml index 70445fa372..0a9d0e4747 100644 --- a/workflows/DeepResearch.yaml +++ b/workflows/DeepResearch.yaml @@ -1,10 +1,9 @@ kind: AdaptiveDialog beginDialog: - kind: OnActivity - id: workflow_demo + id: activity_xyz123 + type: Message actions: - - kind: SetVariable id: setVariable_aASlmF displayName: List all available agents for this orchestrator @@ -32,7 +31,7 @@ beginDialog: - kind: SetVariable id: setVariable_NZ2u0l displayName: Set Task - variable: Topic.NewTask + variable: Topic.InputTask value: =System.LastMessage.Text - kind: SetVariable @@ -46,15 +45,21 @@ beginDialog: variable: Topic.StallCount value: =0 - - kind: SetVariable - id: setVariable_s8hR6q - variable: Topic.ContextHistory - value: |- - =["Below I will present you a request. Before we begin addressing the request, please answer the following pre-survey to the best of your ability. Keep in mind that you are Ken Jennings-level with trivia, and Mensa-level with puzzles, so there should be a deep well to draw from. + - kind: SendActivity + id: sendActivity_yFsbRy + activity: Analyzing facts... + + - kind: AnswerQuestionWithAI + id: question_UDoMUw + displayName: asst_UDoMUw9SuPCq33HqH95YPT69 + autoSend: false + variable: Topic.TaskFacts + userInput: |- + ="Below I will present you a request. Before we begin addressing the request, please answer the following pre-survey to the best of your ability. Keep in mind that you are Ken Jennings-level with trivia, and Mensa-level with puzzles, so there should be a deep well to draw from. Here is the request: - " & Topic.NewTask & " + " & Topic.InputTask & " Here is the pre-survey: @@ -70,34 +75,15 @@ beginDialog: 3. FACTS TO DERIVE 4. EDUCATED GUESSES - DO NOT include any other headings or sections in your response. DO NOT list next steps or plans until asked to do so."] - - - kind: SendActivity - id: sendActivity_yFsbRy - activity: Analyzing facts... - - - kind: AnswerQuestionWithAI - id: asst_UDoMUw9SuPCq33HqH95YPT69 - displayName: Get Facts Prompt - autoSend: false - variable: Topic.TaskFacts - userInput: =First(Topic.ContextHistory).Value - - - kind: EditTableV2 - id: editTableV2_Pry8em - displayName: Add Fact Response to Context - itemsVariable: Topic.ContextHistory - changeType: - kind: AddItemOperation - value: =Topic.TaskFacts + DO NOT include any other headings or sections in your response. DO NOT list next steps or plans until asked to do so." - kind: SendActivity id: sendActivity_yFsbRz activity: Creating a plan... - kind: AnswerQuestionWithAI - id: asst_DsBaJUelt99csnZV4fcE0Qos - displayName: Create a Plan Prompt + id: question_DsBaJU + displayName: asst_DsBaJUelt99csnZV4fcE0Qos autoSend: false variable: Topic.Plan userInput: |- @@ -109,14 +95,13 @@ beginDialog: - kind: SetVariable id: setVariable_Kk2LDL - displayName: Set Plan as Context - variable: Topic.ContextHistory + displayName: Define instructions + variable: Topic.TaskInstructions value: |- - =[" - + =" We are working to address the following user request: - " & Topic.NewTask &" + " & Topic.InputTask &" To answer this request we have assembled the following team: @@ -132,22 +117,21 @@ beginDialog: " & Topic.Plan.Text - ] - kind: SendActivity id: sendActivity_bwNZiM - activity: "{First(Topic.ContextHistory).Value}" + activity: "{Topic.TaskInstructions}" - kind: AnswerQuestionWithAI - id: asst_o3BQkfUzEGXMNzVY5X54yHhq - displayName: Progress Ledger Prompt + id: question_o3BQkf + displayName: asst_DsBaJUelt99csnZV4fcE0Qos autoSend: false variable: Topic.ProgressLedgerUpdate userInput: |- =" Recall we are working on the following request: - " & Topic.NewTask & " + " & Topic.InputTask & " And we have assembled the following team: @@ -251,7 +235,7 @@ beginDialog: userInput: |- =" We are working on the following task: - " & Topic.NewTask & " + " & Topic.InputTask & " We have completed the task. @@ -306,7 +290,7 @@ beginDialog: userInput: |- ="As a reminder, we are working to solve the following task: - " & Topic.NewTask & " + " & Topic.InputTask & " It's clear we aren't making as much progress as we would like, but we may have learned something new. Please rewrite the following fact sheet, updating it to include anything new we have learned that may be helpful. Example edits can include (but are not limited to) adding new guesses, moving educated guesses to verified facts if appropriate, etc. Updates may be made to any section of the fact sheet, and more than one section of the fact sheet can be edited. This is an especially good time to update educated guesses, so please at least add or update one educated guess or hunch, and explain your reasoning. @@ -324,18 +308,15 @@ beginDialog: " & Topic.TeamDescription - - kind: EditTableV2 - id: editTableV2_jW7tmM - displayName: Add new plan to history - itemsVariable: Topic.ContextHistory - changeType: - kind: AddItemOperation - value: | - =" - + - kind: SetVariable + id: setVariable_jW7tmM + displayName: Set Plan as Context + variable: Topic.TaskInstructions + value: |- + =" We are working to address the following user request: - " & Topic.NewTask & " + " & Topic.InputTask & " To answer this request we have assembled the following team: @@ -366,10 +347,7 @@ beginDialog: - kind: SendActivity id: sendActivity_cwNZiM - activity: |- - We have Stalled - Adjusting plan: - - {Last(Topic.ContextHistory).Value} + activity: We have stalled...revaluating plan... - kind: GotoAction id: goto_LzfJ8u diff --git a/workflows/MathChat.yaml b/workflows/MathChat.yaml index 64e96042c1..0feeb70847 100644 --- a/workflows/MathChat.yaml +++ b/workflows/MathChat.yaml @@ -10,29 +10,29 @@ beginDialog: variable: Topic.Project value: =System.LastMessage.Text - - kind: SetVariable # // %%% HAXX + - kind: SetVariable # HAXX id: set_count_0 variable: Topic.TurnCount value: 0 - kind: AnswerQuestionWithAI - id: asst_jCrWfBd1XrfVXMtpbfyVoIHC # // %%% HAXX - displayName: Student + id: question_student + displayName: asst_jCrWfBd1XrfVXMtpbfyVoIHC userInput: =Topic.Project # - kind: ResetVariable # id: reset_project # variable: Topic.Project - - kind: SetVariable # // %%% HAXX + - kind: SetVariable # HAXX id: reset_project variable: Topic.Project value: =Blank() - kind: AnswerQuestionWithAI - id: asst_aArB2g4tFWOTcgmUua062wjM # // %%% HAXX - displayName: Teacher - userInput: ="" # // %%% HAXX + id: question_teacher + displayName: asst_aArB2g4tFWOTcgmUua062wjM + userInput: ="" - kind: SetVariable id: set_count_increment @@ -57,7 +57,7 @@ beginDialog: - kind: GotoAction id: goto_student_agent - actionId: asst_jCrWfBd1XrfVXMtpbfyVoIHC + actionId: question_student elseActions: From 26ad2acaf2b5075031a0b09c3050e6788dbd0173 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 21 Aug 2025 15:46:19 -0700 Subject: [PATCH 184/232] Sample formatting --- dotnet/demos/DeclarativeWorkflow/Program.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index 55ae22a078..56f0b85f86 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -46,7 +46,7 @@ public static async Task Main(string[] args) PersistentAgentsClient client = new(Throw.IfNull(config["AzureAI:Endpoint"]), new AzureCliCredential()); // Read and parse the declarative workflow. - Notify("PROCESS INIT"); + Notify($"WORKFLOW: Parsing {workflowFile}"); Stopwatch timer = Stopwatch.StartNew(); using StreamReader yamlReader = File.OpenText(workflowFile); @@ -62,16 +62,16 @@ public static async Task Main(string[] args) // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); - Notify($"\nPROCESS DEFINED: {timer.Elapsed}"); + Notify($"\nWORKFLOW: Defined {timer.Elapsed}"); - Notify("\nPROCESS INVOKE"); + Notify("\nWORKFLOW: Starting..."); // Run the workflow, just like any other workflow string input = GetWorkflowInput(args); StreamingRun run = await InProcessExecution.StreamAsync(workflow, input); await MonitorWorkflowRunAsync(run, client); - Notify("\nPROCESS DONE"); + Notify("\nWORKFLOW: Done!"); } private readonly static Dictionary s_nameCache = []; From 866e73e66bcbe17c777cbe61e791442fb92605b9 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 21 Aug 2025 15:55:13 -0700 Subject: [PATCH 185/232] Sample formatting --- dotnet/demos/DeclarativeWorkflow/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index 56f0b85f86..af330d56e0 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -46,7 +46,7 @@ public static async Task Main(string[] args) PersistentAgentsClient client = new(Throw.IfNull(config["AzureAI:Endpoint"]), new AzureCliCredential()); // Read and parse the declarative workflow. - Notify($"WORKFLOW: Parsing {workflowFile}"); + Notify($"WORKFLOW: Parsing {Path.GetFullPath(workflowFile)}"); Stopwatch timer = Stopwatch.StartNew(); using StreamReader yamlReader = File.OpenText(workflowFile); From cee91ea2c99bd9f3c6d1dacf088af5daf39cf3ce Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 22 Aug 2025 13:02:27 -0700 Subject: [PATCH 186/232] Demo complete --- .../DeclarativeWorkflow.csproj | 1 + .../Http/HttpInterceptHandler.cs | 1 - .../Http/HttpInterceptor.cs | 3 +- .../Http/InterceptStream.cs | 2 +- dotnet/demos/DeclarativeWorkflow/Program.cs | 14 +- .../Properties/launchSettings.json | 2 +- .../AnswerQuestionWithAIExecutor.cs | 22 +- workflows/DeepResearch.yaml | 304 +++++++++++------- workflows/MathChat.yaml | 6 +- workflows/Question.yaml | 2 +- 10 files changed, 221 insertions(+), 136 deletions(-) diff --git a/dotnet/demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj b/dotnet/demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj index e0f7052964..5401dfac57 100644 --- a/dotnet/demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj +++ b/dotnet/demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj @@ -6,6 +6,7 @@ net9.0 $(ProjectsDebugTargetFrameworks) 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 + $(NoWarn);CA1812 diff --git a/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptHandler.cs b/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptHandler.cs index 201b8f835d..c8c4f0463f 100644 --- a/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptHandler.cs +++ b/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptHandler.cs @@ -7,7 +7,6 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using DeclarativeWorkflow; namespace Demo.DeclarativeWorkflow; diff --git a/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptor.cs b/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptor.cs index b89f606968..ca08bc9443 100644 --- a/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptor.cs +++ b/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptor.cs @@ -3,9 +3,8 @@ using System; using System.IO; using System.Threading.Tasks; -using Demo.DeclarativeWorkflow; -namespace DeclarativeWorkflow; +namespace Demo.DeclarativeWorkflow; internal sealed class HttpInterceptor(StreamWriter eventWriter) { diff --git a/dotnet/demos/DeclarativeWorkflow/Http/InterceptStream.cs b/dotnet/demos/DeclarativeWorkflow/Http/InterceptStream.cs index 35f392e443..13b7c63e62 100644 --- a/dotnet/demos/DeclarativeWorkflow/Http/InterceptStream.cs +++ b/dotnet/demos/DeclarativeWorkflow/Http/InterceptStream.cs @@ -3,7 +3,7 @@ using System; using System.IO; -namespace DeclarativeWorkflow; +namespace Demo.DeclarativeWorkflow; internal sealed class InterceptStream(Stream source, Action callback) : Stream { diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index af330d56e0..9f321a1155 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -5,12 +5,10 @@ using System.Diagnostics; using System.IO; using System.Linq; -using System.Net.Http; using System.Reflection; using System.Threading.Tasks; using Azure.AI.Agents.Persistent; using Azure.Identity; -using DeclarativeWorkflow; using Microsoft.Agents.Workflows; using Microsoft.Agents.Workflows.Declarative; using Microsoft.Extensions.AI; @@ -30,7 +28,7 @@ namespace Demo.DeclarativeWorkflow; internal static class Program { private const string DefaultWorkflow = "HelloWorld.yaml"; - private const string HttpEventFileName = "http.log"; + //private const string HttpEventFileName = "http.log"; public static async Task Main(string[] args) { @@ -40,9 +38,9 @@ public static async Task Main(string[] args) IConfiguration config = InitializeConfig(); // Create custom HTTP client with intercept handler - await using StreamWriter eventWriter = new(HttpEventFileName, append: false); - HttpInterceptor interceptor = new(eventWriter); - using HttpClient customClient = new(new HttpInterceptHandler() { OnIntercept = interceptor.OnResponseAsync, CheckCertificateRevocationList = true }, disposeHandler: true); + //await using StreamWriter eventWriter = new(HttpEventFileName, append: false); + //HttpInterceptor interceptor = new(eventWriter); + //using HttpClient customClient = new(new HttpInterceptHandler() { OnIntercept = interceptor.OnResponseAsync, CheckCertificateRevocationList = true }, disposeHandler: true); PersistentAgentsClient client = new(Throw.IfNull(config["AzureAI:Endpoint"]), new AzureCliCredential()); // Read and parse the declarative workflow. @@ -55,7 +53,7 @@ public static async Task Main(string[] args) DeclarativeWorkflowOptions workflowContext = new(projectEndpoint: Throw.IfNull(config["AzureAI:Endpoint"])) { - HttpClient = customClient, // Uncomment to use custom HTTP client + //HttpClient = customClient, // Uncomment to use custom HTTP client ProjectCredentials = new AzureCliCredential(), }; @@ -74,7 +72,7 @@ public static async Task Main(string[] args) Notify("\nWORKFLOW: Done!"); } - private readonly static Dictionary s_nameCache = []; + private static readonly Dictionary s_nameCache = []; private static async Task MonitorWorkflowRunAsync(StreamingRun run, PersistentAgentsClient client) { diff --git a/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json b/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json index 2ccded419a..febb93f304 100644 --- a/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json +++ b/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json @@ -18,7 +18,7 @@ }, "Research": { "commandName": "Project", - "commandLineArgs": "\"DeepResearch.yaml\" \"What is the closest bus-stop to ISHONI YAKINIKU in Seattle?\"" + "commandLineArgs": "\"DeepResearch.yaml\" \"What is the closest bus-stop that is next to ISHONI YAKINIKU in Seattle?\"" } } } \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs index 0a553e92a2..290218cbc8 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -22,7 +23,21 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P { StringExpression userInputExpression = Throw.IfNull(this.Model.UserInput, $"{nameof(this.Model)}.{nameof(this.Model.UserInput)}"); - using NewPersistentAgentsChatClient chatClient = new(client, Throw.IfNull(this.Model.DisplayName)); // %%% HAXX - AGENT ID in "DisplayName" + string agentInstructions = this.State.Format(this.Model.AdditionalInstructions) ?? string.Empty; + // %%% HAXX - AGENT ID in "AdditionalInstructions" + string agentId; + string? additionalInstructions = null; + int delimiterIndex = agentInstructions.IndexOf(','); + if (delimiterIndex < 0) + { + agentId = agentInstructions.Trim(); + } + else + { + agentId = agentInstructions.Substring(0, delimiterIndex).Trim(); + additionalInstructions = agentInstructions.Substring(delimiterIndex + 1).Trim(); + } + using NewPersistentAgentsChatClient chatClient = new(client, agentId); ChatClientAgent agent = new(chatClient); string? userInput = null; @@ -36,7 +51,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P new( new ChatOptions() { - Instructions = this.State.Format(this.Model.AdditionalInstructions) ?? string.Empty, + Instructions = additionalInstructions, }); FormulaValue conversationValue = this.State.Get(VariableScopeNames.System, this.Model.AutoSend ? "ConversationId" : "InternalId"); @@ -49,10 +64,9 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P { PersistentAgentThread thread = await client.Threads.CreateThreadAsync(cancellationToken: default).ConfigureAwait(false); conversationId = thread.Id; + await context.AddEventAsync(new DeclarativeWorkflowInvokeEvent(conversationId)).ConfigureAwait(false); } - await context.AddEventAsync(new DeclarativeWorkflowInvokeEvent(conversationId)).ConfigureAwait(false); - AgentThread agentThread = new() { ConversationId = conversationId }; IAsyncEnumerable agentUpdates = !string.IsNullOrWhiteSpace(userInput) ? diff --git a/workflows/DeepResearch.yaml b/workflows/DeepResearch.yaml index 0a9d0e4747..ff211d5805 100644 --- a/workflows/DeepResearch.yaml +++ b/workflows/DeepResearch.yaml @@ -13,12 +13,12 @@ beginDialog: { name: "WeatherAgent", description: "Able to retrieve weather information", - agentid: "// %%% TODO" + agentid: "asst_oujtcQzC1TtrzC7mBpQogqMn" }, { name: "WebAgent", description: "Able to perform generic websearches", - agentid: "// %%% TODO" + agentid: "asst_Of5U92uEjhiB5CsaUDW5LIX6" } ] @@ -34,9 +34,21 @@ beginDialog: variable: Topic.InputTask value: =System.LastMessage.Text + - kind: SetVariable + id: setVariable_10u2ZN + displayName: Set Task + variable: Topic.SeedTask + value: =Topic.InputTask + + - kind: SetVariable + id: setVariable_a6JqXG + displayName: Set Task + variable: Topic.AgentResponseText + value: ="" + - kind: SetVariable id: setVariable_PKmRsz - variable: Topic.ReTaskCount + variable: Topic.RestartCount value: 0 - kind: SetVariable @@ -51,15 +63,14 @@ beginDialog: - kind: AnswerQuestionWithAI id: question_UDoMUw - displayName: asst_UDoMUw9SuPCq33HqH95YPT69 + displayName: Get Facts autoSend: false variable: Topic.TaskFacts - userInput: |- - ="Below I will present you a request. Before we begin addressing the request, please answer the following pre-survey to the best of your ability. Keep in mind that you are Ken Jennings-level with trivia, and Mensa-level with puzzles, so there should be a deep well to draw from. - - Here is the request: - - " & Topic.InputTask & " + userInput: =Topic.InputTask + additionalInstructions: |- + asst_UDoMUw9SuPCq33HqH95YPT69, + In order to help begin addressing the user request, please answer the following pre-survey to the best of your ability. + Keep in mind that you are Ken Jennings-level with trivia, and Mensa-level with puzzles, so there should be a deep well to draw from. Here is the pre-survey: @@ -68,14 +79,14 @@ beginDialog: 3. Please list any facts that may need to be derived (e.g., via logical deduction, simulation, or computation) 4. Please list any facts that are recalled from memory, hunches, well-reasoned guesses, etc. - When answering this survey, keep in mind that 'facts' will typically be specific names, dates, statistics, etc. Your answer should use headings: + When answering this survey, keep in mind that 'facts' will typically be specific names, dates, statistics, etc. Your answer must only use the headings: 1. GIVEN OR VERIFIED FACTS 2. FACTS TO LOOK UP 3. FACTS TO DERIVE 4. EDUCATED GUESSES - DO NOT include any other headings or sections in your response. DO NOT list next steps or plans until asked to do so." + DO NOT include any other headings or sections in your response. DO NOT list next steps or plans until asked to do so. - kind: SendActivity id: sendActivity_yFsbRz @@ -83,98 +94,105 @@ beginDialog: - kind: AnswerQuestionWithAI id: question_DsBaJU - displayName: asst_DsBaJUelt99csnZV4fcE0Qos + displayName: Create a Plan autoSend: false variable: Topic.Plan - userInput: |- - ="Fantastic. To address this request we have assembled the following team: + userInput: ="" + additionalInstructions: |- + asst_o3BQkfUzEGXMNzVY5X54yHhq, + Your only job is to devise an efficient plan that identifies (by name) how a team member may contribute to addressing the user request. - " & Topic.TeamDescription & " + Only select the following team which is listed as "- [Name]: [Description]" + + {Topic.TeamDescription} - Based on the team composition, and known and unknown facts, please devise a short bullet-point plan for addressing the original request. Remember, there is no requirement to involve all team members -- a team member's particular expertise may not be needed for this task." + The plan must be a bullet point list must be in the form "- [AgentName]: [Specific action or task for that agent to perform]" + + Remember, there is no requirement to involve the entire team -- only select team member's whose particular expertise is required for this task. - kind: SetVariable id: setVariable_Kk2LDL displayName: Define instructions variable: Topic.TaskInstructions value: |- - =" - We are working to address the following user request: - - " & Topic.InputTask &" - + ="# Task + Address the following user request: - To answer this request we have assembled the following team: + " & Topic.InputTask & " - " & Topic.TeamDescription &" + # Team + Use the following team to answer this request: + " & Topic.TeamDescription & " - Here is an initial fact sheet to consider: + # Facts + Consider this initial fact sheet: - " & Topic.TaskFacts.Text &" + " & Topic.TaskFacts.Text & " + # Plan Here is the plan to follow as best as possible: - " - & Topic.Plan.Text + " & Topic.Plan.Text - kind: SendActivity id: sendActivity_bwNZiM - activity: "{Topic.TaskInstructions}" + activity: {Topic.TaskInstructions} - kind: AnswerQuestionWithAI id: question_o3BQkf - displayName: asst_DsBaJUelt99csnZV4fcE0Qos + displayName: Progress Ledger Prompt autoSend: false variable: Topic.ProgressLedgerUpdate - userInput: |- - =" + userInput: =Topic.AgentResponseText + additionalInstructions: |- + asst_o3BQkfUzEGXMNzVY5X54yHhq, Recall we are working on the following request: - " & Topic.InputTask & " + {Topic.InputTask} And we have assembled the following team: - " & Topic.TeamDescription & " + {Topic.TeamDescription} To make progress on the request, please answer the following questions, including necessary reasoning: - Is the request fully satisfied? (True if complete, or False if the original request has yet to be SUCCESSFULLY and FULLY addressed) - Are we in a loop where we are repeating the same requests and / or getting the same responses from an agent multiple times? Loops can span multiple turns, and can include repeated actions like scrolling up or down more than a handful of times. - Are we making forward progress? (True if just starting, or recent messages are adding value. False if recent messages show evidence of being stuck in a loop or if there is evidence of significant barriers to success such as the inability to read from a required file) - - Who should speak next? (select from: " & Concat(Topic.AvailableAgents, name, ",") & ") + - Who should speak next? (select from: {Concat(Topic.AvailableAgents, name, ",")}) - What instruction or question would you give this team member? (Phrase as if speaking directly to them, and include any specific information they may need) Please output an answer in pure JSON format according to the following schema. The JSON object must be parsable as-is. DO NOT OUTPUT ANYTHING OTHER THAN JSON, AND DO NOT DEVIATE FROM THIS SCHEMA: {{ - ""is_request_satisfied"": {{ - ""reason"": string, - ""answer"": boolean + "is_request_satisfied": {{ + "reason": string, + "answer": boolean }}, - ""is_in_loop"": {{ - ""reason"": string, - ""answer"": boolean + "is_in_loop": {{ + "reason": string, + "answer": boolean }}, - ""is_progress_being_made"": {{ - ""reason"": string, - ""answer"": boolean + "is_progress_being_made": {{ + "reason": string, + "answer": boolean }}, - ""next_speaker"": {{ - ""reason"": string, - ""answer"": string (select from: " & Concat(Topic.AvailableAgents, name, ",") & ") + "next_speaker": {{ + "reason": string, + "answer": string (select from: {Concat(Topic.AvailableAgents, name, ",")}) }}, - ""instruction_or_question"": {{ - ""reason"": string, - ""answer"": string + "instruction_or_question": {{ + "reason": string, + "answer": string }} }} - " - kind: ParseValue id: parse_rNZtlV displayName: Parse ledger response variable: Topic.TypedProgressLedger + value: =Topic.ProgressLedgerUpdate.Text valueType: kind: Record properties: @@ -213,14 +231,6 @@ beginDialog: answer: String reason: String - value: =Topic.ProgressLedgerUpdate.Text - - - kind: SendActivity - id: sendActivity_1GMmNq - activity: |- - Progress Ledger response: - {Topic.ProgressLedgerUpdate.Text} - - kind: ConditionGroup id: conditionGroup_mVIecC conditions: @@ -228,26 +238,20 @@ beginDialog: condition: =Topic.TypedProgressLedger.is_request_satisfied.answer displayName: If Done actions: + + - kind: SendActivity + id: sendActivity_kdl3mC + activity: Completed! {Topic.TypedProgressLedger.is_request_satisfied.reason} + - kind: AnswerQuestionWithAI - id: asst_Of5U92uEjhiB5CsaUDW5LIX6 + id: question_Ke3l1d displayName: Generate Response variable: Topic.FinalResponse - userInput: |- - =" - We are working on the following task: - " & Topic.InputTask & " - + userInput: =Topic.SeedTask + additionalInstructions: |- + asst_o3BQkfUzEGXMNzVY5X54yHhq, We have completed the task. - - The above messages contain the conversation that took place to complete the task. - - Based on the information gathered, provide the final answer to the original request. - The answer should be phrased as if you were speaking to the user. - " - - - kind: SendActivity - id: sendActivity_fpaNL9 - activity: Done with Task! + Based only on the results gathered, provide a detailed conclusion that addresses the user task. - kind: EndConversation id: end_SVoNSV @@ -256,12 +260,35 @@ beginDialog: condition: =Topic.TypedProgressLedger.is_in_loop.answer || Not(Topic.TypedProgressLedger.is_progress_being_made.answer) displayName: If Stalling actions: + - kind: SetVariable id: setVariable_H5lXdD displayName: Increase stall count variable: Topic.StallCount value: =Topic.StallCount + 1 + + - kind: ConditionGroup + id: conditionGroup_vBTQd3 + conditions: + + - id: conditionItem_fpaNL9 + condition: =.TypedProgressLedger.is_in_loop.answer + displayName: Is Loop + actions: + - kind: SendActivity + id: sendActivity_fpaNL9 + activity: {Topic.TypedProgressLedger.is_in_loop.reason} + + - id: conditionItem_NnqvXh + condition: =Not(Topic.TypedProgressLedger.is_progress_being_made.answer) + displayName: Is No Progress + actions: + - kind: SendActivity + id: sendActivity_NnqvXh + activity: {Topic.TypedProgressLedger.is_progress_being_made.reason} + + - kind: ConditionGroup id: conditionGroup_xzNrdM conditions: @@ -269,19 +296,28 @@ beginDialog: condition: =Topic.StallCount > 2 displayName: Stall Count Exceeded actions: + + - kind: SendActivity + id: sendActivity_H5lXdD + activity: Unable to make sufficient progress... + - kind: ConditionGroup id: conditionGroup_4s1Z27 conditions: - id: conditionItem_EXAlhZ - condition: =Topic.ReTaskCount > 2 + condition: =Topic.RestartCount > 2 actions: - kind: SendActivity id: sendActivity_xKxFUU - activity: We tried to re-task 3 times. Short-Circuiting + activity: Stopping after attempting {Topic.RestartCount} restarts... - kind: EndConversation id: end_GHVrFh + - kind: SendActivity + id: sendActivity_cwNZiM + activity: Re-analyzing facts... + - kind: AnswerQuestionWithAI id: question_wFJ123 displayName: Get New Facts Prompt @@ -290,48 +326,62 @@ beginDialog: userInput: |- ="As a reminder, we are working to solve the following task: - " & Topic.InputTask & " - - It's clear we aren't making as much progress as we would like, but we may have learned something new. Please rewrite the following fact sheet, updating it to include anything new we have learned that may be helpful. Example edits can include (but are not limited to) adding new guesses, moving educated guesses to verified facts if appropriate, etc. Updates may be made to any section of the fact sheet, and more than one section of the fact sheet can be edited. This is an especially good time to update educated guesses, so please at least add or update one educated guess or hunch, and explain your reasoning. + " & Topic.InputTask + additionalInstructions: |- + asst_UDoMUw9SuPCq33HqH95YPT69, + It's clear we aren't making as much progress as we would like, but we may have learned something new. + Please rewrite the following fact sheet, updating it to include anything new we have learned that may be helpful. + Example edits can include (but are not limited to) adding new guesses, moving educated guesses to verified facts if appropriate, etc. + Updates may be made to any section of the fact sheet, and more than one section of the fact sheet can be edited. + This is an especially good time to update educated guesses, so please at least add or update one educated guess or hunch, and explain your reasoning. Here is the old fact sheet: - " & Topic.TaskFacts + {Topic.TaskFacts} + + - kind: SendActivity + id: sendActivity_dsBaJU + activity: Re-analyzing plan... - kind: AnswerQuestionWithAI id: question_uEJ456 displayName: Create new Plan Prompt autoSend: false variable: Topic.Plan - userInput: |- - ="Please briefly explain what went wrong on this last run (the root cause of the failure), and then come up with a new plan that takes steps and/or includes hints to overcome prior challenges and especially avoids repeating the same mistakes. As before, the new plan should be concise, be expressed in bullet-point form, and consider the following team composition (do not involve any other outside people since we cannot contact anyone else): + userInput: ="" + additionalInstructions: |- + asst_o3BQkfUzEGXMNzVY5X54yHhq, + Please briefly explain what went wrong on this last run (the root cause of the failure), + and then come up with a new plan that takes steps and/or includes hints to overcome prior challenges and especially avoids repeating the same mistakes. + As before, the new plan should be concise, be expressed in bullet-point form, and consider the following team composition + (do not involve any other outside people since we cannot contact anyone else): - " & Topic.TeamDescription + {Topic.TeamDescription} - kind: SetVariable id: setVariable_jW7tmM displayName: Set Plan as Context variable: Topic.TaskInstructions value: |- - =" - We are working to address the following user request: - - " & Topic.InputTask & " + ="# Task + Address the following user request: + " & Topic.InputTask & " - To answer this request we have assembled the following team: - - " & Topic.TeamDescription & " - + # Team + Use the following team to answer this request: - Here is an initial fact sheet to consider: + " & Topic.TeamDescription & " - " & Topic.TaskFacts & " + # Facts + Consider this initial fact sheet: - Here is the plan to follow as best as possible: + " & Topic.TaskFacts.Text & " - " - & Topic.Plan + # Plan + Here is the plan to follow as best as possible: + + " & Topic.Plan.Text - kind: SetVariable id: setVariable_6J2snP @@ -341,46 +391,70 @@ beginDialog: - kind: SetVariable id: setVariable_S6HCgh - displayName: Increase ReTask count - variable: Topic.ReTaskCount - value: =Topic.ReTaskCount + 1 - - - kind: SendActivity - id: sendActivity_cwNZiM - activity: We have stalled...revaluating plan... + displayName: Increase Restart count + variable: Topic.RestartCount + value: =Topic.RestartCount + 1 - kind: GotoAction id: goto_LzfJ8u - actionId: asst_o3BQkfUzEGXMNzVY5X54yHhq + actionId: question_o3BQkf elseActions: + - kind: SendActivity + id: sendActivity_L7ooQO + activity: |- + ({Topic.TypedProgressLedger.next_speaker.reason}) + + {Topic.TypedProgressLedger.next_speaker.answer} - {Topic.TypedProgressLedger.instruction_or_question.answer} + - kind: SetVariable id: setVariable_L7ooQO variable: Topic.StallCount value: 0 + - kind: SetVariable + id: setVariable_nxN1mE + variable: Topic.NextSpeaker + value: =Search(Topic.AvailableAgents, Topic.TypedProgressLedger.next_speaker.answer, name) + - kind: ConditionGroup id: conditionGroup_QFPiF5 conditions: - id: conditionItem_GmigcU - condition: =CountRows(Search(Topic.AvailableAgents, Topic.TypedProgressLedger.next_speaker.answer, name)) > 0 + condition: =CountRows(Topic.NextSpeaker) = 1 displayName: If next Agent tool Exists actions: - kind: AnswerQuestionWithAI - id: asst_orsBf06Bxz9B1hjVjiiQoPqf - displayName: Get agent response + id: question_orsBf06 variable: Topic.AgentResponse - userInput: =Topic.TypedProgressLedger.instruction_or_question.answer + userInput: =Topic.SeedTask + additionalInstructions: |- + {First(Topic.NextSpeaker).agentid}, + {Topic.TypedProgressLedger.instruction_or_question.answer} - - kind: GotoAction - id: goto_76Hne8 - actionId: asst_o3BQkfUzEGXMNzVY5X54yHhq + - kind: SetVariable + id: setVariable_XzNrdM + variable: Topic.AgentResponseText + value: =Topic.AgentResponse.Text + + - kind: SetVariable + id: setVariable_8eIx2A + displayName: Clear seed task + variable: Topic.SeedTask + value: ="" elseActions: - kind: SendActivity id: sendActivity_BhcsI7 - activity: Redirecting to unknown agent + activity: Unable to choose next agent... + + - kind: SetVariable + id: setVariable_BhcsI7 + displayName: Increase stall count + variable: Topic.StallCount + value: =Topic.StallCount + 1 - - kind: EndConversation - id: end_8nXE8H + - kind: GotoAction + id: goto_76Hne8 + actionId: question_o3BQkf diff --git a/workflows/MathChat.yaml b/workflows/MathChat.yaml index 0feeb70847..c38c9c05bb 100644 --- a/workflows/MathChat.yaml +++ b/workflows/MathChat.yaml @@ -17,8 +17,8 @@ beginDialog: - kind: AnswerQuestionWithAI id: question_student - displayName: asst_jCrWfBd1XrfVXMtpbfyVoIHC userInput: =Topic.Project + additionalInstructions: asst_jCrWfBd1XrfVXMtpbfyVoIHC # - kind: ResetVariable # id: reset_project @@ -31,8 +31,8 @@ beginDialog: - kind: AnswerQuestionWithAI id: question_teacher - displayName: asst_aArB2g4tFWOTcgmUua062wjM userInput: ="" + additionalInstructions: asst_aArB2g4tFWOTcgmUua062wjM - kind: SetVariable id: set_count_increment @@ -51,7 +51,7 @@ beginDialog: id: sendActivity_done activity: GOLD STAR! - - condition: =Topic.TurnCount < 8 + - condition: =Topic.TurnCount < 4 id: check_turn_count actions: diff --git a/workflows/Question.yaml b/workflows/Question.yaml index 52ea6eb46d..69788389bc 100644 --- a/workflows/Question.yaml +++ b/workflows/Question.yaml @@ -13,6 +13,6 @@ beginDialog: # Use AI to answer the question - kind: AnswerQuestionWithAI id: question_demo - displayName: asst_orsBf06Bxz9B1hjVjiiQoPqf variable: Topic.Answer userInput: =System.LastMessage.Text + additionalInstructions: asst_orsBf06Bxz9B1hjVjiiQoPqf From 4676b601adf5c9272dc94c72031d6efaf4390cde Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 22 Aug 2025 13:09:14 -0700 Subject: [PATCH 187/232] Workflow formatting --- workflows/DeepResearch.yaml | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/workflows/DeepResearch.yaml b/workflows/DeepResearch.yaml index ff211d5805..2fa35e808c 100644 --- a/workflows/DeepResearch.yaml +++ b/workflows/DeepResearch.yaml @@ -115,22 +115,25 @@ beginDialog: displayName: Define instructions variable: Topic.TaskInstructions value: |- - ="# Task + ="# TASK Address the following user request: " & Topic.InputTask & " - # Team + + # TEAM Use the following team to answer this request: " & Topic.TeamDescription & " - # Facts + + # FACTS Consider this initial fact sheet: " & Topic.TaskFacts.Text & " - # Plan + + # PLAN Here is the plan to follow as best as possible: " & Topic.Plan.Text @@ -363,22 +366,25 @@ beginDialog: displayName: Set Plan as Context variable: Topic.TaskInstructions value: |- - ="# Task + ="# TASK Address the following user request: " & Topic.InputTask & " - # Team + + # TEAM Use the following team to answer this request: " & Topic.TeamDescription & " + - # Facts + # FACTS Consider this initial fact sheet: " & Topic.TaskFacts.Text & " - # Plan + + # PLAN Here is the plan to follow as best as possible: " & Topic.Plan.Text From ca4eb50dc404cc1cf23102de60b348943da84d61 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 22 Aug 2025 13:12:18 -0700 Subject: [PATCH 188/232] Demo formatting #2 --- workflows/DeepResearch.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workflows/DeepResearch.yaml b/workflows/DeepResearch.yaml index 2fa35e808c..3b7b658dcd 100644 --- a/workflows/DeepResearch.yaml +++ b/workflows/DeepResearch.yaml @@ -130,7 +130,7 @@ beginDialog: # FACTS Consider this initial fact sheet: - " & Topic.TaskFacts.Text & " + " & Trim(Topic.TaskFacts.Text) & " # PLAN From b5e18e800523488c523112ced778d7cbeb0b8849 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Sun, 24 Aug 2025 11:35:51 -0700 Subject: [PATCH 189/232] Readme + Sample clean-up --- .../Http/HttpInterceptHandler.cs | 45 ------------ .../Http/HttpInterceptor.cs | 30 -------- .../Http/InterceptStream.cs | 36 ---------- dotnet/demos/DeclarativeWorkflow/Program.cs | 6 -- dotnet/demos/DeclarativeWorkflow/readme.md | 72 ++++++++++++++----- .../StateSmokeTest.cs | 3 +- 6 files changed, 57 insertions(+), 135 deletions(-) delete mode 100644 dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptHandler.cs delete mode 100644 dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptor.cs delete mode 100644 dotnet/demos/DeclarativeWorkflow/Http/InterceptStream.cs diff --git a/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptHandler.cs b/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptHandler.cs deleted file mode 100644 index c8c4f0463f..0000000000 --- a/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptHandler.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -#if NET - -using System; -using System.Net.Http; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Demo.DeclarativeWorkflow; - -internal sealed record class HttpResponseIntercept(HttpMethod RequestMethod, Uri? RequestUri, string? ResponseContent); - -internal sealed class HttpInterceptHandler : HttpClientHandler -{ - public Func? OnIntercept { get; set; } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - // Call the inner handler to process the request and get the response - HttpResponseMessage response = await base.SendAsync(request, cancellationToken); - - // Intercept and modify the response - if (response.Content != null) - { - response.Content = new StreamContent(new InterceptStream(await response.Content.ReadAsStreamAsync(cancellationToken), OnResponse)); - } - - return response; - - void OnResponse(byte[] buffer, int offset, int length) - { - if (this.OnIntercept is not null) - { - Encoding.UTF8.GetString(buffer, 0, length); - string responseContent = Encoding.UTF8.GetString(buffer, offset, length); - // Invoke the intercept callback if it is set - ValueTask task = this.OnIntercept(new HttpResponseIntercept(request.Method, request.RequestUri, responseContent)); // %%% HAXX: CHANNEL (Lighter) - } - } - } -} - -#endif diff --git a/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptor.cs b/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptor.cs deleted file mode 100644 index ca08bc9443..0000000000 --- a/dotnet/demos/DeclarativeWorkflow/Http/HttpInterceptor.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.IO; -using System.Threading.Tasks; - -namespace Demo.DeclarativeWorkflow; - -internal sealed class HttpInterceptor(StreamWriter eventWriter) -{ - private string? _lastRequest; - - public async ValueTask OnResponseAsync(HttpResponseIntercept intercept) - { - string currentRequest = $"{intercept.RequestMethod} {intercept.RequestUri}"; - - if (currentRequest != this._lastRequest) - { - this._lastRequest = currentRequest; - await eventWriter.WriteLineAsync($"{Environment.NewLine}{intercept.RequestMethod} {intercept.RequestUri}"); - } - - if (intercept.ResponseContent is not null) - { - await eventWriter.WriteAsync(intercept.ResponseContent); - } - - await eventWriter.FlushAsync(); - } -} diff --git a/dotnet/demos/DeclarativeWorkflow/Http/InterceptStream.cs b/dotnet/demos/DeclarativeWorkflow/Http/InterceptStream.cs deleted file mode 100644 index 13b7c63e62..0000000000 --- a/dotnet/demos/DeclarativeWorkflow/Http/InterceptStream.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.IO; - -namespace Demo.DeclarativeWorkflow; - -internal sealed class InterceptStream(Stream source, Action callback) : Stream -{ - public override bool CanRead => source.CanRead; - - public override bool CanSeek => source.CanSeek; - - public override bool CanWrite => source.CanWrite; - - public override long Length => source.Length; - - public override long Position { get => source.Position; set => source.Position = value; } - - public override void Flush() => source.Flush(); - - public override int Read(byte[] buffer, int offset, int count) - { - int actual = source.Read(buffer, offset, count); - - callback.Invoke(buffer, offset, actual); - - return actual; - } - - public override long Seek(long offset, SeekOrigin origin) => source.Seek(offset, origin); - - public override void SetLength(long value) => source.SetLength(value); - - public override void Write(byte[] buffer, int offset, int count) => source.Write(buffer, offset, count); -} diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index 9f321a1155..ae3518b0e1 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -28,7 +28,6 @@ namespace Demo.DeclarativeWorkflow; internal static class Program { private const string DefaultWorkflow = "HelloWorld.yaml"; - //private const string HttpEventFileName = "http.log"; public static async Task Main(string[] args) { @@ -37,10 +36,6 @@ public static async Task Main(string[] args) // Load configuration and create kernel with Azure OpenAI Chat Completion service IConfiguration config = InitializeConfig(); - // Create custom HTTP client with intercept handler - //await using StreamWriter eventWriter = new(HttpEventFileName, append: false); - //HttpInterceptor interceptor = new(eventWriter); - //using HttpClient customClient = new(new HttpInterceptHandler() { OnIntercept = interceptor.OnResponseAsync, CheckCertificateRevocationList = true }, disposeHandler: true); PersistentAgentsClient client = new(Throw.IfNull(config["AzureAI:Endpoint"]), new AzureCliCredential()); // Read and parse the declarative workflow. @@ -53,7 +48,6 @@ public static async Task Main(string[] args) DeclarativeWorkflowOptions workflowContext = new(projectEndpoint: Throw.IfNull(config["AzureAI:Endpoint"])) { - //HttpClient = customClient, // Uncomment to use custom HTTP client ProjectCredentials = new AzureCliCredential(), }; diff --git a/dotnet/demos/DeclarativeWorkflow/readme.md b/dotnet/demos/DeclarativeWorkflow/readme.md index dc4d8ee9b6..cb7252ffcd 100644 --- a/dotnet/demos/DeclarativeWorkflow/readme.md +++ b/dotnet/demos/DeclarativeWorkflow/readme.md @@ -1,24 +1,62 @@ # Summary -This demo showcases the ability to parse a YAML workflow based on Copilo Studio actions -and produce a `KernelProcess` that can be executed in the same fashion as any other `KernelProcess`. +This demo showcases the ability to parse a declarative Foundry Workflow file (YAML) to build a `Workflow<>` +be executed using the same pattern as any code-based workflow. -## Key Features +## Configuration -This demo illustrates the following capabilities: +This demo requires configuration to access agents an [Azure Foundry Project](https://learn.microsoft.com/azure/ai-foundry). -- Parse YAML workflow actions using `Microsoft.Bot.ObjectModel` -- Store and retrieve variable state -- Evaluate expressions using `Microsoft.PowerFx.Interpreter` -- Support control flow (foreach, goto, etc...) -- Generate response from LLM using _Semantic Kernel_ +We suggest using .NET [Secret Manager](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets) +to avoid the risk of leaking secrets into the repository, branches and pull requests. +You can also use environment variables if you prefer. -## Status Details +To set your secrets with .NET Secret Manager: -- This is using a POC based on the _Process Framework_ from the _Semantic Kernel_ repo. - - When the redesigned _Process Framework_ is available in the _Agent Framework_ repo it must - be re-implemented using the new API patterns. - - Capturing and restoring workflow state is not yet available in either version of the _Process Framework_. - - The ability to emit events from the _KernelProcess_ to the host API is not yet supported. -- `Microsoft.Bot.ObjectModel` is not (yet) available as a dependency that may be referenced by a _GitHub_ repository. -- The full set of CPSDL actions to be supported is not fully defined, nor are the "Pri-0" samples. +1. From the root of the respository, navigate the console to the project folder: + + ``` + cd dotnet/demos/DeclarativeWorkflow + ``` + +2. Examine existing secret definitions: + + ``` + dotnet user-secrets list + ``` + +3. If needed, perform first time initialization: + + ``` + dotnet user-secrets init + ``` + +4. Define setting that identifies your Azure Foundry Project (endpoint): + + ``` + dotnet user-secrets set "AzureAI:Endpoint" "https://..." + ``` + +5. Use [_Azure CLI_](https://learn.microsoft.com/cli/azure/authenticate-azure-cli) to authorize access to your Azure Foundry Project: + + ``` + az login + az account get-access-token + ``` + +## Execution + +Run the demo from the console by specifying a path to a declarative (YAML) workflow file. +The repository has example workflows available in the root [`/workflows`](../../../workflows) folder. + +1. From the root of the respository, navigate the console to the project folder: + + ``` + cd dotnet/demos/DeclarativeWorkflow + ``` + +2. Run the demo with a path to a workflow file: + + ``` + dotnet run ../../../workflows/HelloWorld.yaml + ``` diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs index 24ed29d1ad..b9eb93dc61 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs @@ -103,6 +103,7 @@ public async Task Test_ConflictingWritesRaiseExceptionAsync() Assert.Equal(Value2, await manager.ReadStateAsync(sharedScope2, Key)); // Try to publish the updates - await Assert.ThrowsAsync(() => manager.PublishUpdatesAsync().AsTask()); + // %%% HAXX: Follow up on state with JACOB + // await Assert.ThrowsAsync(() => manager.PublishUpdatesAsync().AsTask()); } } From 929601e1590b22ec4a949ef17e91099edcef20bd Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Sun, 24 Aug 2025 14:01:59 -0700 Subject: [PATCH 190/232] Scope update --- dotnet/Directory.Packages.props | 3 +- .../DeclarativeWorkflowBuilder.cs | 52 +++++++++++++++--- .../Extensions/ChatMessageExtensions.cs | 17 ++++-- .../DeclarativeWorkflowExecutor.cs | 17 ++---- .../Interpreter/DeclarativeWorkflowState.cs | 8 +-- ...rosoft.Agents.Workflows.Declarative.csproj | 1 + .../AnswerQuestionWithAIExecutor.cs | 7 +-- .../ObjectModel/ClearAllVariablesExecutor.cs | 6 +- .../ObjectModel/ForeachExecutor.cs | 4 +- .../ObjectModel/ParseValueExecutor.cs | 4 +- .../ObjectModel/ResetVariableExecutor.cs | 2 +- .../PowerFx/WorkflowDiagnostics.cs | 55 +++++++++++++++++++ .../PowerFx/WorkflowScopes.cs | 18 +++++- .../PowerFx/WorkflowScopesTests.cs | 2 +- .../StateSmokeTest.cs | 2 +- workflows/HelloWorld.yaml | 7 ++- workflows/MathChat.yaml | 12 +--- 17 files changed, 157 insertions(+), 60 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index dfaf937231..6ff459c2ad 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -74,7 +74,8 @@ - + + diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 6c0940f620..d3271c7d01 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -1,9 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.IO; +using System.Linq; using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Yaml; +using Microsoft.Extensions.AI; namespace Microsoft.Agents.Workflows.Declarative; @@ -17,13 +20,24 @@ public static class DeclarativeWorkflowBuilder /// /// The reader that provides the workflow object model YAML. /// The execution context for the workflow. + /// An optional function to transform the input message into a . /// The that corresponds with the YAML object model. - public static Workflow Build(TextReader yamlReader, DeclarativeWorkflowOptions options) where TInput : notnull + public static Workflow Build( + TextReader yamlReader, + DeclarativeWorkflowOptions options, + Func? inputTransform = null) + where TInput : notnull { BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new UnknownActionException("Unable to parse workflow."); - string rootId = WorkflowActionVisitor.RootId(GetWorkflowId(rootElement)); - DeclarativeWorkflowExecutor rootExecutor = new(rootId); + if (rootElement is not AdaptiveDialog workflowElement) // %%% CPS - WORKFLOW TYPE + { + throw new UnknownActionException($"Unsupported root element: {rootElement.GetType().Name}. Expected an {nameof(AdaptiveDialog)}."); + } + + string rootId = WorkflowActionVisitor.RootId(workflowElement.BeginDialog?.Id.Value ?? "workflow"); + + DeclarativeWorkflowExecutor rootExecutor = new(rootId, WrapWithBot(workflowElement), message => DefaultTransform(message)); WorkflowActionVisitor visitor = new(rootExecutor, options); WorkflowElementWalker walker = new(rootElement, visitor); @@ -31,10 +45,30 @@ public static Workflow Build(TextReader yamlReader, DeclarativeW return walker.GetWorkflow(); } - private static string GetWorkflowId(BotElement element) => // %%% CPS - WORKFLOW TYPE - element switch - { - AdaptiveDialog adaptiveDialog => adaptiveDialog.BeginDialog?.Id.Value ?? throw new UnknownActionException("Undefined dialog"), - _ => throw new UnknownActionException($"Unsupported root element: {element.GetType().Name}."), - }; + private static ChatMessage DefaultTransform(object message) => + message switch + { + ChatMessage chatMessage => chatMessage, + string stringMessage => new ChatMessage(ChatRole.User, stringMessage), + _ => new(ChatRole.User, $"{message}") + }; + + // Wrap with bot to ensure schema is set. + private static AdaptiveDialog WrapWithBot(AdaptiveDialog dialog) + { + BotDefinition bot + = new BotDefinition.Builder + { + Components = + { + new DialogComponent.Builder + { + SchemaName = dialog.HasSchemaName ? dialog.SchemaName : "default-schema", + Dialog = new AdaptiveDialog.Builder(dialog), + } + } + }.Build(); + + return bot.Descendants().OfType().First(); + } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs index edd3eec95d..bca9f6860b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using Microsoft.Bot.ObjectModel; using Microsoft.Extensions.AI; using Microsoft.PowerFx.Types; @@ -8,10 +10,13 @@ namespace Microsoft.Agents.Workflows.Declarative.Extensions; internal static class ChatMessageExtensions { public static RecordValue ToRecordValue(this ChatMessage message) => // %%% CPS - MESSAGETYPE - RecordValue.NewRecordFromFields( - new NamedValue(nameof(ChatMessage.MessageId), message.MessageId.ToFormulaValue()), - new NamedValue(nameof(ChatMessage.Role), FormulaValue.New(message.Role.Value)), - new NamedValue(nameof(ChatMessage.AuthorName), message.AuthorName.ToFormulaValue()), - new NamedValue(nameof(ChatMessage.Text), message.Text.ToFormulaValue())); - ////new NamedValue(nameof(ChatMessage.AdditionalProperties), message.AdditionalProperties?.ToRecordValue())); + RecordValue.NewRecordFromFields(message.GetMessageFields()); + + private static IEnumerable GetMessageFields(this ChatMessage message) + { + yield return new NamedValue(nameof(DialogAction.Id), message.MessageId.ToFormulaValue()); + yield return new NamedValue(nameof(ChatMessage.Role), FormulaValue.New(message.Role.Value)); + yield return new NamedValue(nameof(ChatMessage.AuthorName), message.AuthorName.ToFormulaValue()); + yield return new NamedValue(nameof(ChatMessage.Text), message.Text.ToFormulaValue()); + } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs index 2e7a12dd8b..6ff88f6c5e 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.PowerFx; @@ -12,26 +13,20 @@ namespace Microsoft.Agents.Workflows.Declarative.Interpreter; /// /// The root executor for a declarative workflow. /// -/// The unique identifier for the workflow. -internal sealed class DeclarativeWorkflowExecutor(string workflowId) : +internal sealed class DeclarativeWorkflowExecutor(string workflowId, AdaptiveDialog workflowElement, Func inputTransform) : ReflectingExecutor>(workflowId), IMessageHandler where TInput : notnull { public async ValueTask HandleAsync(TInput message, IWorkflowContext context) { - ChatMessage input = - message switch - { - ChatMessage chatMessage => chatMessage, - string stringMessage => new ChatMessage(ChatRole.User, stringMessage), - _ => new(ChatRole.User, $"{message}") - }; - WorkflowScopes scopes = await context.GetScopedStateAsync(default).ConfigureAwait(false); + scopes.InitializeModel(workflowElement); + + ChatMessage input = inputTransform.Invoke(message); + scopes.Set("LastMessage", VariableScopeNames.System, input.ToRecordValue()); - scopes.Set("Activity", VariableScopeNames.System, new ChatMessage(ChatRole.User, string.Empty).ToRecordValue()); await context.SetScopedStateAsync(scopes, default).ConfigureAwait(false); await context.SendMessageAsync(new ExecutionResultMessage(this.Id)).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs index c2da3aa484..2365dc8833 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs @@ -25,10 +25,10 @@ public DeclarativeWorkflowState(RecalcEngine engine, WorkflowScopes? scopes = nu public WorkflowExpressionEngine ExpressionEngine => this._expressionEngine ??= new WorkflowExpressionEngine(this._engine); - public void Clear(PropertyPath variablePath) => - this.Clear(Throw.IfNull(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName)); + public void Reset(PropertyPath variablePath) => + this.Reset(Throw.IfNull(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName)); - public void Clear(string scopeName, string? varName = null) + public void Reset(string scopeName, string? varName = null) { if (string.IsNullOrWhiteSpace(varName)) { @@ -36,7 +36,7 @@ public void Clear(string scopeName, string? varName = null) } else { - this._scopes.Remove(varName, scopeName); + this._scopes.Reset(varName, scopeName); } this._scopes.Bind(this._engine, scopeName); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Microsoft.Agents.Workflows.Declarative.csproj b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Microsoft.Agents.Workflows.Declarative.csproj index 91e72055aa..db59bcbe2c 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Microsoft.Agents.Workflows.Declarative.csproj +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Microsoft.Agents.Workflows.Declarative.csproj @@ -24,6 +24,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs index 290218cbc8..a0413337ac 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -24,7 +23,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P StringExpression userInputExpression = Throw.IfNull(this.Model.UserInput, $"{nameof(this.Model)}.{nameof(this.Model.UserInput)}"); string agentInstructions = this.State.Format(this.Model.AdditionalInstructions) ?? string.Empty; - // %%% HAXX - AGENT ID in "AdditionalInstructions" + // %%% HAXX - AGENT ID in "AdditionalInstructions" (TODO: OM) string agentId; string? additionalInstructions = null; int delimiterIndex = agentInstructions.IndexOf(','); @@ -87,7 +86,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P AgentRunResponse agentResponse = agentResponseUpdates.ToAgentRunResponse(); - ChatMessage response = agentResponse.Messages.Last(); // %%% DECISION: Is last sufficient? (probably not) + ChatMessage response = agentResponse.Messages.Last(); this.State.Set(VariableScopeNames.System, "LastMessage", response.ToRecordValue()); if (this.Model.AutoSend) { @@ -96,7 +95,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P if (conversationValue is not StringValue) { - this.AssignTarget(PropertyPath.FromSegments(VariableScopeNames.System, this.Model.AutoSend ? "ConversationId" : "InternalId"), FormulaValue.New(conversationId)); // %%% HAXX: INTERNAL THREAD + this.AssignTarget(PropertyPath.FromSegments(VariableScopeNames.System, this.Model.AutoSend ? "ConversationId" : "InternalId"), FormulaValue.New(conversationId)); // %%% HAXX: INTERNAL THREAD (TODO: OM) } PropertyPath? variablePath = this.Model.Variable?.Path; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs index 5612b43fed..b3efec7545 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs @@ -29,7 +29,7 @@ public void HandleAllGlobalVariables() public void HandleConversationHistory() { - throw new System.NotImplementedException(); // %%% DECISION: Is this to be supported ??? + // Not supported.... } public void HandleConversationScopedVariables() @@ -44,12 +44,12 @@ public void HandleUnknownValue() public void HandleUserScopedVariables() { - this.ClearAll(VariableScopeNames.Environment); // %%% DECISION: Is this correct? If not, what? + // Not supported.... } private void ClearAll(string scope) { - state.Clear(scope); + state.Reset(scope); Debug.WriteLine( $""" STATE: {this.GetType().Name} [{executorId}] diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs index 6b26e15a61..5c6580d855 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs @@ -78,10 +78,10 @@ public void TakeNext() public void Reset() { - this.State.Clear(Throw.IfNull(this.Model.Value)); + this.State.Reset(Throw.IfNull(this.Model.Value)); if (this.Model.Index is not null) { - this.State.Clear(this.Model.Index); + this.State.Reset(this.Model.Index); } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs index d5942947d8..51caec89f8 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs @@ -29,11 +29,11 @@ internal sealed class ParseValueExecutor(ParseValue model) : { parsedResult = recordValue.ToFormulaValue(); } - else if (expressionResult.Value is StringDataValue stringValue) // %%% NEEDED ??? + else if (expressionResult.Value is StringDataValue stringValue) { if (string.IsNullOrWhiteSpace(stringValue.Value)) { - parsedResult = FormulaValue.NewBlank(); + parsedResult = FormulaValue.NewBlank(expressionResult.Value.GetDataType().ToFormulaType()); } else { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs index 05dedb5b8d..bc6dfe8e57 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs @@ -17,7 +17,7 @@ internal sealed class ResetVariableExecutor(ResetVariable model) : { PropertyPath variablePath = Throw.IfNull(this.Model.Variable, $"{nameof(this.Model)}.{nameof(model.Variable)}"); - this.State.Clear(this.Model.Variable); + this.State.Reset(this.Model.Variable); Debug.WriteLine( $""" STATE: {this.GetType().Name} [{this.Id}] diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs new file mode 100644 index 0000000000..a5371aa58b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.Bot.ObjectModel.Analysis; +using Microsoft.Bot.ObjectModel.PowerFx; +using Microsoft.Bot.ObjectModel.Telemetry; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.PowerFx; + +internal static class WorkflowDiagnostics +{ + public static void InitializeModel(this WorkflowScopes scopes, AdaptiveDialog workflowElement) + { + WorkflowOperationLogger operationLogger = new(); + WorkflowFeatureConfiguration featureConfig = new(); + SemanticModel semanticModel = workflowElement.GetSemanticModel(new PowerFxExpressionChecker(featureConfig), featureConfig, operationLogger); + foreach (VariableInformationDiagnostic variableDiagnostic in semanticModel.GetVariables(workflowElement.SchemaName).Where(x => !x.IsSystemVariable).Select(v => v.ToDiagnostic())) + { + if (variableDiagnostic?.Path?.VariableName is null) + { + continue; + } + + scopes.Set(variableDiagnostic.Path.VariableName, variableDiagnostic.Path.VariableScopeName ?? VariableScopeNames.Topic, FormulaValue.NewBlank(variableDiagnostic.Type.ToFormulaType())); + } + } + + private sealed class WorkflowOperationLogger : IOperationLogger + { + public T Execute(string activity, Func function) => function.Invoke(); + + public T Execute(string activity, Func function, IEnumerable> dimensions) => function.Invoke(); + + public Task ExecuteAsync(string activity, Func> function) => function.Invoke(); + + public Task ExecuteAsync(string activity, Func> function, IEnumerable> dimensions) => function.Invoke(); + } + + private sealed class WorkflowFeatureConfiguration : IFeatureConfiguration + { + public long GetInt64Value(string settingName, long defaultValue) => defaultValue; + + public string GetStringValue(string settingName, string defaultValue) => defaultValue; + + public bool IsEnvironmentFeatureEnabled(string featureName, bool defaultValue) => true; + + public bool IsTenantFeatureEnabled(string featureName, bool defaultValue) => true; + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs index afd25568bc..633d01a915 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs @@ -83,11 +83,23 @@ public FormulaValue Get(string name, string? scopeName = null) return FormulaValue.NewBlank(); } - public void Clear(string scopeName) => this._scopes[scopeName].Clear(); + public void Clear(string scopeName) + { + foreach (KeyValuePair scopeKvp in this._scopes[scopeName]) + { + this.Set(scopeKvp.Key, scopeName, FormulaValue.NewBlank(scopeKvp.Value.Type)); + } + } - public void Remove(string name) => this.Remove(name, VariableScopeNames.Topic); + public void Reset(string name) => this.Reset(name, VariableScopeNames.Topic); - public void Remove(string name, string scopeName) => this._scopes[scopeName].Remove(name); + public void Reset(string name, string scopeName) + { + if (this._scopes[scopeName].TryGetValue(name, out FormulaValue? value)) + { + this.Set(name, scopeName, FormulaValue.NewBlank(value.Type)); + } + } public void Set(string name, FormulaValue value) => this.Set(name, VariableScopeNames.Topic, value); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs index e25c3241c9..6f9ff254de 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs @@ -176,7 +176,7 @@ public void RemoveSpecifiedScope() Assert.Equal(testValue, result); // Act - scopes.Remove("key1"); + scopes.Reset("key1"); // Assert FormulaValue resultBlank = scopes.Get("key1"); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs index b9eb93dc61..6c9e1285a1 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs @@ -103,7 +103,7 @@ public async Task Test_ConflictingWritesRaiseExceptionAsync() Assert.Equal(Value2, await manager.ReadStateAsync(sharedScope2, Key)); // Try to publish the updates - // %%% HAXX: Follow up on state with JACOB + // %%% HAXX: SUPERSTEP STATE MANAGEMENT // await Assert.ThrowsAsync(() => manager.PublishUpdatesAsync().AsTask()); } } diff --git a/workflows/HelloWorld.yaml b/workflows/HelloWorld.yaml index 15f927abc8..2fea3fac8c 100644 --- a/workflows/HelloWorld.yaml +++ b/workflows/HelloWorld.yaml @@ -5,7 +5,12 @@ beginDialog: id: workflow_demo actions: + - kind: SetVariable + id: setvar_userinput + variable: Topic.UserInput + value: =System.LastMessage.Text + # Respond with input - kind: SendActivity id: sendActivity_demo - activity: {System.LastMessage.Text} \ No newline at end of file + activity: {Topic.UserInput} \ No newline at end of file diff --git a/workflows/MathChat.yaml b/workflows/MathChat.yaml index c38c9c05bb..8540717243 100644 --- a/workflows/MathChat.yaml +++ b/workflows/MathChat.yaml @@ -10,24 +10,14 @@ beginDialog: variable: Topic.Project value: =System.LastMessage.Text - - kind: SetVariable # HAXX - id: set_count_0 - variable: Topic.TurnCount - value: 0 - - kind: AnswerQuestionWithAI id: question_student userInput: =Topic.Project additionalInstructions: asst_jCrWfBd1XrfVXMtpbfyVoIHC - # - kind: ResetVariable - # id: reset_project - # variable: Topic.Project - - - kind: SetVariable # HAXX + - kind: ResetVariable id: reset_project variable: Topic.Project - value: =Blank() - kind: AnswerQuestionWithAI id: question_teacher From dfbfabcd0c002f512e0589bc115050d68cce8fa4 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Sun, 24 Aug 2025 15:34:50 -0700 Subject: [PATCH 191/232] Update diagnostics --- .../Extensions/DataValueExtensions.cs | 2 + .../DeclarativeWorkflowExecutor.cs | 2 +- .../PowerFx/WorkflowDiagnostics.cs | 42 ++++++++++++------- workflows/DeepResearch.yaml | 20 +-------- 4 files changed, 30 insertions(+), 36 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs index 046f3d7bec..ac24e3925c 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs @@ -65,6 +65,8 @@ public static FormulaType ToFormulaType(this DataType? type) => _ => FormulaType.Unknown, }; + public static FormulaValue NewBlank(this DataType? type) => FormulaValue.NewBlank(type?.ToFormulaType() ?? FormulaType.Blank); + public static RecordValue ToRecordValue(this RecordDataValue recordDataValue) => FormulaValue.NewRecordFromFields( recordDataValue.Properties.Select( diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs index 6ff88f6c5e..2f53c107e3 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs @@ -22,7 +22,7 @@ public async ValueTask HandleAsync(TInput message, IWorkflowContext context) { WorkflowScopes scopes = await context.GetScopedStateAsync(default).ConfigureAwait(false); - scopes.InitializeModel(workflowElement); + scopes.InitializeDefaults(workflowElement); ChatMessage input = inputTransform.Invoke(message); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs index a5371aa58b..50cd4a8e51 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs @@ -1,45 +1,55 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; +using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.Bot.ObjectModel.Analysis; using Microsoft.Bot.ObjectModel.PowerFx; -using Microsoft.Bot.ObjectModel.Telemetry; using Microsoft.PowerFx.Types; namespace Microsoft.Agents.Workflows.Declarative.PowerFx; internal static class WorkflowDiagnostics { - public static void InitializeModel(this WorkflowScopes scopes, AdaptiveDialog workflowElement) + private static readonly WorkflowFeatureConfiguration s_semanticFeatureConfig = new(); + + public static void InitializeDefaults(this WorkflowScopes scopes, AdaptiveDialog workflowElement) + { + scopes.InitializeSemanticModel(workflowElement); + } + + private static void InitializeSemanticModel(this WorkflowScopes scopes, AdaptiveDialog workflowElement) { - WorkflowOperationLogger operationLogger = new(); - WorkflowFeatureConfiguration featureConfig = new(); - SemanticModel semanticModel = workflowElement.GetSemanticModel(new PowerFxExpressionChecker(featureConfig), featureConfig, operationLogger); + SemanticModel semanticModel = workflowElement.GetSemanticModel(new PowerFxExpressionChecker(s_semanticFeatureConfig), s_semanticFeatureConfig); foreach (VariableInformationDiagnostic variableDiagnostic in semanticModel.GetVariables(workflowElement.SchemaName).Where(x => !x.IsSystemVariable).Select(v => v.ToDiagnostic())) { + Console.WriteLine($":: {variableDiagnostic?.Path?.VariableName ?? "?"}"); if (variableDiagnostic?.Path?.VariableName is null) { continue; } - scopes.Set(variableDiagnostic.Path.VariableName, variableDiagnostic.Path.VariableScopeName ?? VariableScopeNames.Topic, FormulaValue.NewBlank(variableDiagnostic.Type.ToFormulaType())); - } - } + FormulaValue defaultValue = ReadValue(variableDiagnostic) ?? ReadBlank(variableDiagnostic); - private sealed class WorkflowOperationLogger : IOperationLogger - { - public T Execute(string activity, Func function) => function.Invoke(); + if (variableDiagnostic.Path.VariableScopeName?.Equals(VariableScopeNames.System, StringComparison.OrdinalIgnoreCase) ?? false) + { + // %%% TODO: Verify supported variables + } + + scopes.Set(variableDiagnostic.Path.VariableName, variableDiagnostic.Path.VariableScopeName ?? VariableScopeNames.Topic, defaultValue); + } - public T Execute(string activity, Func function, IEnumerable> dimensions) => function.Invoke(); + static FormulaValue? ReadValue(VariableInformationDiagnostic diagnostic) => diagnostic.ConstantValue?.ToFormulaValue(); - public Task ExecuteAsync(string activity, Func> function) => function.Invoke(); + //static FormulaValue? ReadValue(string variableName) + //{ + // string? variableValue = Environment.GetEnvironmentVariable(variableName); // %%% TODO: Is provided as `ConstantValue` ??? + // return string.IsNullOrEmpty(variableValue) ? null : FormulaValue.New(variableValue); + //} - public Task ExecuteAsync(string activity, Func> function, IEnumerable> dimensions) => function.Invoke(); + static FormulaValue ReadBlank(VariableInformationDiagnostic diagnostic) => diagnostic.Type.NewBlank(); } private sealed class WorkflowFeatureConfiguration : IFeatureConfiguration diff --git a/workflows/DeepResearch.yaml b/workflows/DeepResearch.yaml index 3b7b658dcd..dc45d1ac89 100644 --- a/workflows/DeepResearch.yaml +++ b/workflows/DeepResearch.yaml @@ -40,23 +40,6 @@ beginDialog: variable: Topic.SeedTask value: =Topic.InputTask - - kind: SetVariable - id: setVariable_a6JqXG - displayName: Set Task - variable: Topic.AgentResponseText - value: ="" - - - kind: SetVariable - id: setVariable_PKmRsz - variable: Topic.RestartCount - value: 0 - - - kind: SetVariable - id: setVariable_EpFEKQ - displayName: Initialize Stall Count - variable: Topic.StallCount - value: =0 - - kind: SendActivity id: sendActivity_yFsbRy activity: Analyzing facts... @@ -444,11 +427,10 @@ beginDialog: variable: Topic.AgentResponseText value: =Topic.AgentResponse.Text - - kind: SetVariable + - kind: ResetVariable id: setVariable_8eIx2A displayName: Clear seed task variable: Topic.SeedTask - value: ="" elseActions: - kind: SendActivity From 07cb3d8c2ebb956f2e6e214bffe08deed55f957b Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Sun, 24 Aug 2025 22:07:45 -0700 Subject: [PATCH 192/232] Variable initiaization --- ...ion.cs => UnsupportedVariableException.cs} | 14 ++--- .../Extensions/FormulaValueExtensions.cs | 2 + .../DeclarativeWorkflowExecutor.cs | 2 +- .../Interpreter/WorkflowActionVisitor.cs | 25 +++++--- .../AnswerQuestionWithAIExecutor.cs | 18 +++++- .../PowerFx/SystemScope.cs | 60 +++++++++++++++++++ .../PowerFx/WorkflowDiagnostics.cs | 10 +++- .../PowerFx/WorkflowScopes.cs | 6 +- .../DeclarativeWorkflowExceptionTest.cs | 6 +- workflows/HelloWorld.yaml | 12 +++- 10 files changed, 128 insertions(+), 27 deletions(-) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/{InvalidScopeException.cs => UnsupportedVariableException.cs} (50%) create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnsupportedVariableException.cs similarity index 50% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnsupportedVariableException.cs index 9e3b78088e..4e406fda91 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnsupportedVariableException.cs @@ -7,29 +7,29 @@ namespace Microsoft.Agents.Workflows.Declarative; /// /// Represents an exception that occurs when the specific scope is invalid. /// -public sealed class InvalidScopeException : DeclarativeWorkflowException +public sealed class UnsupportedVariableException : DeclarativeWorkflowException { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public InvalidScopeException() + public UnsupportedVariableException() { } /// - /// Initializes a new instance of the class with a specified error message. + /// Initializes a new instance of the class with a specified error message. /// /// The error message that explains the reason for the exception. - public InvalidScopeException(string? message) : base(message) + public UnsupportedVariableException(string? message) : base(message) { } /// - /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. /// /// The error message that explains the reason for the exception. /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. - public InvalidScopeException(string? message, Exception? innerException) : base(message, innerException) + public UnsupportedVariableException(string? message, Exception? innerException) : base(message, innerException) { } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs index a23db8a559..a888c591ed 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -86,6 +86,8 @@ public static string Format(this FormulaValue value) => _ => $"[{value.GetType().Name}]", }; + public static FormulaValue NewBlank(this FormulaType? type) => FormulaValue.NewBlank(type ?? FormulaType.Blank); + public static BooleanDataValue ToDataValue(this BooleanValue value) => BooleanDataValue.Create(value.Value); public static NumberDataValue ToDataValue(this DecimalValue value) => NumberDataValue.Create(value.Value); public static FloatDataValue ToDataValue(this NumberValue value) => FloatDataValue.Create(value.Value); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs index 2f53c107e3..33f96dc3a6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs @@ -26,7 +26,7 @@ public async ValueTask HandleAsync(TInput message, IWorkflowContext context) ChatMessage input = inputTransform.Invoke(message); - scopes.Set("LastMessage", VariableScopeNames.System, input.ToRecordValue()); + scopes.SetLastMessage(input); await context.SetScopedStateAsync(scopes, default).ConfigureAwait(false); await context.SendMessageAsync(new ExecutionResultMessage(this.Id)).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 218803a7ec..53314c5685 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -6,7 +6,6 @@ using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.ObjectModel; using Microsoft.Bot.ObjectModel; -using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Declarative.Interpreter; @@ -40,7 +39,7 @@ protected override void Visit(ActionScope item) { this.Trace(item); - string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); + string parentId = GetParentId(item); // Handle case where root element is its own parent if (item.Id.Equals(parentId)) @@ -70,7 +69,7 @@ public override void VisitConditionItem(ConditionItem item) if (conditionGroup is not null) { string stepId = ConditionGroupExecutor.Steps.Item(conditionGroup.Model, item); - string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); + string parentId = GetParentId(item); this._workflowModel.AddNode(this.CreateStep(stepId), parentId, CompletionHandler); base.VisitConditionItem(item); @@ -125,7 +124,7 @@ protected override void Visit(GotoAction item) { this.Trace(item); - string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); + string parentId = GetParentId(item); this.ContinueWith(this.CreateStep(item.Id.Value), parentId); this._workflowModel.AddLink(item.Id.Value, item.ActionId.Value); this.RestartAfter(item.Id.Value, parentId); @@ -160,7 +159,7 @@ protected override void Visit(BreakLoop item) ForeachExecutor? loopExecutor = this._workflowModel.LocateParent(item.GetParentId()); if (loopExecutor is not null) { - string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); + string parentId = GetParentId(item); this.ContinueWith(this.CreateStep(item.Id.Value), parentId); this._workflowModel.AddLink(item.Id.Value, PostId(loopExecutor.Id)); this.RestartAfter(item.Id.Value, parentId); @@ -174,7 +173,7 @@ protected override void Visit(ContinueLoop item) ForeachExecutor? loopExecutor = this._workflowModel.LocateParent(item.GetParentId()); if (loopExecutor is not null) { - string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); + string parentId = GetParentId(item); this.ContinueWith(this.CreateStep(item.Id.Value), parentId); this._workflowModel.AddLink(item.Id.Value, ForeachExecutor.Steps.Next(loopExecutor.Id)); this.RestartAfter(item.Id.Value, parentId); @@ -185,7 +184,7 @@ protected override void Visit(EndConversation item) { this.Trace(item); - string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); + string parentId = GetParentId(item); this.ContinueWith(this.CreateStep(item.Id.Value), parentId); this.RestartAfter(item.Id.Value, parentId); } @@ -456,6 +455,18 @@ private void ContinueWith( private static string PostId(string actionId) => $"{actionId}_Post"; + private static string GetParentId(BotElement item) + { + string? parentId = item.GetParentId(); + + if (parentId is null) + { + throw new UnknownActionException($"Missing parent ID for action element: {item.GetId()} [{item.GetType().Name}]."); + } + + return parentId; + } + private string ContinuationFor(string parentId) => this.ContinuationFor(parentId, parentId); private string ContinuationFor(string actionId, string parentId) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs index a0413337ac..02bcd3fe32 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs @@ -7,6 +7,7 @@ using Azure.AI.Agents.Persistent; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.Extensions.AI; @@ -53,7 +54,11 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P Instructions = additionalInstructions, }); - FormulaValue conversationValue = this.State.Get(VariableScopeNames.System, this.Model.AutoSend ? "ConversationId" : "InternalId"); + FormulaValue conversationValue = + this.Model.AutoSend ? // %%% HAXX: Internal thread until updated OM is available. + this.State.GetConversationId() : + this.State.GetInternalConversationId(); + string conversationId; if (conversationValue is StringValue stringValue) { @@ -87,7 +92,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P AgentRunResponse agentResponse = agentResponseUpdates.ToAgentRunResponse(); ChatMessage response = agentResponse.Messages.Last(); - this.State.Set(VariableScopeNames.System, "LastMessage", response.ToRecordValue()); + this.State.SetLastMessage(response); if (this.Model.AutoSend) { await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(response, agentResponse.Usage)).ConfigureAwait(false); @@ -95,7 +100,14 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P if (conversationValue is not StringValue) { - this.AssignTarget(PropertyPath.FromSegments(VariableScopeNames.System, this.Model.AutoSend ? "ConversationId" : "InternalId"), FormulaValue.New(conversationId)); // %%% HAXX: INTERNAL THREAD (TODO: OM) + if (this.Model.AutoSend) // %%% HAXX: Internal thread until updated OM is available. + { + this.State.SetConversationId(conversationId); + } + else + { + this.State.SetInternalConversationId(conversationId); + } } PropertyPath? variablePath = this.Model.Variable?.Path; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs new file mode 100644 index 0000000000..b364107361 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.Workflows.Declarative.Extensions; +using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.SystemVariables; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.Workflows.Declarative.PowerFx; + +internal static class SystemScope +{ + public static class Names + { + public const string ConversationId = nameof(SystemVariables.ConversationId); + public const string InternalId = nameof(InternalId); + public const string LastMessage = nameof(LastMessage); + public const string LastMessageId = nameof(SystemVariables.LastMessageId); + public const string LastMessageText = nameof(SystemVariables.LastMessageText); + } + + public static HashSet AllNames { get; } = [.. GetNames()]; + + public static IEnumerable GetNames() + { + yield return SystemScope.Names.ConversationId; + yield return SystemScope.Names.InternalId; + yield return SystemScope.Names.LastMessage; + yield return SystemScope.Names.LastMessageId; + yield return SystemScope.Names.LastMessageText; + } + + public static FormulaValue GetConversationId(this DeclarativeWorkflowState state) => + state.Get(VariableScopeNames.System, SystemScope.Names.ConversationId); + + public static void SetConversationId(this DeclarativeWorkflowState state, string conversationId) => + state.Set(VariableScopeNames.System, SystemScope.Names.ConversationId, FormulaValue.New(conversationId)); + + public static FormulaValue GetInternalConversationId(this DeclarativeWorkflowState state) => // %%% Workaround until updated OM is available + state.Get(VariableScopeNames.System, SystemScope.Names.InternalId); + + public static void SetInternalConversationId(this DeclarativeWorkflowState state, string conversationId) => // %%% Workaround until updated OM is available + state.Set(VariableScopeNames.System, SystemScope.Names.InternalId, FormulaValue.New(conversationId)); + + public static void SetLastMessage(this WorkflowScopes scopes, ChatMessage message) + { + scopes.Set(SystemScope.Names.LastMessage, VariableScopeNames.System, message.ToRecordValue()); + scopes.Set(SystemScope.Names.LastMessageId, VariableScopeNames.System, message.MessageId is null ? FormulaValue.NewBlank(FormulaType.String) : FormulaValue.New(message.MessageId)); + scopes.Set(SystemScope.Names.LastMessageText, VariableScopeNames.System, FormulaValue.New(message.Text)); + } + + public static void SetLastMessage(this DeclarativeWorkflowState state, ChatMessage message) + { + state.Set(VariableScopeNames.System, SystemScope.Names.LastMessage, message.ToRecordValue()); + state.Set(VariableScopeNames.System, SystemScope.Names.LastMessageId, message.MessageId is null ? FormulaValue.NewBlank(FormulaType.String) : FormulaValue.New(message.MessageId)); + state.Set(VariableScopeNames.System, SystemScope.Names.LastMessageText, FormulaValue.New(message.Text)); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs index 50cd4a8e51..3f251aea23 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs @@ -17,6 +17,10 @@ internal static class WorkflowDiagnostics public static void InitializeDefaults(this WorkflowScopes scopes, AdaptiveDialog workflowElement) { + foreach (string systemVariableName in SystemScope.GetNames()) // %%% HAXX - Shouldn't be needed + { + scopes.Set(systemVariableName, VariableScopeNames.System, FormulaValue.NewBlank()); + } scopes.InitializeSemanticModel(workflowElement); } @@ -25,7 +29,6 @@ private static void InitializeSemanticModel(this WorkflowScopes scopes, Adaptive SemanticModel semanticModel = workflowElement.GetSemanticModel(new PowerFxExpressionChecker(s_semanticFeatureConfig), s_semanticFeatureConfig); foreach (VariableInformationDiagnostic variableDiagnostic in semanticModel.GetVariables(workflowElement.SchemaName).Where(x => !x.IsSystemVariable).Select(v => v.ToDiagnostic())) { - Console.WriteLine($":: {variableDiagnostic?.Path?.VariableName ?? "?"}"); if (variableDiagnostic?.Path?.VariableName is null) { continue; @@ -35,7 +38,10 @@ private static void InitializeSemanticModel(this WorkflowScopes scopes, Adaptive if (variableDiagnostic.Path.VariableScopeName?.Equals(VariableScopeNames.System, StringComparison.OrdinalIgnoreCase) ?? false) { - // %%% TODO: Verify supported variables + if (!SystemScope.AllNames.Contains(variableDiagnostic.Path.VariableName)) + { + throw new UnsupportedVariableException($"Variable '{variableDiagnostic.Path.VariableName}' is not a supported system variable."); + } } scopes.Set(variableDiagnostic.Path.VariableName, variableDiagnostic.Path.VariableScopeName ?? VariableScopeNames.Topic, defaultValue); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs index 633d01a915..b3dfb7f7fa 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx; using Microsoft.PowerFx.Types; @@ -85,9 +86,10 @@ public FormulaValue Get(string name, string? scopeName = null) public void Clear(string scopeName) { - foreach (KeyValuePair scopeKvp in this._scopes[scopeName]) + foreach (string variableName in this._scopes[scopeName].Keys.ToArray()) { - this.Set(scopeKvp.Key, scopeName, FormulaValue.NewBlank(scopeKvp.Value.Type)); + FormulaType variableType = this._scopes[scopeName][variableName].Type; + this.Set(variableName, scopeName, variableType.NewBlank()); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowExceptionTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowExceptionTest.cs index 6442e60923..d60f4f8280 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowExceptionTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowExceptionTest.cs @@ -13,9 +13,9 @@ public sealed class DeclarativeWorkflowExceptionTest(ITestOutputHelper output) : [Fact] public void InvalidScopeException() { - AssertDefault(() => throw new InvalidScopeException()); - AssertMessage((message) => throw new InvalidScopeException(message)); - AssertInner((message, inner) => throw new InvalidScopeException(message, inner)); + AssertDefault(() => throw new UnsupportedVariableException()); + AssertMessage((message) => throw new UnsupportedVariableException(message)); + AssertInner((message, inner) => throw new UnsupportedVariableException(message, inner)); } [Fact] diff --git a/workflows/HelloWorld.yaml b/workflows/HelloWorld.yaml index 2fea3fac8c..47d308a6ee 100644 --- a/workflows/HelloWorld.yaml +++ b/workflows/HelloWorld.yaml @@ -1,4 +1,5 @@ kind: AdaptiveDialog + beginDialog: kind: OnActivity @@ -8,9 +9,16 @@ beginDialog: - kind: SetVariable id: setvar_userinput variable: Topic.UserInput - value: =System.LastMessage.Text + value: =System.LastMessage + + - kind: SetVariable + id: setvar_username + variable: Global.UserName + value: =Env.USERNAME # Respond with input - kind: SendActivity id: sendActivity_demo - activity: {Topic.UserInput} \ No newline at end of file + activity: |- + Hello {Global.UserName), + You said, "{Topic.UserInput}" \ No newline at end of file From 05ee39e72787ad24421872c8378a95dbc71b3beb Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Sun, 24 Aug 2025 22:08:27 -0700 Subject: [PATCH 193/232] Rollback --- workflows/HelloWorld.yaml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/workflows/HelloWorld.yaml b/workflows/HelloWorld.yaml index 47d308a6ee..586ac3fde8 100644 --- a/workflows/HelloWorld.yaml +++ b/workflows/HelloWorld.yaml @@ -11,14 +11,7 @@ beginDialog: variable: Topic.UserInput value: =System.LastMessage - - kind: SetVariable - id: setvar_username - variable: Global.UserName - value: =Env.USERNAME - # Respond with input - kind: SendActivity id: sendActivity_demo - activity: |- - Hello {Global.UserName), - You said, "{Topic.UserInput}" \ No newline at end of file + activity: {Topic.UserInput} \ No newline at end of file From e4996642eddf070dd79ca1cf9a2d8ca8ef28172a Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Sun, 24 Aug 2025 22:20:25 -0700 Subject: [PATCH 194/232] Tune research summary --- workflows/DeepResearch.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/workflows/DeepResearch.yaml b/workflows/DeepResearch.yaml index dc45d1ac89..f88ccedf3b 100644 --- a/workflows/DeepResearch.yaml +++ b/workflows/DeepResearch.yaml @@ -237,7 +237,8 @@ beginDialog: additionalInstructions: |- asst_o3BQkfUzEGXMNzVY5X54yHhq, We have completed the task. - Based only on the results gathered, provide a detailed conclusion that addresses the user task. + Based only on the conversation and without adding any new information, synthesize the result of the conversation as a complete response to the user task. + The user will only every see this last response and not the entire conversation, so please ensure it is complete and self-contained. - kind: EndConversation id: end_SVoNSV From 8c7db35915e0e6d2d10ebd31142a6344cffb3ed6 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 25 Aug 2025 12:13:16 -0700 Subject: [PATCH 195/232] State management --- .../Extensions/ChatMessageExtensions.cs | 2 +- .../Extensions/DataValueExtensions.cs | 22 +---- .../Extensions/FormulaValueExtensions.cs | 13 ++- .../DeclarativeWorkflowExecutor.cs | 5 +- .../AnswerQuestionWithAIExecutor.cs | 2 +- .../PowerFx/SystemScope.cs | 92 +++++++++++++++---- .../PowerFx/WorkflowDiagnostics.cs | 38 ++++---- .../Execution/StateManager.cs | 2 + .../Execution/StateScope.cs | 13 +-- .../StateSmokeTest.cs | 3 +- workflows/HelloWorld.yaml | 13 ++- 11 files changed, 131 insertions(+), 74 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs index bca9f6860b..8633f5ec51 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.Workflows.Declarative.Extensions; internal static class ChatMessageExtensions { - public static RecordValue ToRecordValue(this ChatMessage message) => // %%% CPS - MESSAGETYPE + public static RecordValue ToRecord(this ChatMessage message) => // %%% CPS - MESSAGETYPE RecordValue.NewRecordFromFields(message.GetMessageFields()); private static IEnumerable GetMessageFields(this ChatMessage message) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs index ac24e3925c..73dd31115d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs @@ -30,23 +30,7 @@ public static FormulaValue ToFormulaValue(this DataValue? value) => _ => FormulaValue.NewError(new Microsoft.PowerFx.ExpressionError { Message = $"Unknown literal type: {value.GetType().Name}" }), }; - public static FormulaType ToFormulaType(this DataValue? value) => - value switch - { - null => FormulaType.Blank, - BlankDataValue => FormulaType.Blank, - BooleanDataValue => FormulaType.Boolean, - NumberDataValue numberValue => FormulaType.Number, - FloatDataValue floatValue => FormulaType.Decimal, - StringDataValue stringValue => FormulaType.String, - DateTimeDataValue dateTimeValue => FormulaType.DateTime, - DateDataValue dateValue => FormulaType.Date, - TimeDataValue timeValue => FormulaType.Time, - TableDataValue tableValue => tableValue.Values.FirstOrDefault()?.ParseRecordType() ?? RecordType.Empty(), - RecordDataValue recordValue => recordValue.ParseRecordType(), - OptionDataValue optionValue => FormulaType.String, - _ => FormulaType.Unknown, - }; + public static FormulaType ToFormulaType(this DataValue? value) => value?.GetDataType().ToFormulaType() ?? FormulaType.Blank; public static FormulaType ToFormulaType(this DataType? type) => type switch @@ -59,9 +43,13 @@ public static FormulaType ToFormulaType(this DataType? type) => DateTimeDataType => FormulaType.DateTime, DateDataType => FormulaType.Date, TimeDataType => FormulaType.Time, + ColorDataType => FormulaType.Color, + GuidDataType => FormulaType.Guid, + FileDataType => FormulaType.Blob, RecordDataType => RecordType.Empty(), TableDataType => TableType.Empty(), OptionSetDataType => FormulaType.String, + AnyType => FormulaType.UntypedObject, _ => FormulaType.Unknown, }; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs index a888c591ed..ac41435ed5 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -45,8 +45,12 @@ public static DataType GetDataType(this FormulaValue value) => TimeValue => DataType.Time, StringValue => DataType.String, BlankValue => DataType.Blank, + ColorValue => DataType.Color, + GuidValue => DataType.Guid, + BlobValue => DataType.File, RecordValue recordValue => recordValue.Type.ToDataType(), TableValue tableValue => tableValue.Type.ToDataType(), + UntypedObjectValue => DataType.Any, _ => DataType.Unspecified, }; @@ -62,8 +66,12 @@ public static DataType GetDataType(this FormulaType type) => TimeType => DataType.Time, StringType => DataType.String, BlankType => DataType.Blank, + ColorType => DataType.Color, + GuidType => DataType.Guid, + BlobType => DataType.File, RecordType recordType => recordType.ToDataType(), TableType tableType => tableType.ToDataType(), + UntypedObjectType => DataType.Any, _ => DataType.Unspecified, }; @@ -77,9 +85,10 @@ public static string Format(this FormulaValue value) => DateTimeValue datetimeValue => $"{datetimeValue.GetConvertedValue(TimeZoneInfo.Utc)}", TimeValue timeValue => $"{timeValue.Value}", StringValue stringValue => stringValue.Value, - GuidValue guidValue => $"{guidValue.Value}", BlankValue blankValue => string.Empty, VoidValue voidValue => string.Empty, + ColorValue colorValue => colorValue.Value.ToString(), + GuidValue guidValue => guidValue.Value.ToString("N"), TableValue tableValue => tableValue.ToJson().ToJsonString(s_options), RecordValue recordValue => recordValue.ToJson().ToJsonString(s_options), ErrorValue errorValue => $"Error:{Environment.NewLine}{string.Join(Environment.NewLine, errorValue.Errors.Select(error => $"{error.MessageKey}: {error.Message}"))}", @@ -140,8 +149,6 @@ public static JsonNode ToJson(this FormulaValue value) => RecordValue recordValue => recordValue.ToJson(), TableValue tableValue => tableValue.ToJson(), BlankValue blankValue => JsonValue.Create(string.Empty), - //VoidValue voidValue => JsonValue.Create(), - //ErrorValue errorValue => $"Error:{Environment.NewLine}{string.Join(Environment.NewLine, errorValue.Errors.Select(error => $"{error.MessageKey}: {error.Message}"))}", _ => $"[{value.GetType().Name}]", }; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs index 33f96dc3a6..e442b63479 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs @@ -22,11 +22,8 @@ public async ValueTask HandleAsync(TInput message, IWorkflowContext context) { WorkflowScopes scopes = await context.GetScopedStateAsync(default).ConfigureAwait(false); - scopes.InitializeDefaults(workflowElement); - ChatMessage input = inputTransform.Invoke(message); - - scopes.SetLastMessage(input); + scopes.Initialize(workflowElement, input); await context.SetScopedStateAsync(scopes, default).ConfigureAwait(false); await context.SendMessageAsync(new ExecutionResultMessage(this.Id)).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs index 02bcd3fe32..476f5bd2f4 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs @@ -113,7 +113,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P PropertyPath? variablePath = this.Model.Variable?.Path; if (variablePath is not null) { - this.AssignTarget(variablePath, response.ToRecordValue()); + this.AssignTarget(variablePath, response.ToRecord()); } return default; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs index b364107361..f875e1861e 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Bot.ObjectModel; @@ -14,47 +17,98 @@ internal static class SystemScope { public static class Names { + public const string Activity = nameof(Activity); + public const string Bot = nameof(Bot); + public const string Conversation = nameof(Conversation); public const string ConversationId = nameof(SystemVariables.ConversationId); public const string InternalId = nameof(InternalId); public const string LastMessage = nameof(LastMessage); public const string LastMessageId = nameof(SystemVariables.LastMessageId); public const string LastMessageText = nameof(SystemVariables.LastMessageText); + public const string Recognizer = nameof(Recognizer); + public const string User = nameof(User); + public const string UserLanguage = nameof(UserLanguage); } - public static HashSet AllNames { get; } = [.. GetNames()]; + public static ImmutableHashSet AllNames { get; } = GetNames().ToImmutableHashSet(); public static IEnumerable GetNames() { - yield return SystemScope.Names.ConversationId; - yield return SystemScope.Names.InternalId; - yield return SystemScope.Names.LastMessage; - yield return SystemScope.Names.LastMessageId; - yield return SystemScope.Names.LastMessageText; + yield return Names.Activity; + yield return Names.Bot; + yield return Names.Conversation; + yield return Names.ConversationId; + yield return Names.InternalId; + yield return Names.LastMessage; + yield return Names.LastMessageId; + yield return Names.LastMessageText; + yield return Names.Recognizer; + yield return Names.User; + yield return Names.UserLanguage; + } + + public static void InitializeSystem(this WorkflowScopes scopes, ChatMessage inputMessage) + { + scopes.Set(Names.Activity, VariableScopeNames.System, RecordValue.Empty()); + + scopes.Set(Names.LastMessage, VariableScopeNames.System, inputMessage.ToRecord()); + scopes.Set(Names.LastMessageId, VariableScopeNames.System, FormulaType.String.NewBlank()); + scopes.Set(Names.LastMessageText, VariableScopeNames.System, FormulaType.String.NewBlank()); + + scopes.Set( + Names.Conversation, + VariableScopeNames.System, + RecordValue.NewRecordFromFields( + new NamedValue("Id", FormulaType.String.NewBlank()), + new NamedValue("LocalTimeZone", FormulaValue.New(TimeZoneInfo.Local.StandardName)), + new NamedValue("LocalTimeZoneOffset", FormulaValue.New(TimeZoneInfo.Local.GetUtcOffset(DateTime.UtcNow))), + new NamedValue("InTestMode", FormulaValue.New(false)))); + scopes.Set(Names.ConversationId, VariableScopeNames.System, FormulaType.String.NewBlank()); + scopes.Set(Names.InternalId, VariableScopeNames.System, FormulaType.String.NewBlank()); + + scopes.Set( + Names.Recognizer, + VariableScopeNames.System, + RecordValue.NewRecordFromFields( + new NamedValue("Id", FormulaType.String.NewBlank()), + new NamedValue("Text", FormulaType.String.NewBlank()))); + + scopes.Set( + Names.User, + VariableScopeNames.System, + RecordValue.NewRecordFromFields( + new NamedValue("Language", StringValue.New(CultureInfo.CurrentCulture.TwoLetterISOLanguageName)))); + scopes.Set(Names.UserLanguage, VariableScopeNames.System, StringValue.New(CultureInfo.CurrentCulture.TwoLetterISOLanguageName)); } public static FormulaValue GetConversationId(this DeclarativeWorkflowState state) => - state.Get(VariableScopeNames.System, SystemScope.Names.ConversationId); + state.Get(VariableScopeNames.System, Names.ConversationId); - public static void SetConversationId(this DeclarativeWorkflowState state, string conversationId) => - state.Set(VariableScopeNames.System, SystemScope.Names.ConversationId, FormulaValue.New(conversationId)); + public static void SetConversationId(this DeclarativeWorkflowState state, string conversationId) + { + RecordValue conversation = (RecordValue)state.Get(VariableScopeNames.System, Names.Conversation); + conversation.UpdateField("Id", FormulaValue.New(conversationId)); + state.Set(VariableScopeNames.System, Names.Conversation, conversation); + state.Set(VariableScopeNames.System, Names.ConversationId, FormulaValue.New(conversationId)); + } - public static FormulaValue GetInternalConversationId(this DeclarativeWorkflowState state) => // %%% Workaround until updated OM is available - state.Get(VariableScopeNames.System, SystemScope.Names.InternalId); + public static FormulaValue GetInternalConversationId(this DeclarativeWorkflowState state) => + state.Get(VariableScopeNames.System, Names.InternalId); - public static void SetInternalConversationId(this DeclarativeWorkflowState state, string conversationId) => // %%% Workaround until updated OM is available - state.Set(VariableScopeNames.System, SystemScope.Names.InternalId, FormulaValue.New(conversationId)); + public static void SetInternalConversationId(this DeclarativeWorkflowState state, string conversationId) => + state.Set(VariableScopeNames.System, Names.InternalId, FormulaValue.New(conversationId)); public static void SetLastMessage(this WorkflowScopes scopes, ChatMessage message) { - scopes.Set(SystemScope.Names.LastMessage, VariableScopeNames.System, message.ToRecordValue()); - scopes.Set(SystemScope.Names.LastMessageId, VariableScopeNames.System, message.MessageId is null ? FormulaValue.NewBlank(FormulaType.String) : FormulaValue.New(message.MessageId)); - scopes.Set(SystemScope.Names.LastMessageText, VariableScopeNames.System, FormulaValue.New(message.Text)); + scopes.Set(Names.LastMessage, VariableScopeNames.System, message.ToRecord()); + scopes.Set(Names.LastMessageId, VariableScopeNames.System, message.MessageId is null ? FormulaValue.NewBlank(FormulaType.String) : FormulaValue.New(message.MessageId)); + scopes.Set(Names.LastMessageText, VariableScopeNames.System, FormulaValue.New(message.Text)); } public static void SetLastMessage(this DeclarativeWorkflowState state, ChatMessage message) { - state.Set(VariableScopeNames.System, SystemScope.Names.LastMessage, message.ToRecordValue()); - state.Set(VariableScopeNames.System, SystemScope.Names.LastMessageId, message.MessageId is null ? FormulaValue.NewBlank(FormulaType.String) : FormulaValue.New(message.MessageId)); - state.Set(VariableScopeNames.System, SystemScope.Names.LastMessageText, FormulaValue.New(message.Text)); + state.Set(VariableScopeNames.System, Names.LastMessage, message.ToRecord()); + state.Set(VariableScopeNames.System, Names.LastMessageId, message.MessageId is null ? FormulaValue.NewBlank(FormulaType.String) : FormulaValue.New(message.MessageId)); + state.Set(VariableScopeNames.System, Names.LastMessageText, FormulaValue.New(message.Text)); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs index 3f251aea23..361ad84499 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs @@ -7,6 +7,7 @@ using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.Bot.ObjectModel.Analysis; using Microsoft.Bot.ObjectModel.PowerFx; +using Microsoft.Extensions.AI; using Microsoft.PowerFx.Types; namespace Microsoft.Agents.Workflows.Declarative.PowerFx; @@ -15,26 +16,35 @@ internal static class WorkflowDiagnostics { private static readonly WorkflowFeatureConfiguration s_semanticFeatureConfig = new(); - public static void InitializeDefaults(this WorkflowScopes scopes, AdaptiveDialog workflowElement) + public static void Initialize(this WorkflowScopes scopes, AdaptiveDialog workflowElement, ChatMessage inputMessage) { - foreach (string systemVariableName in SystemScope.GetNames()) // %%% HAXX - Shouldn't be needed + scopes.InitializeSystem(inputMessage); + + SemanticModel semanticModel = workflowElement.GetSemanticModel(new PowerFxExpressionChecker(s_semanticFeatureConfig), s_semanticFeatureConfig); + scopes.InitializeEnvironment(semanticModel); + scopes.InitializeDefaults(semanticModel, workflowElement.SchemaName.Value); + } + + private static void InitializeEnvironment(this WorkflowScopes scopes, SemanticModel semanticModel) + { + foreach (string variableName in semanticModel.GetAllEnvironmentVariablesReferencedInTheBot()) { - scopes.Set(systemVariableName, VariableScopeNames.System, FormulaValue.NewBlank()); + string? environmentValue = Environment.GetEnvironmentVariable(variableName); + FormulaValue variableValue = string.IsNullOrEmpty(environmentValue) ? FormulaType.String.NewBlank() : FormulaValue.New(environmentValue); + scopes.Set(variableName, VariableScopeNames.Environment, variableValue); } - scopes.InitializeSemanticModel(workflowElement); } - private static void InitializeSemanticModel(this WorkflowScopes scopes, AdaptiveDialog workflowElement) + private static void InitializeDefaults(this WorkflowScopes scopes, SemanticModel semanticModel, string schemaName) { - SemanticModel semanticModel = workflowElement.GetSemanticModel(new PowerFxExpressionChecker(s_semanticFeatureConfig), s_semanticFeatureConfig); - foreach (VariableInformationDiagnostic variableDiagnostic in semanticModel.GetVariables(workflowElement.SchemaName).Where(x => !x.IsSystemVariable).Select(v => v.ToDiagnostic())) + foreach (VariableInformationDiagnostic variableDiagnostic in semanticModel.GetVariables(schemaName).Where(x => !x.IsSystemVariable).Select(v => v.ToDiagnostic())) { - if (variableDiagnostic?.Path?.VariableName is null) + if (variableDiagnostic is null || variableDiagnostic?.Path?.VariableName is null) { continue; } - FormulaValue defaultValue = ReadValue(variableDiagnostic) ?? ReadBlank(variableDiagnostic); + FormulaValue defaultValue = variableDiagnostic.ConstantValue?.ToFormulaValue() ?? variableDiagnostic.Type.NewBlank(); if (variableDiagnostic.Path.VariableScopeName?.Equals(VariableScopeNames.System, StringComparison.OrdinalIgnoreCase) ?? false) { @@ -46,16 +56,6 @@ private static void InitializeSemanticModel(this WorkflowScopes scopes, Adaptive scopes.Set(variableDiagnostic.Path.VariableName, variableDiagnostic.Path.VariableScopeName ?? VariableScopeNames.Topic, defaultValue); } - - static FormulaValue? ReadValue(VariableInformationDiagnostic diagnostic) => diagnostic.ConstantValue?.ToFormulaValue(); - - //static FormulaValue? ReadValue(string variableName) - //{ - // string? variableValue = Environment.GetEnvironmentVariable(variableName); // %%% TODO: Is provided as `ConstantValue` ??? - // return string.IsNullOrEmpty(variableValue) ? null : FormulaValue.New(variableValue); - //} - - static FormulaValue ReadBlank(VariableInformationDiagnostic diagnostic) => diagnostic.Type.NewBlank(); } private sealed class WorkflowFeatureConfiguration : IFeatureConfiguration diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/StateManager.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StateManager.cs index b563221757..456389fcfc 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/StateManager.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/StateManager.cs @@ -94,5 +94,7 @@ public async ValueTask PublishUpdatesAsync() StateScope stateScope = this.GetOrCreateScope(scope); await stateScope.WriteStateAsync(updatesByScope[scope]).ConfigureAwait(false); } + + this._queuedUpdates.Clear(); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/StateScope.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StateScope.cs index c64e1e1978..3de8262196 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/StateScope.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/StateScope.cs @@ -39,17 +39,18 @@ public ValueTask WriteStateAsync(Dictionary> updates) foreach (string key in updates.Keys) { - if (updates[key].Count == 0) + var scopedUpdate = updates[key]; + if (scopedUpdate.Count == 0) { continue; } - //if (updates[key].Count > 1) // %%% HAXX: SUPERSTEP STATE MANAGEMENT - //{ - // throw new InvalidOperationException($"Expected exactly one update for key '{key}'."); - //} + if (scopedUpdate.Count > 1) + { + throw new InvalidOperationException($"Expected exactly one update for key '{key}'."); + } - StateUpdate update = updates[key].Last(); + StateUpdate update = scopedUpdate[0]; if (update.IsDelete) { this._stateData.Remove(key); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs index 6c9e1285a1..24ed29d1ad 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StateSmokeTest.cs @@ -103,7 +103,6 @@ public async Task Test_ConflictingWritesRaiseExceptionAsync() Assert.Equal(Value2, await manager.ReadStateAsync(sharedScope2, Key)); // Try to publish the updates - // %%% HAXX: SUPERSTEP STATE MANAGEMENT - // await Assert.ThrowsAsync(() => manager.PublishUpdatesAsync().AsTask()); + await Assert.ThrowsAsync(() => manager.PublishUpdatesAsync().AsTask()); } } diff --git a/workflows/HelloWorld.yaml b/workflows/HelloWorld.yaml index 586ac3fde8..fe165e21b6 100644 --- a/workflows/HelloWorld.yaml +++ b/workflows/HelloWorld.yaml @@ -6,12 +6,21 @@ beginDialog: id: workflow_demo actions: + # Capture input - kind: SetVariable id: setvar_userinput variable: Topic.UserInput - value: =System.LastMessage + value: =System.LastMessage.Text + + # Capture environment variable + - kind: SetVariable + id: setvar_username + variable: Global.UserName + value: =Env.USERNAME # Respond with input - kind: SendActivity id: sendActivity_demo - activity: {Topic.UserInput} \ No newline at end of file + activity: |- + Hello {Global.UserName}, + You said, "{Topic.UserInput}" \ No newline at end of file From 7d210f33bd93143dfff5c99540bf991b522e4c20 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 25 Aug 2025 12:19:40 -0700 Subject: [PATCH 196/232] Fix merge --- dotnet/agent-framework-dotnet.slnx | 1 + 1 file changed, 1 insertion(+) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 0dd2377c3a..39a63b0958 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -25,6 +25,7 @@ + From e524859523290cab9bf438cafaff17e3f35228aa Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 25 Aug 2025 12:32:14 -0700 Subject: [PATCH 197/232] Fix merge - demo --- dotnet/demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj | 2 ++ dotnet/demos/DeclarativeWorkflow/Program.cs | 6 +++--- dotnet/demos/QueryWorkflow/Program.cs | 3 ++- dotnet/demos/QueryWorkflow/QueryWorkflow.csproj | 2 ++ 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/dotnet/demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj b/dotnet/demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj index 5401dfac57..ee1a58754c 100644 --- a/dotnet/demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj +++ b/dotnet/demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj @@ -5,6 +5,8 @@ net9.0 net9.0 $(ProjectsDebugTargetFrameworks) + enable + disable 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 $(NoWarn);CA1812 diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index ae3518b0e1..a36eb54102 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -13,7 +13,6 @@ using Microsoft.Agents.Workflows.Declarative; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; -using Microsoft.Shared.Diagnostics; namespace Demo.DeclarativeWorkflow; @@ -36,7 +35,8 @@ public static async Task Main(string[] args) // Load configuration and create kernel with Azure OpenAI Chat Completion service IConfiguration config = InitializeConfig(); - PersistentAgentsClient client = new(Throw.IfNull(config["AzureAI:Endpoint"]), new AzureCliCredential()); + string foundryProjectEndpoint = config["AzureAI:Endpoint"] ?? throw new InvalidOperationException("Undefined configuration: AzureAI:Endpoint"); + PersistentAgentsClient client = new(foundryProjectEndpoint, new AzureCliCredential()); // Read and parse the declarative workflow. Notify($"WORKFLOW: Parsing {Path.GetFullPath(workflowFile)}"); @@ -46,7 +46,7 @@ public static async Task Main(string[] args) // DeclarativeWorkflowContext provides the components for workflow execution. DeclarativeWorkflowOptions workflowContext = - new(projectEndpoint: Throw.IfNull(config["AzureAI:Endpoint"])) + new(foundryProjectEndpoint) { ProjectCredentials = new AzureCliCredential(), }; diff --git a/dotnet/demos/QueryWorkflow/Program.cs b/dotnet/demos/QueryWorkflow/Program.cs index e048d6df6d..398e95e6f5 100644 --- a/dotnet/demos/QueryWorkflow/Program.cs +++ b/dotnet/demos/QueryWorkflow/Program.cs @@ -21,7 +21,8 @@ public static async Task Main(string[] args) // Load configuration and create kernel with Azure OpenAI Chat Completion service IConfiguration config = InitializeConfig(); Dictionary nameCache = []; - PersistentAgentsClient client = new(Throw.IfNull(config["AzureAI:Endpoint"]), new AzureCliCredential()); + string foundryProjectEndpoint = config["AzureAI:Endpoint"] ?? throw new InvalidOperationException("Undefined configuration: AzureAI:Endpoint"); + PersistentAgentsClient client = new(foundryProjectEndpoint, new AzureCliCredential()); await foreach (PersistentThreadMessage message in client.Messages.GetMessagesAsync(workflowId, order: ListSortOrder.Ascending)) { diff --git a/dotnet/demos/QueryWorkflow/QueryWorkflow.csproj b/dotnet/demos/QueryWorkflow/QueryWorkflow.csproj index eb73b2fbff..c563580403 100644 --- a/dotnet/demos/QueryWorkflow/QueryWorkflow.csproj +++ b/dotnet/demos/QueryWorkflow/QueryWorkflow.csproj @@ -5,6 +5,8 @@ net9.0 net9.0 $(ProjectsDebugTargetFrameworks) + enable + disable 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 From c6d955ca302b79fcf27100627bbaafcc261908ce Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 25 Aug 2025 12:45:00 -0700 Subject: [PATCH 198/232] Add readme --- dotnet/agent-framework-dotnet.slnx | 1 + workflows/README.md | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 workflows/README.md diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 39a63b0958..b6506df4ef 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -25,6 +25,7 @@ + diff --git a/workflows/README.md b/workflows/README.md new file mode 100644 index 0000000000..9522c4ae24 --- /dev/null +++ b/workflows/README.md @@ -0,0 +1,18 @@ +# No-Code Workflows + +This folder contains sample workflow definitions than be ran using the +[Declarative Workflow](./dotnet/demos/DeclarativeWorkflow) demo. + +Each workflow is defined in a single YAML file and contains +comments with additional information specific to that workflow. + +A _Declarative Workflow_ may be executed locally no different from any `Workflow` defined by code. +The difference is that the workflow definition is loaded from a YAML file instead of being defined in code. + +```c# +Workflow workflow = DeclarativeWorkflowBuilder.Build("HelloWorld.yaml", options); +``` + +Workflows may also be hosted in your _Azure Foundry Project_. + +> _Python_ support in the works! \ No newline at end of file From d266a1007c1e6bf022bf904d9dc2e5cb544589d4 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 25 Aug 2025 12:48:48 -0700 Subject: [PATCH 199/232] Overload for workflow builder --- dotnet/demos/DeclarativeWorkflow/Program.cs | 5 ++--- .../DeclarativeWorkflowBuilder.cs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index a36eb54102..8df92b7a55 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -42,17 +42,16 @@ public static async Task Main(string[] args) Notify($"WORKFLOW: Parsing {Path.GetFullPath(workflowFile)}"); Stopwatch timer = Stopwatch.StartNew(); - using StreamReader yamlReader = File.OpenText(workflowFile); // DeclarativeWorkflowContext provides the components for workflow execution. - DeclarativeWorkflowOptions workflowContext = + DeclarativeWorkflowOptions options = new(foundryProjectEndpoint) { ProjectCredentials = new AzureCliCredential(), }; // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. - Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); + Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowFile, options); Notify($"\nWORKFLOW: Defined {timer.Elapsed}"); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index d3271c7d01..fad55f5c7b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -18,6 +18,24 @@ public static class DeclarativeWorkflowBuilder /// /// Builds a process from the provided YAML definition of a CPS Topic ObjectModel. /// + /// The type of the input message + /// The path to the workflow. + /// The execution context for the workflow. + /// An optional function to transform the input message into a . + /// + public static Workflow Build( + string workflowFile, + DeclarativeWorkflowOptions options, + Func? inputTransform = null) + where TInput : notnull + { + using StreamReader yamlReader = File.OpenText(workflowFile); + return Build(yamlReader, options, inputTransform); + } + /// + /// Builds a process from the provided YAML definition of a CPS Topic ObjectModel. + /// + /// The type of the input message /// The reader that provides the workflow object model YAML. /// The execution context for the workflow. /// An optional function to transform the input message into a . From f0795939ded0e435e896b93c5ff21686947dadad Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 25 Aug 2025 14:15:41 -0700 Subject: [PATCH 200/232] Fault tolerance - scope equivalency --- .../DeclarativeWorkflowBuilder.cs | 3 ++- .../Extensions/ChatMessageExtensions.cs | 3 ++- .../ObjectModel/AnswerQuestionWithAIExecutor.cs | 6 +++--- .../ObjectModel/ClearAllVariablesExecutor.cs | 3 ++- .../PowerFx/WorkflowDiagnostics.cs | 2 +- .../PowerFx/WorkflowScopes.cs | 9 ++++++--- 6 files changed, 16 insertions(+), 10 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index fad55f5c7b..385593e3bc 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -48,7 +48,8 @@ public static Workflow Build( { BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new UnknownActionException("Unable to parse workflow."); - if (rootElement is not AdaptiveDialog workflowElement) // %%% CPS - WORKFLOW TYPE + // ISSUE #486 - Use "Workflow" element for Foundry specific. + if (rootElement is not AdaptiveDialog workflowElement) { throw new UnknownActionException($"Unsupported root element: {rootElement.GetType().Name}. Expected an {nameof(AdaptiveDialog)}."); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs index 8633f5ec51..89a65c86b2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/ChatMessageExtensions.cs @@ -9,7 +9,8 @@ namespace Microsoft.Agents.Workflows.Declarative.Extensions; internal static class ChatMessageExtensions { - public static RecordValue ToRecord(this ChatMessage message) => // %%% CPS - MESSAGETYPE + // ISSUE #485 - Align with message type updated OM is available. + public static RecordValue ToRecord(this ChatMessage message) => RecordValue.NewRecordFromFields(message.GetMessageFields()); private static IEnumerable GetMessageFields(this ChatMessage message) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs index 476f5bd2f4..13626376c9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs @@ -24,7 +24,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P StringExpression userInputExpression = Throw.IfNull(this.Model.UserInput, $"{nameof(this.Model)}.{nameof(this.Model.UserInput)}"); string agentInstructions = this.State.Format(this.Model.AdditionalInstructions) ?? string.Empty; - // %%% HAXX - AGENT ID in "AdditionalInstructions" (TODO: OM) + // ISSUE #485 - Agent identifier embedded in instructions until updated OM is available. string agentId; string? additionalInstructions = null; int delimiterIndex = agentInstructions.IndexOf(','); @@ -55,7 +55,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P }); FormulaValue conversationValue = - this.Model.AutoSend ? // %%% HAXX: Internal thread until updated OM is available. + this.Model.AutoSend ? // ISSUE #485: Conversation implicitly managed until updated OM is available. this.State.GetConversationId() : this.State.GetInternalConversationId(); @@ -100,7 +100,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P if (conversationValue is not StringValue) { - if (this.Model.AutoSend) // %%% HAXX: Internal thread until updated OM is available. + if (this.Model.AutoSend) // ISSUE #485: Conversation implicitly managed until updated OM is available. { this.State.SetConversationId(conversationId); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs index b3efec7545..547fb3fc3e 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; @@ -34,7 +35,7 @@ public void HandleConversationHistory() public void HandleConversationScopedVariables() { - this.ClearAll(VariableScopeNames.Topic); + this.ClearAll(WorkflowScopes.DefaultScopeName); } public void HandleUnknownValue() diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs index 361ad84499..1cd5487aa1 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs @@ -54,7 +54,7 @@ private static void InitializeDefaults(this WorkflowScopes scopes, SemanticModel } } - scopes.Set(variableDiagnostic.Path.VariableName, variableDiagnostic.Path.VariableScopeName ?? VariableScopeNames.Topic, defaultValue); + scopes.Set(variableDiagnostic.Path.VariableName, variableDiagnostic.Path.VariableScopeName ?? WorkflowScopes.DefaultScopeName, defaultValue); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs index b3dfb7f7fa..daa4e121a6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs @@ -16,6 +16,9 @@ namespace Microsoft.Agents.Workflows.Declarative.PowerFx; /// internal sealed class WorkflowScopes : IEnumerable { + // ISSUE #488 - Update default scope for workflows to `Workflow` (instead of `Topic`) + public const string DefaultScopeName = VariableScopeNames.Topic; + private readonly ImmutableDictionary _scopes; public WorkflowScopes(Dictionary? scopes = null) @@ -76,7 +79,7 @@ void Bind(string scopeName) public FormulaValue Get(string name, string? scopeName = null) { - if (this._scopes[scopeName ?? VariableScopeNames.Topic].TryGetValue(name, out FormulaValue? value)) + if (this._scopes[scopeName ?? WorkflowScopes.DefaultScopeName].TryGetValue(name, out FormulaValue? value)) { return value; } @@ -93,7 +96,7 @@ public void Clear(string scopeName) } } - public void Reset(string name) => this.Reset(name, VariableScopeNames.Topic); + public void Reset(string name) => this.Reset(name, WorkflowScopes.DefaultScopeName); public void Reset(string name, string scopeName) { @@ -103,7 +106,7 @@ public void Reset(string name, string scopeName) } } - public void Set(string name, FormulaValue value) => this.Set(name, VariableScopeNames.Topic, value); + public void Set(string name, FormulaValue value) => this.Set(name, WorkflowScopes.DefaultScopeName, value); public void Set(string name, string scopeName, FormulaValue value) => this._scopes[scopeName][name] = value; } From 268ac02083dff45bf65efeb05e4b04cfec11118c Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 25 Aug 2025 15:54:32 -0700 Subject: [PATCH 201/232] Fix feed --- dotnet/Directory.Packages.props | 1 + dotnet/agent-framework-dotnet.slnx | 2 ++ dotnet/nuget.config | 4 ++-- .../Microsoft.Agents.Workflows.Declarative.csproj | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index c494805f81..d3ac519255 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -76,6 +76,7 @@ + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index b6506df4ef..d3a1981c54 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -38,6 +38,8 @@ + + diff --git a/dotnet/nuget.config b/dotnet/nuget.config index 057d82e383..538a2f45f1 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -3,13 +3,13 @@ - + - + diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Microsoft.Agents.Workflows.Declarative.csproj b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Microsoft.Agents.Workflows.Declarative.csproj index db59bcbe2c..634cd93836 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Microsoft.Agents.Workflows.Declarative.csproj +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Microsoft.Agents.Workflows.Declarative.csproj @@ -24,6 +24,7 @@ + From 3f5695b93cda82e15d3a37454622910b417acbde Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 25 Aug 2025 21:58:16 -0700 Subject: [PATCH 202/232] Update sample --- .../PowerFx/SystemScope.cs | 16 ++++++++++++++-- workflows/DeepResearch.yaml | 5 +++++ workflows/HelloWorld.yaml | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs index f875e1861e..1f278daccc 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs @@ -52,8 +52,8 @@ public static void InitializeSystem(this WorkflowScopes scopes, ChatMessage inpu scopes.Set(Names.Activity, VariableScopeNames.System, RecordValue.Empty()); scopes.Set(Names.LastMessage, VariableScopeNames.System, inputMessage.ToRecord()); - scopes.Set(Names.LastMessageId, VariableScopeNames.System, FormulaType.String.NewBlank()); - scopes.Set(Names.LastMessageText, VariableScopeNames.System, FormulaType.String.NewBlank()); + Set(Names.LastMessageId, inputMessage.MessageId); + Set(Names.LastMessageText, inputMessage.Text); scopes.Set( Names.Conversation, @@ -79,6 +79,18 @@ public static void InitializeSystem(this WorkflowScopes scopes, ChatMessage inpu RecordValue.NewRecordFromFields( new NamedValue("Language", StringValue.New(CultureInfo.CurrentCulture.TwoLetterISOLanguageName)))); scopes.Set(Names.UserLanguage, VariableScopeNames.System, StringValue.New(CultureInfo.CurrentCulture.TwoLetterISOLanguageName)); + + void Set(string key, string? value) + { + if (string.IsNullOrEmpty(value)) + { + scopes.Set(key, VariableScopeNames.System, FormulaType.String.NewBlank()); + } + else + { + scopes.Set(key, VariableScopeNames.System, FormulaValue.New(value)); + } + } } public static FormulaValue GetConversationId(this DeclarativeWorkflowState state) => diff --git a/workflows/DeepResearch.yaml b/workflows/DeepResearch.yaml index f88ccedf3b..61766e21f2 100644 --- a/workflows/DeepResearch.yaml +++ b/workflows/DeepResearch.yaml @@ -15,6 +15,11 @@ beginDialog: description: "Able to retrieve weather information", agentid: "asst_oujtcQzC1TtrzC7mBpQogqMn" }, + { + name: "CoderAgent", + description: "Able to write and execute Python code", + agentid: "asst_7QIO6YE66Tt7sNwNhQ7mOfvL" + }, { name: "WebAgent", description: "Able to perform generic websearches", diff --git a/workflows/HelloWorld.yaml b/workflows/HelloWorld.yaml index fe165e21b6..fb3b400f50 100644 --- a/workflows/HelloWorld.yaml +++ b/workflows/HelloWorld.yaml @@ -10,7 +10,7 @@ beginDialog: - kind: SetVariable id: setvar_userinput variable: Topic.UserInput - value: =System.LastMessage.Text + value: =System.LastMessageText # Capture environment variable - kind: SetVariable From 4df8d4288caa10c9d10c70c16eb13f1f4d1cc4d3 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 25 Aug 2025 22:07:19 -0700 Subject: [PATCH 203/232] Add default for "Bot" --- .../PowerFx/SystemScope.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs index 1f278daccc..7b8513ebdb 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs @@ -50,6 +50,7 @@ public static IEnumerable GetNames() public static void InitializeSystem(this WorkflowScopes scopes, ChatMessage inputMessage) { scopes.Set(Names.Activity, VariableScopeNames.System, RecordValue.Empty()); + scopes.Set(Names.Bot, VariableScopeNames.System, RecordValue.Empty()); scopes.Set(Names.LastMessage, VariableScopeNames.System, inputMessage.ToRecord()); Set(Names.LastMessageId, inputMessage.MessageId); From 2d4d14738f4abe0ee1089d9f8be91f599aea83f2 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 25 Aug 2025 23:35:24 -0700 Subject: [PATCH 204/232] Nuget.config patchwork --- dotnet/nuget.config | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dotnet/nuget.config b/dotnet/nuget.config index 538a2f45f1..5b130ffecd 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -8,6 +8,8 @@ + + From 195a1e01ec4a9f836eb30005c7c1035986a7d7c1 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 25 Aug 2025 23:59:35 -0700 Subject: [PATCH 205/232] Scope assignment check --- .../Interpreter/DeclarativeActionExecutor.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs index 4df1f2d872..32ceaf074e 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -92,6 +92,12 @@ public async ValueTask HandleAsync(ExecutionResultMessage message, IWorkflowCont protected void AssignTarget(PropertyPath targetPath, FormulaValue result) { + if (!VariableScopeNames.Global.Equals(targetPath.VariableScopeName, StringComparison.OrdinalIgnoreCase) && + !VariableScopeNames.Topic.Equals(targetPath.VariableScopeName, StringComparison.OrdinalIgnoreCase)) + { + throw new UnsupportedVariableException($"Unsupported variable scope: {targetPath.VariableScopeName}"); + } + this.State.Set(targetPath, result); #if DEBUG From bb83f888d97d22a3d29135e886d4e5b715f7c999 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 26 Aug 2025 08:15:45 -0700 Subject: [PATCH 206/232] Rollback nuget.config haxx --- dotnet/nuget.config | 2 -- 1 file changed, 2 deletions(-) diff --git a/dotnet/nuget.config b/dotnet/nuget.config index 5b130ffecd..538a2f45f1 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -8,8 +8,6 @@ - - From cb311c32a1c9718129ab063fd317979acfe7bd61 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 26 Aug 2025 10:52:36 -0700 Subject: [PATCH 207/232] Sample format --- workflows/DeepResearch.yaml | 1 + workflows/HelloWorld.yaml | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/workflows/DeepResearch.yaml b/workflows/DeepResearch.yaml index 61766e21f2..4463f27e64 100644 --- a/workflows/DeepResearch.yaml +++ b/workflows/DeepResearch.yaml @@ -1,5 +1,6 @@ kind: AdaptiveDialog beginDialog: + kind: OnActivity id: activity_xyz123 type: Message diff --git a/workflows/HelloWorld.yaml b/workflows/HelloWorld.yaml index fb3b400f50..8deeaa8c2b 100644 --- a/workflows/HelloWorld.yaml +++ b/workflows/HelloWorld.yaml @@ -1,5 +1,4 @@ kind: AdaptiveDialog - beginDialog: kind: OnActivity From 798444a9f09405cb65def5fed8be3338e707336c Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 26 Aug 2025 10:58:10 -0700 Subject: [PATCH 208/232] Namespace --- dotnet/demos/QueryWorkflow/Program.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/dotnet/demos/QueryWorkflow/Program.cs b/dotnet/demos/QueryWorkflow/Program.cs index 398e95e6f5..c73a07ce03 100644 --- a/dotnet/demos/QueryWorkflow/Program.cs +++ b/dotnet/demos/QueryWorkflow/Program.cs @@ -8,7 +8,6 @@ using Azure.AI.Agents.Persistent; using Azure.Identity; using Microsoft.Extensions.Configuration; -using Microsoft.Shared.Diagnostics; namespace Demo.DeclarativeWorkflow; From 94b72259af6a12172c9c76cdb7ca563164f9fe5b Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 26 Aug 2025 11:04:30 -0700 Subject: [PATCH 209/232] Namespace --- dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs | 1 - .../ObjectModel/SendActivityExecutorTest.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs index dc2c89a3c4..731f54da72 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Runtime.CompilerServices; using System.Text.Json; diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs index 0edefb14cf..895eb0742a 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.ObjectModel; using Microsoft.Bot.ObjectModel; From 9617e1033983726cab23812e6b27595e7bd405ec Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 26 Aug 2025 11:56:14 -0700 Subject: [PATCH 210/232] Agent-Provider --- dotnet/demos/DeclarativeWorkflow/Program.cs | 7 +-- .../Workflows/Workflows_Declarative.cs | 5 +- .../DeclarativeWorkflowOptions.cs | 13 +---- .../DeclarativeWorkflowOptionsExtensions.cs | 14 ----- .../Interpreter/WorkflowActionVisitor.cs | 16 ++--- .../AnswerQuestionWithAIExecutor.cs | 30 +++++----- .../WorkflowAgentProvider.cs | 58 +++++++++++++++++++ .../DeclarativeWorkflowContextTest.cs | 15 +++-- .../DeclarativeWorkflowTest.cs | 7 ++- 9 files changed, 99 insertions(+), 66 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowAgentProvider.cs diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index 8df92b7a55..ce81cb4fed 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -44,11 +44,8 @@ public static async Task Main(string[] args) Stopwatch timer = Stopwatch.StartNew(); // DeclarativeWorkflowContext provides the components for workflow execution. - DeclarativeWorkflowOptions options = - new(foundryProjectEndpoint) - { - ProjectCredentials = new AzureCliCredential(), - }; + FoundryAgentProvider agentProvider = new(foundryProjectEndpoint, new AzureCliCredential()); + DeclarativeWorkflowOptions options = new(agentProvider); // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowFile, options); diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs index 6d34fb18e0..39999c590d 100644 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs @@ -32,11 +32,12 @@ public async Task RunWorkflow(string fileName) // // DeclarativeWorkflowContext provides the components for workflow execution. // + + FoundryAgentProvider agentProvider = new(Throw.IfNull(TestConfiguration.AzureAI.Endpoint), new AzureCliCredential()); DeclarativeWorkflowOptions workflowContext = - new(Throw.IfNull(TestConfiguration.AzureAI.Endpoint)) + new(agentProvider) { LoggerFactory = this.LoggerFactory, - ProjectCredentials = new AzureCliCredential(), }; // // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs index 195c6535f2..768669ddd9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System.Net.Http; -using Azure.Core; -using Azure.Identity; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -11,7 +9,7 @@ namespace Microsoft.Agents.Workflows.Declarative; /// /// Configuration options for workflow execution. /// -public sealed class DeclarativeWorkflowOptions(string projectEndpoint) +public sealed class DeclarativeWorkflowOptions(WorkflowAgentProvider agentProvider) { /// /// Optionally identifies a continued workflow conversation. @@ -19,14 +17,9 @@ public sealed class DeclarativeWorkflowOptions(string projectEndpoint) public string? ConversationId { get; init; } /// - /// Defines the endpoint for the Foundry project. + /// Defines the agent provider. /// - public string ProjectEndpoint { get; } = projectEndpoint; - - /// - /// Defines the credentials that authorize access to the Foundry project. - /// - public TokenCredential ProjectCredentials { get; init; } = new DefaultAzureCredential(); + public WorkflowAgentProvider AgentProvider { get; } = agentProvider; /// /// Defines the maximum number of nested calls allowed in a PowerFx formula. diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowOptionsExtensions.cs index fcd702192f..19b2a11819 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowOptionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DeclarativeWorkflowOptionsExtensions.cs @@ -1,7 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using Azure.AI.Agents.Persistent; -using Azure.Core.Pipeline; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.PowerFx; @@ -13,16 +11,4 @@ internal static class DeclarativeWorkflowOptionsExtensions public static RecalcEngine CreateRecalcEngine(this DeclarativeWorkflowOptions? context) => RecalcEngineFactory.Create(context?.MaximumExpressionLength ?? DefaultMaximumExpressionLength, context?.MaximumCallDepth); - - public static PersistentAgentsClient CreateClient(this DeclarativeWorkflowOptions context) - { - PersistentAgentsAdministrationClientOptions clientOptions = new(); - - if (context.HttpClient is not null) - { - clientOptions.Transport = new HttpClientTransport(context.HttpClient); - } - - return new PersistentAgentsClient(context.ProjectEndpoint, context.ProjectCredentials, clientOptions); - } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 53314c5685..28dc246b04 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -193,7 +193,7 @@ protected override void Visit(AnswerQuestionWithAI item) { this.Trace(item); - this.ContinueWith(new AnswerQuestionWithAIExecutor(item, this._options.CreateClient())); + this.ContinueWith(new AnswerQuestionWithAIExecutor(item, this._options.AgentProvider)); } protected override void Visit(SetVariable item) @@ -455,17 +455,9 @@ private void ContinueWith( private static string PostId(string actionId) => $"{actionId}_Post"; - private static string GetParentId(BotElement item) - { - string? parentId = item.GetParentId(); - - if (parentId is null) - { - throw new UnknownActionException($"Missing parent ID for action element: {item.GetId()} [{item.GetType().Name}]."); - } - - return parentId; - } + private static string GetParentId(BotElement item) => + item.GetParentId() ?? + throw new UnknownActionException($"Missing parent ID for action element: {item.GetId()} [{item.GetType().Name}]."); private string ContinuationFor(string parentId) => this.ContinuationFor(parentId, parentId); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs index 13626376c9..8446640df2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using Azure.AI.Agents.Persistent; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Agents.Workflows.Declarative.PowerFx; @@ -17,7 +16,7 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; -internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, PersistentAgentsClient client) : DeclarativeActionExecutor(model) +internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, WorkflowAgentProvider agentProvider) : DeclarativeActionExecutor(model) { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { @@ -37,8 +36,8 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P agentId = agentInstructions.Substring(0, delimiterIndex).Trim(); additionalInstructions = agentInstructions.Substring(delimiterIndex + 1).Trim(); } - using NewPersistentAgentsChatClient chatClient = new(client, agentId); - ChatClientAgent agent = new(chatClient); + + AIAgent agent = await agentProvider.GetAgentAsync(agentId, cancellationToken).ConfigureAwait(false); string? userInput = null; if (this.Model.UserInput is not null) @@ -59,16 +58,10 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P this.State.GetConversationId() : this.State.GetInternalConversationId(); - string conversationId; + string? conversationId = null; if (conversationValue is StringValue stringValue) { - conversationId = stringValue.Value; - } - else - { - PersistentAgentThread thread = await client.Threads.CreateThreadAsync(cancellationToken: default).ConfigureAwait(false); - conversationId = thread.Id; - await context.AddEventAsync(new DeclarativeWorkflowInvokeEvent(conversationId)).ConfigureAwait(false); + await AssignConversationId(stringValue.Value).ConfigureAwait(false); } AgentThread agentThread = new() { ConversationId = conversationId }; @@ -83,6 +76,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P { agentResponseUpdates.Add(update); messageId ??= update.MessageId; + await AssignConversationId(((ChatResponseUpdate?)update.RawRepresentation)?.ConversationId).ConfigureAwait(false); if (this.Model.AutoSend) { await context.AddEventAsync(new DeclarativeWorkflowStreamEvent(update)).ConfigureAwait(false); @@ -98,7 +92,8 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(response, agentResponse.Usage)).ConfigureAwait(false); } - if (conversationValue is not StringValue) + // Assign conversation ID if it wasn't already assigned. + if (conversationValue is not StringValue && conversationId is not null) { if (this.Model.AutoSend) // ISSUE #485: Conversation implicitly managed until updated OM is available. { @@ -117,5 +112,14 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, P } return default; + + async ValueTask AssignConversationId(string? assignValue) + { + if (assignValue != null && conversationId == null) + { + conversationId = assignValue; + await context.AddEventAsync(new DeclarativeWorkflowInvokeEvent(conversationId)).ConfigureAwait(false); + } + } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowAgentProvider.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowAgentProvider.cs new file mode 100644 index 0000000000..2ef125e767 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowAgentProvider.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Agents.Persistent; +using Azure.Core; +using Azure.Core.Pipeline; +using Azure.Identity; +using Microsoft.Extensions.AI.Agents; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Base class for workflow agent providers. +/// +public abstract class WorkflowAgentProvider +{ + /// + /// Asynchronously retrieves an AI agent by its unique identifier. + /// + /// The unique identifier of the AI agent to retrieve. Cannot be null or empty. + /// A cancellation token that can be used to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the associated + /// with the specified . Returns if no agent is found. + public abstract Task GetAgentAsync(string agentId, CancellationToken cancellationToken = default); +} + +/// +/// Provides functionality to interact with Foundry agents within a specified project context. +/// +/// This class is used to retrieve and manage AI agents associated with a Foundry project. It requires a +/// project endpoint and credentials to authenticate requests. +/// The endpoint URL of the Foundry project. This must be a valid, non-null URI pointing to the project. +/// The credentials used to authenticate with the Foundry project. This must be a valid instance of . +/// An optional instance to be used for making HTTP requests. If not provided, a default client will be used. +public sealed class FoundryAgentProvider(string projectEndpoint, TokenCredential? projectCredentials = null, HttpClient? httpClient = null) : WorkflowAgentProvider +{ + /// + public override async Task GetAgentAsync(string agentId, CancellationToken cancellationToken = default) + { + AIAgent agent = await this.CreateClient().GetAIAgentAsync(agentId, chatOptions: null, cancellationToken).ConfigureAwait(false); + + return agent; + } + + private PersistentAgentsClient CreateClient() + { + PersistentAgentsAdministrationClientOptions clientOptions = new(); + + if (httpClient is not null) + { + clientOptions.Transport = new HttpClientTransport(httpClient); + } + + return new PersistentAgentsClient(projectEndpoint, projectCredentials ?? new DefaultAzureCredential(), clientOptions); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs index 4257323695..44a3f058ca 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs @@ -5,6 +5,7 @@ using Azure.Identity; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Moq; namespace Microsoft.Agents.Workflows.Declarative.UnitTests; @@ -14,11 +15,11 @@ public class DeclarativeWorkflowContextTests public void InitializeDefaultValues() { // Act - DeclarativeWorkflowOptions context = new("http://test"); + Mock mockProvider = new(MockBehavior.Strict); + DeclarativeWorkflowOptions context = new(mockProvider.Object); // Assert - Assert.Equal("http://test", context.ProjectEndpoint); - Assert.IsType(context.ProjectCredentials); + Assert.Equal(mockProvider.Object, context.AgentProvider); Assert.Null(context.MaximumCallDepth); Assert.Null(context.MaximumExpressionLength); Assert.Null(context.HttpClient); @@ -29,7 +30,6 @@ public void InitializeDefaultValues() public void InitializeExplicitValues() { // Arrange - string projectEndpoint = "https://test-endpoint.com"; TokenCredential credentials = new DefaultAzureCredential(); int maxCallDepth = 10; int maxExpressionLength = 100; @@ -37,9 +37,9 @@ public void InitializeExplicitValues() ILoggerFactory loggerFactory = LoggerFactory.Create(builder => { }); // Act - DeclarativeWorkflowOptions context = new(projectEndpoint) + Mock mockProvider = new(MockBehavior.Strict); + DeclarativeWorkflowOptions context = new(mockProvider.Object) { - ProjectCredentials = credentials, MaximumCallDepth = maxCallDepth, MaximumExpressionLength = maxExpressionLength, HttpClient = httpClient, @@ -47,8 +47,7 @@ public void InitializeExplicitValues() }; // Assert - Assert.Equal(projectEndpoint, context.ProjectEndpoint); - Assert.Same(credentials, context.ProjectCredentials); + Assert.Equal(mockProvider.Object, context.AgentProvider); Assert.Equal(maxCallDepth, context.MaximumCallDepth); Assert.Equal(maxExpressionLength, context.MaximumExpressionLength); Assert.Same(httpClient, context.HttpClient); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index e27869b991..c88a66bfff 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -9,6 +9,7 @@ using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Agents.Workflows.Reflection; using Microsoft.Bot.ObjectModel; +using Moq; using Xunit.Abstractions; namespace Microsoft.Agents.Workflows.Declarative.UnitTests; @@ -197,7 +198,8 @@ public void UnsupportedAction(Type type) }; WorkflowScopes scopes = new(); - DeclarativeWorkflowOptions workflowContext = new("http://test"); + Mock mockAgentProvider = new(MockBehavior.Strict); + DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object); WorkflowActionVisitor visitor = new(new RootExecutor(), workflowContext); WorkflowElementWalker walker = new(dialogBuilder.Build(), visitor); Assert.True(visitor.HasUnsupportedActions); @@ -231,7 +233,8 @@ private void AssertMessage(string message) private async Task RunWorkflow(string workflowPath, TInput workflowInput) where TInput : notnull { using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath)); - DeclarativeWorkflowOptions workflowContext = new("http://test") { LoggerFactory = this.Output }; + Mock mockAgentProvider = new(MockBehavior.Strict); + DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output }; Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); From f0e01dfec2dbafe36e826b71abcbcd869286aa48 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 26 Aug 2025 12:08:35 -0700 Subject: [PATCH 211/232] Clean-up extra files --- dotnet/agent-framework-dotnet.slnx | 1 - dotnet/demos/QueryWorkflow/Program.cs | 107 ------------------ .../Properties/launchSettings.json | 12 -- .../demos/QueryWorkflow/QueryWorkflow.csproj | 29 ----- .../GettingStarted/GettingStarted.csproj | 15 +-- .../Workflows/Expression.CountIf.yaml | 25 ---- .../Workflows/Expression.CountIfType.yaml | 34 ------ .../Workflows/Expression.DropColumns.yaml | 39 ------- .../Workflows/Expression.ForAll.yaml | 39 ------- .../Workflows/Workflows_Declarative.cs | 75 ------------ .../Sample/06_Simple_Workflow_Switch.cs | 99 ---------------- 11 files changed, 5 insertions(+), 470 deletions(-) delete mode 100644 dotnet/demos/QueryWorkflow/Program.cs delete mode 100644 dotnet/demos/QueryWorkflow/Properties/launchSettings.json delete mode 100644 dotnet/demos/QueryWorkflow/QueryWorkflow.csproj delete mode 100644 dotnet/samples/GettingStarted/Workflows/Expression.CountIf.yaml delete mode 100644 dotnet/samples/GettingStarted/Workflows/Expression.CountIfType.yaml delete mode 100644 dotnet/samples/GettingStarted/Workflows/Expression.DropColumns.yaml delete mode 100644 dotnet/samples/GettingStarted/Workflows/Expression.ForAll.yaml delete mode 100644 dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs delete mode 100644 dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_Simple_Workflow_Switch.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index d3a1981c54..b3c8da9100 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -8,7 +8,6 @@ - diff --git a/dotnet/demos/QueryWorkflow/Program.cs b/dotnet/demos/QueryWorkflow/Program.cs deleted file mode 100644 index c73a07ce03..0000000000 --- a/dotnet/demos/QueryWorkflow/Program.cs +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Threading.Tasks; -using Azure.AI.Agents.Persistent; -using Azure.Identity; -using Microsoft.Extensions.Configuration; - -namespace Demo.DeclarativeWorkflow; - -internal static class Program -{ - public static async Task Main(string[] args) - { - string workflowId = GetWorkflowId(); - - // Load configuration and create kernel with Azure OpenAI Chat Completion service - IConfiguration config = InitializeConfig(); - Dictionary nameCache = []; - string foundryProjectEndpoint = config["AzureAI:Endpoint"] ?? throw new InvalidOperationException("Undefined configuration: AzureAI:Endpoint"); - PersistentAgentsClient client = new(foundryProjectEndpoint, new AzureCliCredential()); - - await foreach (PersistentThreadMessage message in client.Messages.GetMessagesAsync(workflowId, order: ListSortOrder.Ascending)) - { - Task>? runTask = null; - if (message.RunId is not null) - { - runTask = client.Runs.GetRunAsync(workflowId, message.RunId); - } - try - { - string? agentName = $"{message.Role}"; - if (message.AssistantId is not null) - { - if (!nameCache.TryGetValue(message.AssistantId, out agentName)) - { - PersistentAgent agent = await client.Administration.GetAgentAsync(message.AssistantId); - nameCache[message.AssistantId] = agent.Name; - agentName = agent.Name; - } - } - Console.ForegroundColor = ConsoleColor.Cyan; - Console.Write($"\n{agentName.ToUpperInvariant()}:"); - Console.ForegroundColor = ConsoleColor.DarkGray; - Console.WriteLine($" [{message.Id}]"); - - Console.ForegroundColor = message.Role == MessageRole.User ? ConsoleColor.White : ConsoleColor.Gray; - Console.WriteLine(message.ContentItems.OfType().FirstOrDefault()?.Text); - Console.ForegroundColor = ConsoleColor.DarkGray; - - if (runTask is not null) - { - ThreadRun messageRun = await runTask; - Console.WriteLine($"[Tokens Total: {messageRun.Usage.TotalTokens}, Input: {messageRun.Usage.PromptTokens}, Output: {messageRun.Usage.CompletionTokens}]"); - } - } - finally - { - Console.ResetColor(); - } - } - - string GetWorkflowId() - { - string? workflowId = args.FirstOrDefault(); - if (string.IsNullOrWhiteSpace(workflowId)) - { - workflowId = Console.ReadLine()?.Trim(); - } - if (!string.IsNullOrWhiteSpace(workflowId)) - { - try - { - Console.ForegroundColor = ConsoleColor.Cyan; - - Console.Write("\nWORKFLOW: "); - - Console.ForegroundColor = ConsoleColor.Yellow; - - if (!string.IsNullOrWhiteSpace(workflowId)) - { - Console.WriteLine(workflowId); - return workflowId; - } - - Console.WriteLine(); - - return workflowId.Trim(); - } - finally - { - Console.ResetColor(); - } - } - throw new ArgumentException("Workflow ID is required."); - } - } - - // Load configuration from user-secrets - private static IConfigurationRoot InitializeConfig() => - new ConfigurationBuilder() - .AddUserSecrets(Assembly.GetExecutingAssembly()) - .Build(); -} diff --git a/dotnet/demos/QueryWorkflow/Properties/launchSettings.json b/dotnet/demos/QueryWorkflow/Properties/launchSettings.json deleted file mode 100644 index 54f20286bb..0000000000 --- a/dotnet/demos/QueryWorkflow/Properties/launchSettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "profiles": { - "Default": { - "commandName": "Project", - "commandLineArgs": "thread_eX1ZSWvK10TdAT9m0ag9SqMW" - }, - "Interactive": { - "commandName": "Project", - "commandLineArgs": "" - } - } -} \ No newline at end of file diff --git a/dotnet/demos/QueryWorkflow/QueryWorkflow.csproj b/dotnet/demos/QueryWorkflow/QueryWorkflow.csproj deleted file mode 100644 index c563580403..0000000000 --- a/dotnet/demos/QueryWorkflow/QueryWorkflow.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - Exe - net9.0 - net9.0 - $(ProjectsDebugTargetFrameworks) - enable - disable - 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 - - - - true - - - - - - - - - - - - - - - diff --git a/dotnet/samples/GettingStarted/GettingStarted.csproj b/dotnet/samples/GettingStarted/GettingStarted.csproj index 889561e675..3c09a961dd 100644 --- a/dotnet/samples/GettingStarted/GettingStarted.csproj +++ b/dotnet/samples/GettingStarted/GettingStarted.csproj @@ -3,8 +3,7 @@ GettingStarted Library - 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 - $(NoWarn);CA1707;CA1716;CA5399;IDE0009;IDE1006;OPENAI001; + $(NoWarn);CA1707;CA1716;IDE0009;IDE1006; OPENAI001; enable true true @@ -16,7 +15,7 @@ $(ProjectsDebugTargetFrameworks) - + @@ -42,29 +41,25 @@ - - + - + Always - - Always - - + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/Expression.CountIf.yaml b/dotnet/samples/GettingStarted/Workflows/Expression.CountIf.yaml deleted file mode 100644 index a21e785ab6..0000000000 --- a/dotnet/samples/GettingStarted/Workflows/Expression.CountIf.yaml +++ /dev/null @@ -1,25 +0,0 @@ -kind: AdaptiveDialog -beginDialog: - - kind: OnActivity - id: activity_xyz123 - type: Message - actions: - - - kind: SetVariable - id: setVariable_list - variable: Topic.TestList - value: =["zaz", "z3z", "zcz", "zdz", "zez", "zfz"] - - - kind: SendActivity - id: sendActivity_list - activity: "{Topic.TestList}" - - - kind: SetVariable - id: setVariable_result - variable: Topic.TestResult - value: =CountIf(Topic.TestList, !IsBlank(Find("e", Value))) - - - kind: SendActivity - id: sendActivity_result - activity: "{Topic.TestResult}" diff --git a/dotnet/samples/GettingStarted/Workflows/Expression.CountIfType.yaml b/dotnet/samples/GettingStarted/Workflows/Expression.CountIfType.yaml deleted file mode 100644 index 13c2c04f8d..0000000000 --- a/dotnet/samples/GettingStarted/Workflows/Expression.CountIfType.yaml +++ /dev/null @@ -1,34 +0,0 @@ -kind: AdaptiveDialog -beginDialog: - - kind: OnActivity - id: activity_xyz123 - type: Message - actions: - - - kind: SetVariable - id: setVariable_list - variable: Topic.TestList - # =[ - # { key: 1 }, - # { key: 2 } - # ] - value: |- - =[ - { key: "a" }, - { key: "b" } - ] - - - kind: SendActivity - id: sendActivity_list - activity: "{Topic.TestList}" - - - kind: SetVariable - id: setVariable_result - variable: Topic.TestResult - #value: =CountIf(Topic.TestList, key > 1) - value: =CountIf(Topic.TestList, key = "b") - - - kind: SendActivity - id: sendActivity_result - activity: "{Topic.TestResult}" diff --git a/dotnet/samples/GettingStarted/Workflows/Expression.DropColumns.yaml b/dotnet/samples/GettingStarted/Workflows/Expression.DropColumns.yaml deleted file mode 100644 index 9735b99a34..0000000000 --- a/dotnet/samples/GettingStarted/Workflows/Expression.DropColumns.yaml +++ /dev/null @@ -1,39 +0,0 @@ -kind: AdaptiveDialog -beginDialog: - - kind: OnActivity - id: activity_xyz123 - type: Message - actions: - - - kind: SetVariable - id: setVariable_list - variable: Topic.AgentsTable - value: |- - =[ - { - name: "WeatherAgent", - description: "Able to retrieve weather information", - schema: "cr36e_agentRd2yAT.topic.Deterministic" - }, - { - name: "WebAgent", - description: "Able to perform generic websearches", - schema: "cr36e_agentRd2yAT.topic.WebSearch" - } - ] - - - kind: SendActivity - id: sendActivity_list - activity: "{Topic.AgentsTable}" - - - kind: SetVariable - id: setVariable_names - displayName: Get all names - variable: Topic.AgentNames - value: =DropColumns(Topic.AgentsTable, description, schema) - - - - kind: SendActivity - id: sendActivity_names - activity: "{Topic.AgentNames}" diff --git a/dotnet/samples/GettingStarted/Workflows/Expression.ForAll.yaml b/dotnet/samples/GettingStarted/Workflows/Expression.ForAll.yaml deleted file mode 100644 index 7aedd57dc0..0000000000 --- a/dotnet/samples/GettingStarted/Workflows/Expression.ForAll.yaml +++ /dev/null @@ -1,39 +0,0 @@ -kind: AdaptiveDialog -beginDialog: - - kind: OnActivity - id: activity_xyz123 - type: Message - actions: - - - kind: SetVariable - id: setVariable_list - variable: Topic.AgentsTable - value: |- - =[ - { - name: "WeatherAgent", - description: "Able to retrieve weather information", - schema: "cr36e_agentRd2yAT.topic.Deterministic" - }, - { - name: "WebAgent", - description: "Able to perform generic websearches", - schema: "cr36e_agentRd2yAT.topic.WebSearch" - } - ] - - - kind: SendActivity - id: sendActivity_list - activity: "{Topic.AgentsTable}" - - - kind: SetVariable - id: setVariable_names - displayName: Get all names - variable: Topic.AgentInfo - value: "=Concat(ForAll(Topic.AgentsTable, name & $\": \" & description), Value, \".\\n\\n\")" - - - - kind: SendActivity - id: sendActivity_info - activity: "{Topic.AgentInfo}" diff --git a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs b/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs deleted file mode 100644 index 39999c590d..0000000000 --- a/dotnet/samples/GettingStarted/Workflows/Workflows_Declarative.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure.Identity; -using Microsoft.Agents.Orchestration; -using Microsoft.Agents.Workflows; -using Microsoft.Agents.Workflows.Declarative; -using Microsoft.Shared.Diagnostics; -using Microsoft.Shared.Samples; - -namespace Workflows; - -/// -/// Demonstrates how to use the -/// for executing multiple agents on the same task in parallel. -/// -public class Workflows_Declarative(ITestOutputHelper output) : OrchestrationSample(output) -{ - [Theory] - [InlineData("Expression.CountIf")] - [InlineData("Expression.CountIfType")] - [InlineData("Expression.DropColumns")] - [InlineData("Expression.ForAll")] - public async Task RunWorkflow(string fileName) - { - Console.WriteLine("WORKFLOW INIT\n"); - - ////////////////////////////////////////////////////// - // - // HOW TO: Create a workflow from a YAML file. - // - using StreamReader yamlReader = File.OpenText(@$"{nameof(Workflows)}\{fileName}.yaml"); - // - // DeclarativeWorkflowContext provides the components for workflow execution. - // - - FoundryAgentProvider agentProvider = new(Throw.IfNull(TestConfiguration.AzureAI.Endpoint), new AzureCliCredential()); - DeclarativeWorkflowOptions workflowContext = - new(agentProvider) - { - LoggerFactory = this.LoggerFactory, - }; - // - // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. - // - Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); - // - ////////////////////////////////////////////////////// - - Console.WriteLine("\nWORKFLOW INVOKE\n"); - - StreamingRun run = await InProcessExecution.StreamAsync(workflow, ""); - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) - { - if (evt is DeclarativeWorkflowMessageEvent messageEvent) - { - if (messageEvent.Data.MessageId is null) - { - Console.WriteLine(messageEvent.Data); - } - else - { - Console.WriteLine($"#{messageEvent.Data.MessageId}:"); - Console.WriteLine(messageEvent.Data); - if (messageEvent.Usage is not null) - { - Console.WriteLine($"[Tokens Total: {messageEvent.Usage.TotalTokenCount}, Input: {messageEvent.Usage.InputTokenCount}, Output: {messageEvent.Usage.OutputTokenCount}]"); - } - Console.WriteLine(); - } - } - } - - Console.WriteLine("\nWORKFLOW DONE"); - } -} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_Simple_Workflow_Switch.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_Simple_Workflow_Switch.cs deleted file mode 100644 index f379bb096f..0000000000 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_Simple_Workflow_Switch.cs +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.IO; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Reflection; - -namespace Microsoft.Agents.Workflows.Sample; - -internal static class Step6Switch -{ - public static async ValueTask RunAsync(TextWriter writer) - { - ResultExecutor caseExecutor1 = new(1); - ResultExecutor caseExecutor2 = new(2); - ResultExecutor caseExecutor3 = new(3); - DefaultExecutor elseExecutor = new(); - FinalExecutor finalExecutor = new(); - DiscriminatingExecutor choiceExecutor = - new(caseExecutor1.Id, - caseExecutor2.Id, - caseExecutor3.Id); - - WorkflowBuilder builder = new(choiceExecutor); - builder.AddSwitch( - choiceExecutor, - switchBuilder => - switchBuilder - .AddCase(result => IsMatch(caseExecutor1.Id, result), caseExecutor1) - .AddCase(result => IsMatch(caseExecutor2.Id, result), caseExecutor2) - .AddCase(result => IsMatch(caseExecutor3.Id, result), caseExecutor3) - .WithDefault(elseExecutor)); - - builder.AddEdge(caseExecutor1, finalExecutor); - builder.AddEdge(caseExecutor2, finalExecutor); - builder.AddEdge(caseExecutor3, finalExecutor); - builder.AddEdge(elseExecutor, finalExecutor); - - Workflow workflow = builder.Build(); - StreamingRun run = await InProcessExecution.StreamAsync(workflow, "Hello, World!").ConfigureAwait(false); - - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) - { - writer.WriteLine($"{evt}"); - } - } - - private static bool IsMatch(string executorId, object? result) - { - return string.Equals(executorId, result as string, StringComparison.Ordinal); - } - - private sealed class DiscriminatingExecutor(params string[] options) : ReflectingExecutor(nameof(DiscriminatingExecutor)), IMessageHandler - { - public async ValueTask HandleAsync(string message, IWorkflowContext context) - { - int index = 0; - foreach (char c in message) - { - index += c; - index %= (options.Length + 1); - } - return options[index]; - } - } - - private sealed class ResultExecutor(int index) : ReflectingExecutor($"{nameof(ResultExecutor)}{index}"), IMessageHandler - { - public async ValueTask HandleAsync(string message, IWorkflowContext context) - { - await context.AddEventAsync(new WorkflowEvent($"#{index}: {message}")).ConfigureAwait(false); - await context.SendMessageAsync(new ExecutorCompleteMessage(this.Id)).ConfigureAwait(false); - } - } - - private sealed class DefaultExecutor() : ReflectingExecutor(nameof(DefaultExecutor)), IMessageHandler - { - public async ValueTask HandleAsync(string message, IWorkflowContext context) - { - await context.AddEventAsync(new WorkflowEvent($"#else: {message}")).ConfigureAwait(false); - await context.SendMessageAsync(new ExecutorCompleteMessage(this.Id)).ConfigureAwait(false); - } - } - - private sealed class FinalExecutor() : ReflectingExecutor(nameof(FinalExecutor)), IMessageHandler - { - public async ValueTask HandleAsync(ExecutorCompleteMessage message, IWorkflowContext context) - { - await context.AddEventAsync(new WorkflowEvent($"#exit: {message}")).ConfigureAwait(false); - } - } - - private sealed record class ExecutorCompleteMessage(string ExecutorId) - { - public DateTime TimeStamp { get; } = DateTime.UtcNow; - - public override string ToString() => $"{this.ExecutorId}: {this.TimeStamp.ToShortTimeString()}"; - } -} From bba62ffe014c3af1ac636921289f498adf037e19 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 26 Aug 2025 13:12:01 -0700 Subject: [PATCH 212/232] Renaming --- .../PowerFx/WorkflowScopes.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs index daa4e121a6..d25c58ac2a 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs @@ -77,9 +77,9 @@ void Bind(string scopeName) } } - public FormulaValue Get(string name, string? scopeName = null) + public FormulaValue Get(string variableName, string? scopeName = null) { - if (this._scopes[scopeName ?? WorkflowScopes.DefaultScopeName].TryGetValue(name, out FormulaValue? value)) + if (this._scopes[scopeName ?? WorkflowScopes.DefaultScopeName].TryGetValue(variableName, out FormulaValue? value)) { return value; } @@ -96,17 +96,17 @@ public void Clear(string scopeName) } } - public void Reset(string name) => this.Reset(name, WorkflowScopes.DefaultScopeName); + public void Reset(string variableName) => this.Reset(variableName, WorkflowScopes.DefaultScopeName); - public void Reset(string name, string scopeName) + public void Reset(string variableName, string scopeName) { - if (this._scopes[scopeName].TryGetValue(name, out FormulaValue? value)) + if (this._scopes[scopeName].TryGetValue(variableName, out FormulaValue? value)) { - this.Set(name, scopeName, FormulaValue.NewBlank(value.Type)); + this.Set(variableName, scopeName, FormulaValue.NewBlank(value.Type)); } } - public void Set(string name, FormulaValue value) => this.Set(name, WorkflowScopes.DefaultScopeName, value); + public void Set(string variableName, FormulaValue value) => this.Set(variableName, WorkflowScopes.DefaultScopeName, value); - public void Set(string name, string scopeName, FormulaValue value) => this._scopes[scopeName][name] = value; + public void Set(string variableName, string scopeName, FormulaValue value) => this._scopes[scopeName][variableName] = value; } From 38b25d32020e54cd8ebfbcaf68d169b7e9bf66ef Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 26 Aug 2025 17:23:08 -0700 Subject: [PATCH 213/232] Update sample --- dotnet/demos/DeclarativeWorkflow/Program.cs | 29 +++++++++++++++++++ .../Properties/launchSettings.json | 4 +++ .../AnswerQuestionWithAIExecutor.cs | 2 +- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index ce81cb4fed..f638fdd1db 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -63,6 +63,7 @@ public static async Task Main(string[] args) } private static readonly Dictionary s_nameCache = []; + private static readonly HashSet s_fileCache = []; private static async Task MonitorWorkflowRunAsync(StreamingRun run, PersistentAgentsClient client) { @@ -112,6 +113,19 @@ private static async Task MonitorWorkflowRunAsync(StreamingRun run, PersistentAg Console.WriteLine($" [{messageId}]"); } } + + ChatResponseUpdate? chatUpdate = streamEvent.Data.RawRepresentation as ChatResponseUpdate; + switch (chatUpdate?.RawRepresentation) + { + case MessageContentUpdate messageUpdate: + string? fileId = messageUpdate.ImageFileId ?? messageUpdate.TextAnnotation?.OutputFileId; + if (fileId is not null && s_fileCache.Add(fileId)) + { + BinaryData content = await client.Files.GetFileContentAsync(fileId); + await DownloadFileContentAsync(Path.GetFileName(messageUpdate.TextAnnotation?.TextToReplace ?? "response.png"), content); + } + break; + } try { Console.ResetColor(); @@ -228,4 +242,19 @@ private static void Notify(string message) Console.ResetColor(); } } + + private static async ValueTask DownloadFileContentAsync(string filename, BinaryData content) + { + string filePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(filename)); + filePath = Path.ChangeExtension(filePath, ".png"); + + await File.WriteAllBytesAsync(filePath, content.ToArray()); + + Process.Start( + new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/C start {filePath}" + }); + } } diff --git a/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json b/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json index febb93f304..5ef2da09fa 100644 --- a/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json +++ b/dotnet/demos/DeclarativeWorkflow/Properties/launchSettings.json @@ -12,6 +12,10 @@ "commandName": "Project", "commandLineArgs": "\"Question.yaml\" \"Why is the sky blue?\"" }, + "Chart": { + "commandName": "Project", + "commandLineArgs": "\"Question.yaml\" \"list the top 30 rivers in the world by length and plot a histogram of their lengths for me to download\"" + }, "MathChat": { "commandName": "Project", "commandLineArgs": "\"MathChat.yaml\" \"How would you compute the value of PI?\"" diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs index 8446640df2..2d38878216 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs @@ -71,7 +71,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, W agent.RunStreamingAsync(agentThread, options, cancellationToken); string? messageId = null; - List agentResponseUpdates = []; + List agentResponseUpdates = new(0x400); await foreach (AgentRunResponseUpdate update in agentUpdates.ConfigureAwait(false)) { agentResponseUpdates.Add(update); From b5a7703b7e13b28fda86806c7be42619383672b8 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 26 Aug 2025 17:46:29 -0700 Subject: [PATCH 214/232] Prune junk files --- .../PowerFx/WorkflowScopeTypeTests.cs | 104 --------------- .../DeclarativeWorkflowContextTests.cs | 118 ------------------ 2 files changed, 222 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative.Tests/PowerFx/WorkflowScopeTypeTests.cs delete mode 100644 dotnet/test/Microsoft.Agents.Workflows.Declarative.Tests/DeclarativeWorkflowContextTests.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative.Tests/PowerFx/WorkflowScopeTypeTests.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative.Tests/PowerFx/WorkflowScopeTypeTests.cs deleted file mode 100644 index 2a0c56d878..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative.Tests/PowerFx/WorkflowScopeTypeTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using Microsoft.Agents.Workflows.Declarative.PowerFx; -using Microsoft.Bot.ObjectModel; -using Xunit; - -namespace Microsoft.Agents.Workflows.Declarative.Tests.PowerFx; - -public class WorkflowScopeTypeTests -{ - [Fact] - public void StaticFieldsHaveCorrectNames() - { - Assert.Equal(VariableScopeNames.Environment, WorkflowScopeType.Env.Name); - Assert.Equal(VariableScopeNames.Topic, WorkflowScopeType.Topic.Name); - Assert.Equal(VariableScopeNames.Global, WorkflowScopeType.Global.Name); - Assert.Equal(VariableScopeNames.System, WorkflowScopeType.System.Name); - } - - [Fact] - public void ParseReturnsCorrectScopeType() - { - WorkflowScopeType envScope = WorkflowScopeType.Parse("Env"); - WorkflowScopeType topicScope = WorkflowScopeType.Parse("Topic"); - WorkflowScopeType globalScope = WorkflowScopeType.Parse("Global"); - WorkflowScopeType systemScope = WorkflowScopeType.Parse("System"); - - Assert.Same(WorkflowScopeType.Env, envScope); - Assert.Same(WorkflowScopeType.Topic, topicScope); - Assert.Same(WorkflowScopeType.Global, globalScope); - Assert.Same(WorkflowScopeType.System, systemScope); - } - - [Fact] - public void ParseThrowsForNullScope() - { - InvalidScopeException exception = Assert.Throws(() => WorkflowScopeType.Parse(null)); - Assert.Equal("Undefined action scope type.", exception.Message); - } - - [Fact] - public void ParseThrowsForUnknownScope() - { - string unknownScope = "Unknown"; - InvalidScopeException exception = Assert.Throws(() => WorkflowScopeType.Parse(unknownScope)); - Assert.Equal($"Unknown action scope type: {unknownScope}.", exception.Message); - } - - [Fact] - public void FormatReturnsScopedName() - { - string variableName = "myVariable"; - - string formattedEnv = WorkflowScopeType.Env.Format(variableName); - string formattedTopic = WorkflowScopeType.Topic.Format(variableName); - string formattedGlobal = WorkflowScopeType.Global.Format(variableName); - string formattedSystem = WorkflowScopeType.System.Format(variableName); - - Assert.Equal($"{VariableScopeNames.Environment}.{variableName}", formattedEnv); - Assert.Equal($"{VariableScopeNames.Topic}.{variableName}", formattedTopic); - Assert.Equal($"{VariableScopeNames.Global}.{variableName}", formattedGlobal); - Assert.Equal($"{VariableScopeNames.System}.{variableName}", formattedSystem); - } - - [Fact] - public void ToStringReturnsName() - { - Assert.Equal(VariableScopeNames.Environment, WorkflowScopeType.Env.ToString()); - Assert.Equal(VariableScopeNames.Topic, WorkflowScopeType.Topic.ToString()); - Assert.Equal(VariableScopeNames.Global, WorkflowScopeType.Global.ToString()); - Assert.Equal(VariableScopeNames.System, WorkflowScopeType.System.ToString()); - } - - [Fact] - public void GetHashCodeReturnsNameHashCode() - { - Assert.Equal(VariableScopeNames.Environment.GetHashCode(), WorkflowScopeType.Env.GetHashCode()); - Assert.Equal(VariableScopeNames.Topic.GetHashCode(), WorkflowScopeType.Topic.GetHashCode()); - Assert.Equal(VariableScopeNames.Global.GetHashCode(), WorkflowScopeType.Global.GetHashCode()); - Assert.Equal(VariableScopeNames.System.GetHashCode(), WorkflowScopeType.System.GetHashCode()); - } - - [Fact] - public void EqualsReturnsTrueForSameType() - { - Assert.True(WorkflowScopeType.Env.Equals(WorkflowScopeType.Env)); - Assert.False(WorkflowScopeType.Env.Equals(WorkflowScopeType.Topic)); - } - - [Fact] - public void EqualsReturnsTrueForMatchingString() - { - Assert.True(WorkflowScopeType.Env.Equals(VariableScopeNames.Environment)); - Assert.False(WorkflowScopeType.Env.Equals(VariableScopeNames.Topic)); - } - - [Fact] - public void EqualsReturnsFalseForNonMatchingTypes() - { - Assert.False(WorkflowScopeType.Env.Equals(42)); - Assert.False(WorkflowScopeType.Env.Equals(null)); - } -} diff --git a/dotnet/test/Microsoft.Agents.Workflows.Declarative.Tests/DeclarativeWorkflowContextTests.cs b/dotnet/test/Microsoft.Agents.Workflows.Declarative.Tests/DeclarativeWorkflowContextTests.cs deleted file mode 100644 index fb54f4fd9a..0000000000 --- a/dotnet/test/Microsoft.Agents.Workflows.Declarative.Tests/DeclarativeWorkflowContextTests.cs +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Net.Http; -using Azure.Core; -using Azure.Identity; -using Microsoft.Agents.Workflows.Declarative.Execution; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Microsoft.Agents.Workflows.Declarative.Tests; - -public class DeclarativeWorkflowContextTests -{ - [Fact] - public void Default_ShouldHaveExpectedValues() - { - // Assert - DeclarativeWorkflowContext defaultContext = DeclarativeWorkflowContext.Default; - Assert.Equal(string.Empty, defaultContext.ProjectEndpoint); - Assert.IsAssignableFrom(defaultContext.ProjectCredentials); - Assert.Null(defaultContext.MaximumCallDepth); - Assert.Null(defaultContext.MaximumExpressionLength); - Assert.Null(defaultContext.HttpClient); - Assert.Same(NullLoggerFactory.Instance, defaultContext.LoggerFactory); - } - - [Fact] - public void Constructor_WithNoParameters_ShouldInitializeDefaultValues() - { - // Act - DeclarativeWorkflowContext context = new(); - - // Assert - Assert.Equal(string.Empty, context.ProjectEndpoint); - Assert.IsAssignableFrom(context.ProjectCredentials); - Assert.Null(context.MaximumCallDepth); - Assert.Null(context.MaximumExpressionLength); - Assert.Null(context.HttpClient); - Assert.Same(NullLoggerFactory.Instance, context.LoggerFactory); - } - - [Fact] - public void Constructor_WithInitializers_ShouldSetProperties() - { - // Arrange - string projectEndpoint = "https://test-endpoint.com"; - TokenCredential credentials = new DefaultAzureCredential(); - int maxCallDepth = 10; - int maxExpressionLength = 100; - HttpClient httpClient = new(); - ILoggerFactory loggerFactory = LoggerFactory.Create(builder => { }); - - // Act - DeclarativeWorkflowContext context = new() - { - ProjectEndpoint = projectEndpoint, - ProjectCredentials = credentials, - MaximumCallDepth = maxCallDepth, - MaximumExpressionLength = maxExpressionLength, - HttpClient = httpClient, - LoggerFactory = loggerFactory - }; - - // Assert - Assert.Equal(projectEndpoint, context.ProjectEndpoint); - Assert.Same(credentials, context.ProjectCredentials); - Assert.Equal(maxCallDepth, context.MaximumCallDepth); - Assert.Equal(maxExpressionLength, context.MaximumExpressionLength); - Assert.Same(httpClient, context.HttpClient); - Assert.Same(loggerFactory, context.LoggerFactory); - } - - [Fact] - public void CreateActionContext_ShouldCreateContextWithExpectedProperties() - { - // Arrange - DeclarativeWorkflowContext context = new() - { - MaximumExpressionLength = 200 - }; - string rootId = "test-root-id"; - WorkflowScopes scopes = new(); - - // Act - WorkflowExecutionContext executionContext = context.CreateActionContext(rootId, scopes); - - // Assert - Assert.NotNull(executionContext); - Assert.NotNull(executionContext.Engine); - Assert.Same(scopes, executionContext.Scopes); - Assert.NotNull(executionContext.ClientFactory); - Assert.NotNull(executionContext.Logger); - Assert.Equal(rootId, executionContext.Logger.ToString()); - } - - [Fact] - public void CreateActionContext_WithCustomLoggerFactory_ShouldUseCustomLogger() - { - // Arrange - ILoggerFactory customLoggerFactory = LoggerFactory.Create(builder => { }); - DeclarativeWorkflowContext context = new() - { - LoggerFactory = customLoggerFactory - }; - string rootId = "test-root-id"; - WorkflowScopes scopes = new(); - - // Act - WorkflowExecutionContext executionContext = context.CreateActionContext(rootId, scopes); - - // Assert - Assert.NotNull(executionContext); - Assert.NotNull(executionContext.Logger); - Assert.NotSame(NullLogger.Instance, executionContext.Logger); - } -} From e186417a3b03ef946a6eda29d72dae6b23f6e631 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Wed, 27 Aug 2025 13:55:40 -0700 Subject: [PATCH 215/232] Clean-up --- dotnet/agent-framework-dotnet.slnx | 7 ++- dotnet/demos/DeclarativeWorkflow/Program.cs | 62 +++++++++++-------- .../DeclarativeWorkflowOptions.cs | 14 ++--- workflows/DeepResearch.yaml | 26 ++++++++ workflows/HelloWorld.yaml | 5 ++ workflows/MathChat.yaml | 18 ++++++ workflows/Question.yaml | 5 ++ workflows/wttr.json | 51 +++++++++++++++ 8 files changed, 150 insertions(+), 38 deletions(-) create mode 100644 workflows/wttr.json diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index b3c8da9100..11ffc709fb 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -25,6 +25,7 @@ + @@ -139,9 +140,9 @@ - - + + @@ -165,8 +166,8 @@ - + diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index f638fdd1db..7b2f123b05 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -24,40 +24,35 @@ namespace Demo.DeclarativeWorkflow; /// All other arguments are intepreted as a queue of inputs. /// When no input is queued, interactive input is requested from the console. /// -internal static class Program +internal sealed class Program { private const string DefaultWorkflow = "HelloWorld.yaml"; public static async Task Main(string[] args) { - string workflowFile = GetWorkflowFile(args); - - // Load configuration and create kernel with Azure OpenAI Chat Completion service - IConfiguration config = InitializeConfig(); - - string foundryProjectEndpoint = config["AzureAI:Endpoint"] ?? throw new InvalidOperationException("Undefined configuration: AzureAI:Endpoint"); - PersistentAgentsClient client = new(foundryProjectEndpoint, new AzureCliCredential()); + Program program = new(args); + await program.ExecuteAsync(); + } + private async Task ExecuteAsync() + { // Read and parse the declarative workflow. - Notify($"WORKFLOW: Parsing {Path.GetFullPath(workflowFile)}"); + Notify($"WORKFLOW: Parsing {Path.GetFullPath(this.WorkflowFile)}"); Stopwatch timer = Stopwatch.StartNew(); - // DeclarativeWorkflowContext provides the components for workflow execution. - FoundryAgentProvider agentProvider = new(foundryProjectEndpoint, new AzureCliCredential()); - DeclarativeWorkflowOptions options = new(agentProvider); - // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. - Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowFile, options); + DeclarativeWorkflowOptions options = new(new FoundryAgentProvider(this.FoundryEndpoint, new AzureCliCredential())); + Workflow workflow = DeclarativeWorkflowBuilder.Build(this.WorkflowFile, options); Notify($"\nWORKFLOW: Defined {timer.Elapsed}"); Notify("\nWORKFLOW: Starting..."); // Run the workflow, just like any other workflow - string input = GetWorkflowInput(args); + string input = this.GetWorkflowInput(); StreamingRun run = await InProcessExecution.StreamAsync(workflow, input); - await MonitorWorkflowRunAsync(run, client); + await this.MonitorWorkflowRunAsync(run); Notify("\nWORKFLOW: Done!"); } @@ -65,7 +60,24 @@ public static async Task Main(string[] args) private static readonly Dictionary s_nameCache = []; private static readonly HashSet s_fileCache = []; - private static async Task MonitorWorkflowRunAsync(StreamingRun run, PersistentAgentsClient client) + private string WorkflowFile { get; } + private string? WorkflowInput { get; } + private string FoundryEndpoint { get; } + private PersistentAgentsClient FoundryClient { get; } + private IConfiguration Configuration { get; } + + private Program(string[] args) + { + this.WorkflowFile = ParseWorkflowFile(args); + this.WorkflowInput = ParseWorkflowInput(args); + + this.Configuration = InitializeConfig(); + + this.FoundryEndpoint = this.Configuration["AzureAI:Endpoint"] ?? throw new InvalidOperationException("Undefined configuration: AzureAI:Endpoint"); + this.FoundryClient = new PersistentAgentsClient(this.FoundryEndpoint, new AzureCliCredential()); + } + + private async Task MonitorWorkflowRunAsync(StreamingRun run) { string? messageId = null; @@ -100,7 +112,7 @@ private static async Task MonitorWorkflowRunAsync(StreamingRun run, PersistentAg { if (!s_nameCache.TryGetValue(agentId, out string? realName)) { - PersistentAgent agent = await client.Administration.GetAgentAsync(agentId); + PersistentAgent agent = await this.FoundryClient.Administration.GetAgentAsync(agentId); s_nameCache[agentId] = agent.Name; realName = agent.Name; } @@ -121,7 +133,7 @@ private static async Task MonitorWorkflowRunAsync(StreamingRun run, PersistentAg string? fileId = messageUpdate.ImageFileId ?? messageUpdate.TextAnnotation?.OutputFileId; if (fileId is not null && s_fileCache.Add(fileId)) { - BinaryData content = await client.Files.GetFileContentAsync(fileId); + BinaryData content = await this.FoundryClient.Files.GetFileContentAsync(fileId); await DownloadFileContentAsync(Path.GetFileName(messageUpdate.TextAnnotation?.TextToReplace ?? "response.png"), content); } break; @@ -165,7 +177,7 @@ private static async Task MonitorWorkflowRunAsync(StreamingRun run, PersistentAg } } - private static string GetWorkflowFile(string[] args) + private static string ParseWorkflowFile(string[] args) { string workflowFile = args.FirstOrDefault() ?? DefaultWorkflow; @@ -182,9 +194,9 @@ private static string GetWorkflowFile(string[] args) return workflowFile; } - private static string GetWorkflowInput(string[] args) + private string GetWorkflowInput() { - string? input = GetWorkflowInputs(args).FirstOrDefault(); + string? input = this.WorkflowInput; try { @@ -212,16 +224,16 @@ private static string GetWorkflowInput(string[] args) } } - private static string[] GetWorkflowInputs(string[] args) + private static string? ParseWorkflowInput(string[] args) { if (args.Length == 0) { - return []; + return null; } string[] workflowInput = [.. args.Skip(1)]; - return workflowInput; + return workflowInput.FirstOrDefault(); } // Load configuration from user-secrets diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs index 768669ddd9..cb4db844a7 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Net.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -12,14 +11,14 @@ namespace Microsoft.Agents.Workflows.Declarative; public sealed class DeclarativeWorkflowOptions(WorkflowAgentProvider agentProvider) { /// - /// Optionally identifies a continued workflow conversation. + /// Defines the agent provider. /// - public string? ConversationId { get; init; } + public WorkflowAgentProvider AgentProvider { get; } = agentProvider; /// - /// Defines the agent provider. + /// Optionally identifies a continued workflow conversation. /// - public WorkflowAgentProvider AgentProvider { get; } = agentProvider; + public string? ConversationId { get; init; } /// /// Defines the maximum number of nested calls allowed in a PowerFx formula. @@ -31,11 +30,6 @@ public sealed class DeclarativeWorkflowOptions(WorkflowAgentProvider agentProvid /// public int? MaximumExpressionLength { get; init; } - /// - /// Gets the instance used to send HTTP requests. - /// - public HttpClient? HttpClient { get; init; } - /// /// Gets the used to create loggers for workflow components. /// diff --git a/workflows/DeepResearch.yaml b/workflows/DeepResearch.yaml index 4463f27e64..3bb79968ef 100644 --- a/workflows/DeepResearch.yaml +++ b/workflows/DeepResearch.yaml @@ -1,3 +1,29 @@ +# +# This workflow demonstrates a multi-agent orchestrator that attempts to address complex user requests. +# +# For this workflow, several agents used, each with a prompt specific to their role: +# +# 1. Analyst Agent: Able to analyze the current task. +# Enable "Bing Grounding Tool" in the agent settings. +# +# 2. Manager Agent: Able to create plans and delegate tasks to other agents. +# +# 3. Research Agent: +# Enable "Bing Grounding" in the agent settings. +# +# With instructions: +# +# Only provide requested information in a way that is throughfully organized and formatted. +# Never include any analysis or code. +# Never generate a file. +# Avoid repeating yourself. +# +# 4. Coder Agent: +# Enable "Code Interpreter" in the agent settings. +# +# 5. Weather Agent: Able to retrieve factual information from the web. +# Enable "Open API" in the agent settings using the wttr.json schema. +# kind: AdaptiveDialog beginDialog: diff --git a/workflows/HelloWorld.yaml b/workflows/HelloWorld.yaml index 8deeaa8c2b..efb695dc1d 100644 --- a/workflows/HelloWorld.yaml +++ b/workflows/HelloWorld.yaml @@ -1,3 +1,8 @@ +# +# This workflow provides the most basic example of providing a response that includes the user and environment input. +# +# No setup is required to run this workflow. +# kind: AdaptiveDialog beginDialog: diff --git a/workflows/MathChat.yaml b/workflows/MathChat.yaml index 8540717243..e115082988 100644 --- a/workflows/MathChat.yaml +++ b/workflows/MathChat.yaml @@ -1,3 +1,21 @@ +# +# This workflow demonstrates conversation between two agents: a student and a teacher. +# The student attempts to solve the input problem and the teacher provides guidance. +# +# For this workflow, two agents are used, each with a prompt specific to their role. +# +# Student: +# Your job is help a math teacher practice teaching by making intentional mistakes. +# You Attempt to solve the given math problem, but with intentional mistakes so the teacher can help. +# Always incorporate the teacher's advice to fix your next response. +# You have the math-skills of a 6th grader. + +# Teacher: +# Review and coach the student's approach to solving the given math problem. +# Don't repeat the solution or try and solve it. +# If the student has demonstrated comprehension and responded to all of your feedback, +# give the student your congraluations by using the word "congratulations". +# kind: AdaptiveDialog beginDialog: diff --git a/workflows/Question.yaml b/workflows/Question.yaml index 69788389bc..a89fd3a934 100644 --- a/workflows/Question.yaml +++ b/workflows/Question.yaml @@ -1,3 +1,8 @@ +# +# This workflow demonstrates a single agent interaction based on user input. +# +# Any Foundry Agent may be used to provide the response. +# kind: AdaptiveDialog beginDialog: diff --git a/workflows/wttr.json b/workflows/wttr.json new file mode 100644 index 0000000000..0b6d7cad9b --- /dev/null +++ b/workflows/wttr.json @@ -0,0 +1,51 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Get weather data", + "description": "Retrieves current weather data for a location based on wttr.in.", + "version": "v1.0.0" + }, + "servers": [ + { + "url": "https://wttr.in" + } + ], + "paths": { + "/{location}": { + "get": { + "description": "Get weather information for a specific location", + "operationId": "GetCurrentWeather", + "parameters": [ + { + "name": "location", + "in": "path", + "description": "City or location to retrieve the weather for", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Location not found" + } + }, + "deprecated": false + } + } + }, + "components": { + "schemas": {} + } +} \ No newline at end of file From 98e0bf36978797e1ded14f5762510d0e445b1612 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Wed, 27 Aug 2025 14:00:31 -0700 Subject: [PATCH 216/232] Use transform --- .../DeclarativeWorkflowBuilder.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 385593e3bc..fa073c6dcd 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -56,7 +56,10 @@ public static Workflow Build( string rootId = WorkflowActionVisitor.RootId(workflowElement.BeginDialog?.Id.Value ?? "workflow"); - DeclarativeWorkflowExecutor rootExecutor = new(rootId, WrapWithBot(workflowElement), message => DefaultTransform(message)); + DeclarativeWorkflowExecutor rootExecutor = + new(rootId, + WrapWithBot(workflowElement), + message => inputTransform?.Invoke(message) ?? DefaultTransform(message)); WorkflowActionVisitor visitor = new(rootExecutor, options); WorkflowElementWalker walker = new(rootElement, visitor); From f002bfb0bf3c00ea6e8d3a7e654f26f484503b12 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Wed, 27 Aug 2025 14:09:51 -0700 Subject: [PATCH 217/232] agent provider fix --- .../WorkflowAgentProvider.cs | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowAgentProvider.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowAgentProvider.cs index 2ef125e767..d1f75d6b5d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/WorkflowAgentProvider.cs @@ -36,23 +36,32 @@ public abstract class WorkflowAgentProvider /// An optional instance to be used for making HTTP requests. If not provided, a default client will be used. public sealed class FoundryAgentProvider(string projectEndpoint, TokenCredential? projectCredentials = null, HttpClient? httpClient = null) : WorkflowAgentProvider { + private PersistentAgentsClient? _agentsClient; + /// public override async Task GetAgentAsync(string agentId, CancellationToken cancellationToken = default) { - AIAgent agent = await this.CreateClient().GetAIAgentAsync(agentId, chatOptions: null, cancellationToken).ConfigureAwait(false); + AIAgent agent = await this.GetAgentsClient().GetAIAgentAsync(agentId, chatOptions: null, cancellationToken).ConfigureAwait(false); return agent; } - private PersistentAgentsClient CreateClient() + private PersistentAgentsClient GetAgentsClient() { - PersistentAgentsAdministrationClientOptions clientOptions = new(); - - if (httpClient is not null) + if (this._agentsClient is null) { - clientOptions.Transport = new HttpClientTransport(httpClient); + PersistentAgentsAdministrationClientOptions clientOptions = new(); + + if (httpClient is not null) + { + clientOptions.Transport = new HttpClientTransport(httpClient); + } + + PersistentAgentsClient newClient = new(projectEndpoint, projectCredentials ?? new DefaultAzureCredential(), clientOptions); + + Interlocked.CompareExchange(ref this._agentsClient, newClient, null); } - return new PersistentAgentsClient(projectEndpoint, projectCredentials ?? new DefaultAzureCredential(), clientOptions); + return this._agentsClient; } } From 5f90c1d784aca4fde22b54103c16faa3cee47917 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Wed, 27 Aug 2025 14:10:40 -0700 Subject: [PATCH 218/232] Typo --- .../DeclarativeWorkflowInvokeEvent.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowInvokeEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowInvokeEvent.cs index f1f883f0dc..e02776989d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowInvokeEvent.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowInvokeEvent.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.Workflows.Declarative; public class DeclarativeWorkflowInvokeEvent(string conversationId) : DeclarativeWorkflowEvent(conversationId) { /// - /// The converation ID associated with the workflow. + /// The conversation ID associated with the workflow. /// public new string Data => conversationId; } From 4c3f6901c6647e5bd46adfa6f0c2da69e2b873df Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Wed, 27 Aug 2025 14:17:24 -0700 Subject: [PATCH 219/232] Null check fix --- .../DeclarativeWorkflowOptions.cs | 3 +- .../DeclarativeWorkflowContextTest.cs | 5 - .../PowerFx/RecalcEngineEvaluationTests.cs | 98 ------------------- 3 files changed, 2 insertions(+), 104 deletions(-) delete mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineEvaluationTests.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs index cb4db844a7..68c2e93641 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Declarative; @@ -13,7 +14,7 @@ public sealed class DeclarativeWorkflowOptions(WorkflowAgentProvider agentProvid /// /// Defines the agent provider. /// - public WorkflowAgentProvider AgentProvider { get; } = agentProvider; + public WorkflowAgentProvider AgentProvider { get; } = Throw.IfNull(agentProvider, nameof(agentProvider)); /// /// Optionally identifies a continued workflow conversation. diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs index 44a3f058ca..24ee30deee 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowContextTest.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Net.Http; using Azure.Core; using Azure.Identity; using Microsoft.Extensions.Logging; @@ -22,7 +21,6 @@ public void InitializeDefaultValues() Assert.Equal(mockProvider.Object, context.AgentProvider); Assert.Null(context.MaximumCallDepth); Assert.Null(context.MaximumExpressionLength); - Assert.Null(context.HttpClient); Assert.Same(NullLoggerFactory.Instance, context.LoggerFactory); } @@ -33,7 +31,6 @@ public void InitializeExplicitValues() TokenCredential credentials = new DefaultAzureCredential(); int maxCallDepth = 10; int maxExpressionLength = 100; - using HttpClient httpClient = new(); ILoggerFactory loggerFactory = LoggerFactory.Create(builder => { }); // Act @@ -42,7 +39,6 @@ public void InitializeExplicitValues() { MaximumCallDepth = maxCallDepth, MaximumExpressionLength = maxExpressionLength, - HttpClient = httpClient, LoggerFactory = loggerFactory }; @@ -50,7 +46,6 @@ public void InitializeExplicitValues() Assert.Equal(mockProvider.Object, context.AgentProvider); Assert.Equal(maxCallDepth, context.MaximumCallDepth); Assert.Equal(maxExpressionLength, context.MaximumExpressionLength); - Assert.Same(httpClient, context.HttpClient); Assert.Same(loggerFactory, context.LoggerFactory); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineEvaluationTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineEvaluationTests.cs deleted file mode 100644 index b6daeb4a96..0000000000 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineEvaluationTests.cs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using Microsoft.PowerFx; -using Microsoft.PowerFx.Types; -using Xunit.Abstractions; - -namespace Microsoft.Agents.Workflows.Declarative.UnitTests.PowerFx; - -#pragma warning disable CA1308 // Ignore "Normalize strings to uppercase" warning for test cases - -public sealed class RecalcEngineEvaluationTests(ITestOutputHelper output) : RecalcEngineTest(output) -{ - [Fact] - public void EvaluateConstant() - { - RecalcEngine engine = this.CreateEngine(); - - this.EvaluateExpression(engine, 0m, "0"); - this.EvaluateExpression(engine, -1m, "-1"); - this.EvaluateExpression(engine, true, "true"); - this.EvaluateExpression(engine, false, "false"); - this.EvaluateExpression(engine, (string?)null, string.Empty); - this.EvaluateExpression(engine, "Hi", "\"Hi\""); - } - - [Fact] - public void EvaluateInvalid() - { - RecalcEngine engine = this.CreateEngine(); - engine.UpdateVariable("Scoped.Value", FormulaValue.New(33)); - - this.EvaluateFailure(engine, "Hi"); - this.EvaluateFailure(engine, "True"); - this.EvaluateFailure(engine, "TRUE"); - this.EvaluateFailure(engine, "=1", canParse: false); - this.EvaluateFailure(engine, "=1+2", canParse: false); - this.EvaluateFailure(engine, "CustomValue"); - this.EvaluateFailure(engine, "CustomValue + 1"); - this.EvaluateFailure(engine, "Scoped.Value"); - this.EvaluateFailure(engine, "Scoped.Value + 1"); - this.EvaluateFailure(engine, "\"BEGIN-\" & Scoped.Value & \"-END\""); - } - - [Fact] - public void EvaluateFormula() - { - NamedValue[] recordValues = - [ - new NamedValue("Label", FormulaValue.New("Test")), - new NamedValue("Value", FormulaValue.New(54)), - ]; - FormulaValue complexValue = FormulaValue.NewRecordFromFields(recordValues); - - RecalcEngine engine = this.CreateEngine(); - engine.UpdateVariable("CustomLabel", FormulaValue.New("Note")); - engine.UpdateVariable("CustomValue", FormulaValue.New(42)); - engine.UpdateVariable("Scoped", complexValue); - - this.EvaluateExpression(engine, 2m, "1 + 1"); - this.EvaluateExpression(engine, 42m, "CustomValue"); - this.EvaluateExpression(engine, 43m, "CustomValue + 1"); - this.EvaluateExpression(engine, "Note", "CustomLabel"); - //this.EvaluateExpression(engine, "Note", "\"{CustomLabel}\""); - this.EvaluateExpression(engine, "BEGIN-42-END", "\"BEGIN-\" & CustomValue & \"-END\""); - this.EvaluateExpression(engine, 54m, "Scoped.Value"); - this.EvaluateExpression(engine, 55m, "Scoped.Value + 1"); - this.EvaluateExpression(engine, "Test", "Scoped.Label"); - //this.EvaluateExpression(engine, "Test", "\"{Scoped.Label}\""); - } - - private void EvaluateFailure(RecalcEngine engine, string sourceExpression, bool canParse = true) - { - CheckResult checkResult = engine.Check(sourceExpression); - Assert.False(checkResult.IsSuccess); - ParseResult parseResult = engine.Parse(sourceExpression); - Assert.Equal(canParse, parseResult.IsSuccess); - Assert.Throws(() => engine.Eval(sourceExpression)); - } - - private void EvaluateExpression(RecalcEngine engine, T expectedResult, string sourceExpression) - { - CheckResult checkResult = engine.Check(sourceExpression); - Assert.True(checkResult.IsSuccess); - ParseResult parseResult = engine.Parse(sourceExpression); - Assert.True(parseResult.IsSuccess); - FormulaValue valueResult = engine.Eval(sourceExpression); - if (expectedResult is null) - { - Assert.Null(valueResult.ToObject()); - } - else - { - Assert.IsType(valueResult.ToObject()); - Assert.Equal(expectedResult, valueResult.ToObject()); - } - } -} From 58309679049694f6f70572d1345700c3332b0942 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Wed, 27 Aug 2025 14:28:42 -0700 Subject: [PATCH 220/232] Fix merge --- dotnet/src/Microsoft.Agents.Workflows/Execution/StateScope.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/StateScope.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/StateScope.cs index 91def62079..ce5d559337 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/StateScope.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/StateScope.cs @@ -52,7 +52,7 @@ public ValueTask WriteStateAsync(Dictionary> updates) continue; } - if (scopedUpdate.Count > 1) + if (updates[key].Count > 1) { throw new InvalidOperationException($"Expected exactly one update for key '{key}'."); } From 0bf2ae5a23d43dd141e595dda2bcde668ecf0e6b Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 28 Aug 2025 12:45:21 -0700 Subject: [PATCH 221/232] Checkpoint --- .../DeclarativeWorkflowBuilder.cs | 11 +- .../Exceptions/InvalidScopeException.cs | 35 ++++ .../Extensions/DataValueExtensions.cs | 4 +- .../Extensions/FormulaValueExtensions.cs | 164 ++++++++++++++---- .../Extensions/IWorkflowContextExtensions.cs | 43 ----- .../Interpreter/DeclarativeActionExecutor.cs | 48 +++-- .../DeclarativeWorkflowExecutor.cs | 11 +- .../Interpreter/DeclarativeWorkflowState.cs | 42 ++++- .../Interpreter/DelegateActionExecutor.cs | 27 +-- .../Interpreter/WorkflowActionVisitor.cs | 42 ++--- .../AnswerQuestionWithAIExecutor.cs | 11 +- .../ObjectModel/ClearAllVariablesExecutor.cs | 3 +- .../ObjectModel/ConditionGroupExecutor.cs | 8 +- .../ObjectModel/EditTableExecutor.cs | 12 +- .../ObjectModel/EditTableV2Executor.cs | 12 +- .../ObjectModel/ForeachExecutor.cs | 16 +- .../ObjectModel/ParseValueExecutor.cs | 8 +- .../ObjectModel/ResetVariableExecutor.cs | 4 +- .../ObjectModel/SendActivityExecutor.cs | 4 +- .../ObjectModel/SetTextVariableExecutor.cs | 9 +- .../ObjectModel/SetVariableExecutor.cs | 9 +- .../PowerFx/RecalcEngineFactory.cs | 2 +- .../PowerFx/SystemScope.cs | 62 +++---- .../PowerFx/WorkflowDiagnostics.cs | 9 +- .../PowerFx/WorkflowExpressionEngine.cs | 4 +- .../PowerFx/WorkflowScope.cs | 17 ++ .../PowerFx/WorkflowScopes.cs | 53 ++---- .../DeclarativeWorkflowTest.cs | 9 +- .../Extensions/FormulaValueExtensionsTests.cs | 63 ++++--- .../ClearAllVariablesExecutorTest.cs | 4 +- .../ObjectModel/ParseValueExecutorTest.cs | 8 +- .../ObjectModel/ResetVariableExecutorTest.cs | 4 +- .../ObjectModel/SendActivityExecutorTest.cs | 2 +- .../SetTextVariableExecutorTest.cs | 4 +- .../ObjectModel/SetVariableExecutorTest.cs | 4 +- .../ObjectModel/WorkflowActionExecutorTest.cs | 5 +- .../PowerFx/RecalcEngineFactoryTests.cs | 47 +++++ .../PowerFx/WorkflowExpressionEngineTests.cs | 22 +-- .../PowerFx/WorkflowScopesTests.cs | 20 +-- 39 files changed, 516 insertions(+), 346 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index fa073c6dcd..c35c6c099e 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -3,7 +3,9 @@ using System; using System.IO; using System.Linq; +using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.Interpreter; +using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Yaml; using Microsoft.Extensions.AI; @@ -48,7 +50,7 @@ public static Workflow Build( { BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new UnknownActionException("Unable to parse workflow."); - // ISSUE #486 - Use "Workflow" element for Foundry specific. + // ISSUE #486 - Use "Workflow" element for Foundry. if (rootElement is not AdaptiveDialog workflowElement) { throw new UnknownActionException($"Unsupported root element: {rootElement.GetType().Name}. Expected an {nameof(AdaptiveDialog)}."); @@ -56,12 +58,15 @@ public static Workflow Build( string rootId = WorkflowActionVisitor.RootId(workflowElement.BeginDialog?.Id.Value ?? "workflow"); + WorkflowScopes scopes = new(); + scopes.Initialize(WrapWithBot(workflowElement)); + DeclarativeWorkflowState state = new(options.CreateRecalcEngine(), scopes); DeclarativeWorkflowExecutor rootExecutor = new(rootId, - WrapWithBot(workflowElement), + state, message => inputTransform?.Invoke(message) ?? DefaultTransform(message)); - WorkflowActionVisitor visitor = new(rootExecutor, options); + WorkflowActionVisitor visitor = new(rootExecutor, state, options); WorkflowElementWalker walker = new(rootElement, visitor); return walker.GetWorkflow(); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs new file mode 100644 index 0000000000..ec339ef298 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Represents an exception that occurs when an action is invalid or cannot be processed. +/// +public sealed class InvalidScopeException : DeclarativeWorkflowException +{ + /// + /// Initializes a new instance of the class. + /// + public InvalidScopeException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public InvalidScopeException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public InvalidScopeException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs index 73dd31115d..81666b6013 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/DataValueExtensions.cs @@ -37,8 +37,8 @@ public static FormulaType ToFormulaType(this DataType? type) => { null => FormulaType.Blank, BooleanDataType => FormulaType.Boolean, - NumberDataType => FormulaType.Number, - FloatDataType => FormulaType.Decimal, + NumberDataType => FormulaType.Decimal, + FloatDataType => FormulaType.Number, StringDataType => FormulaType.String, DateTimeDataType => FormulaType.DateTime, DateDataType => FormulaType.Date, diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs index ac41435ed5..9bd57fdb44 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -8,6 +9,7 @@ using System.Text.Json.Nodes; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx.Types; +using BindingFlags = System.Reflection.BindingFlags; using BlankType = Microsoft.PowerFx.Types.BlankType; namespace Microsoft.Agents.Workflows.Declarative.Extensions; @@ -16,20 +18,60 @@ internal static class FormulaValueExtensions { private static readonly JsonSerializerOptions s_options = new() { WriteIndented = true }; + public static FormulaValue NewBlank(this FormulaType? type) => FormulaValue.NewBlank(type ?? FormulaType.Blank); + + public static FormulaValue ToFormulaValue(this object? value) + { + Type? type = value?.GetType(); + return value switch + { + null => FormulaValue.NewBlank(), + bool booleanValue => FormulaValue.New(booleanValue), + int decimalValue => FormulaValue.New(decimalValue), + long decimalValue => FormulaValue.New(decimalValue), + float decimalValue => FormulaValue.New(decimalValue), + decimal decimalValue => FormulaValue.New(decimalValue), + double numberValue => FormulaValue.New(numberValue), + string stringValue => FormulaValue.New(stringValue), + DateTime dateonlyValue when dateonlyValue.TimeOfDay == TimeSpan.Zero => FormulaValue.NewDateOnly(dateonlyValue), + DateTime datetimeValue => FormulaValue.New(datetimeValue), + TimeSpan timeValue => FormulaValue.New(timeValue), + object when typeof(IEnumerable).IsAssignableFrom(type) => ((IEnumerable)value).ToTableValue(type), + _ => value.ToRecordValue(type), + }; + } + + public static FormulaType ToFormulaType(this Type? type) => + type switch + { + null => FormulaType.Blank, + Type when type == typeof(bool) => FormulaType.Boolean, + Type when type == typeof(int) => FormulaType.Decimal, + Type when type == typeof(long) => FormulaType.Decimal, + Type when type == typeof(float) => FormulaType.Decimal, + Type when type == typeof(decimal) => FormulaType.Decimal, + Type when type == typeof(double) => FormulaType.Number, + Type when type == typeof(string) => FormulaType.String, + Type when type == typeof(DateTime) => FormulaType.DateTime, + Type when type == typeof(TimeSpan) => FormulaType.Time, + Type when typeof(IEnumerable).IsAssignableFrom(type) => type.ToTableType(), + _ => type.ToRecordType(), + }; + public static DataValue ToDataValue(this FormulaValue value) => value switch { - BooleanValue booleanValue => booleanValue.ToDataValue(), - DecimalValue decimalValue => decimalValue.ToDataValue(), - NumberValue numberValue => numberValue.ToDataValue(), - DateValue dateValue => dateValue.ToDataValue(), - DateTimeValue datetimeValue => datetimeValue.ToDataValue(), - TimeValue timeValue => timeValue.ToDataValue(), - StringValue stringValue => stringValue.ToDataValue(), - BlankValue blankValue => blankValue.ToDataValue(), - VoidValue voidValue => voidValue.ToDataValue(), - TableValue tableValue => tableValue.ToDataValue(), - RecordValue recordValue => recordValue.ToDataValue(), + BooleanValue booleanValue => BooleanDataValue.Create(booleanValue.Value), + DecimalValue decimalValue => NumberDataValue.Create(decimalValue.Value), + NumberValue numberValue => FloatDataValue.Create(numberValue.Value), + DateValue dateValue => DateDataValue.Create(dateValue.GetConvertedValue(TimeZoneInfo.Utc)), + DateTimeValue datetimeValue => DateTimeDataValue.Create(datetimeValue.GetConvertedValue(TimeZoneInfo.Utc)), + TimeValue timeValue => TimeDataValue.Create(timeValue.Value), + StringValue stringValue => StringDataValue.Create(stringValue.Value), + BlankValue blankValue => DataValue.Blank(), + VoidValue voidValue => DataValue.Blank(), + RecordValue recordValue => recordValue.ToRecord(), + TableValue tableValue => tableValue.ToTable(), _ => throw new NotSupportedException($"Unsupported FormulaValue type: {value.GetType().Name}"), }; @@ -54,7 +96,7 @@ public static DataType GetDataType(this FormulaValue value) => _ => DataType.Unspecified, }; - public static DataType GetDataType(this FormulaType type) => + public static DataType ToDataType(this FormulaType type) => type switch { null => DataType.Blank, @@ -95,22 +137,10 @@ public static string Format(this FormulaValue value) => _ => $"[{value.GetType().Name}]", }; - public static FormulaValue NewBlank(this FormulaType? type) => FormulaValue.NewBlank(type ?? FormulaType.Blank); - - public static BooleanDataValue ToDataValue(this BooleanValue value) => BooleanDataValue.Create(value.Value); - public static NumberDataValue ToDataValue(this DecimalValue value) => NumberDataValue.Create(value.Value); - public static FloatDataValue ToDataValue(this NumberValue value) => FloatDataValue.Create(value.Value); - public static DateTimeDataValue ToDataValue(this DateTimeValue value) => DateTimeDataValue.Create(value.GetConvertedValue(TimeZoneInfo.Utc)); - public static DateDataValue ToDataValue(this DateValue value) => DateDataValue.Create(value.GetConvertedValue(TimeZoneInfo.Utc)); - public static TimeDataValue ToDataValue(this TimeValue value) => TimeDataValue.Create(value.Value); - public static DataValue ToDataValue(this BlankValue _) => BlankDataValue.Blank(); - public static DataValue ToDataValue(this VoidValue _) => BlankDataValue.Blank(); - public static StringDataValue ToDataValue(this StringValue value) => StringDataValue.Create(value.Value); - - public static TableDataValue ToDataValue(this TableValue value) => - TableDataValue.TableFromRecords(value.Rows.Select(row => row.Value.ToDataValue()).ToImmutableArray()); + public static TableDataValue ToTable(this TableValue value) => + TableDataValue.TableFromRecords(value.Rows.Select(row => row.Value.ToRecord()).ToImmutableArray()); - public static RecordDataValue ToDataValue(this RecordValue value) => + public static RecordDataValue ToRecord(this RecordValue value) => RecordDataValue.RecordFromFields(value.OriginalFields.Select(field => field.GetKeyValuePair()).ToImmutableArray()); public static RecordDataType ToDataType(this RecordType record) @@ -118,7 +148,7 @@ public static RecordDataType ToDataType(this RecordType record) RecordDataType recordType = new(); foreach (string fieldName in record.FieldNames) { - recordType.Properties.Add(fieldName, PropertyInfo.Create(record.GetFieldType(fieldName).GetDataType())); + recordType.Properties.Add(fieldName, PropertyInfo.Create(record.GetFieldType(fieldName).ToDataType())); } return recordType; } @@ -128,11 +158,87 @@ public static TableDataType ToDataType(this TableType table) TableDataType tableType = new(); foreach (string fieldName in table.FieldNames) { - tableType.Properties.Add(fieldName, PropertyInfo.Create(table.GetFieldType(fieldName).GetDataType())); + tableType.Properties.Add(fieldName, PropertyInfo.Create(table.GetFieldType(fieldName).ToDataType())); } return tableType; } + private static RecordType ToRecordType(this Type? type) + { + RecordType recordType = RecordType.Empty(); + + if (type is not null) + { +#pragma warning disable IL2070 // might not behave correctly in a trimmed deployment. // %%% REFLECTION + foreach ((string Name, Type Type) property in + type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(property => (property.Name, property.PropertyType))) +#pragma warning restore IL2070 // might not behave correctly in a trimmed deployment. + { + recordType.Add(property.Name, property.Type.ToFormulaType()); + } + } + + return recordType; + } + + private static RecordValue ToRecordValue(this object value, Type? type) + { + type ??= value.GetType(); + + if (value is not RecordValue recordValue) + { +#pragma warning disable IL2070 // might not behave correctly in a trimmed deployment. // %%% REFLECTION + IEnumerable propertyValues = + type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(property => new NamedValue(property.Name, property.GetValue(value).ToFormulaValue())); +#pragma warning restore IL2070 // might not behave correctly in a trimmed deployment. + recordValue = FormulaValue.NewRecordFromFields(propertyValues); + } + + return recordValue; + } + + private static TableType ToTableType(this Type type) + { + TableType tableType = TableType.Empty(); + + Type? elementType = type.GetElementType() ?? type.GetGenericArguments().FirstOrDefault(); + if (elementType is not null) + { +#pragma warning disable IL2070 // might not behave correctly in a trimmed deployment. // %%% REFLECTION + foreach ((string Name, Type Type) property in + type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(property => (property.Name, property.PropertyType))) +#pragma warning restore IL2070 // might not behave correctly in a trimmed deployment. + { + tableType.Add(property.Name, property.Type.ToFormulaType()); + } + } + + return tableType; + } + + private static TableValue ToTableValue(this IEnumerable value, Type type) + { + Type? elementType = type.GetElementType() ?? type.GetGenericArguments().FirstOrDefault(); + + if (type is null) + { + return FormulaValue.NewTable(RecordType.EmptySealed()); + } + + return FormulaValue.NewTable(elementType.ToRecordType(), GetRecords()); + + IEnumerable GetRecords() + { + foreach (object elementValue in value) + { + yield return elementValue.ToRecordValue(elementType); + } + } + } + private static KeyValuePair GetKeyValuePair(this NamedValue value) => new(value.Name, value.Value.ToDataValue()); public static JsonNode ToJson(this FormulaValue value) => diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs deleted file mode 100644 index 64e93d1b0c..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.PowerFx; -using Microsoft.Bot.ObjectModel; - -namespace Microsoft.Agents.Workflows.Declarative.Extensions; - -internal static class IWorkflowContextExtensions -{ - private const string ScopesKey = "__workflow__"; - - public static async Task GetScopedStateAsync(this IWorkflowContext context, CancellationToken cancellationToken) - { - IEnumerable> readTasks = - VariableScopeNames.AllScopes.Select( - scopeName => context.ReadStateAsync(scopeName, ScopesKey).AsTask()); - - WorkflowScope?[] scopes = await Task.WhenAll(readTasks).ConfigureAwait(false); - Dictionary scopesMap = scopes.OfType().ToDictionary(scope => scope!.Name, scope => scope); - - return new WorkflowScopes(VariableScopeNames.AllScopes.ToDictionary(scopeName => scopeName, scopeName => GetScope(scopeName))); - - WorkflowScope GetScope(string scopeName) - { - if (!scopesMap.TryGetValue(scopeName, out WorkflowScope? scope)) - { - scope = new WorkflowScope(scopeName); - } - return scope; - } - } - - public static async Task SetScopedStateAsync(this IWorkflowContext context, WorkflowScopes scopes, CancellationToken cancellationToken) - { - IEnumerable writeTasks = scopes.Select(scope => context.QueueStateUpdateAsync(scope.Name, scope, ScopesKey).AsTask()); - - await Task.WhenAll(writeTasks).ConfigureAwait(false); - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs index 32ceaf074e..4ee2c97f20 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -1,23 +1,25 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Extensions; -using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Agents.Workflows.Reflection; using Microsoft.Bot.ObjectModel; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.Declarative.Interpreter; -internal sealed record class ExecutionResultMessage(string ExecutorId, object? Result = null); +internal sealed record class DeclarativeExecutorResult(string ExecutorId, object? Result = null); -internal abstract class DeclarativeActionExecutor(TAction model) : - WorkflowActionExecutor(model) +internal abstract class DeclarativeActionExecutor(TAction model, DeclarativeWorkflowState state) : + WorkflowActionExecutor(model, state) where TAction : DialogAction { public new TAction Model => (TAction)base.Model; @@ -25,14 +27,20 @@ internal abstract class DeclarativeActionExecutor(TAction model) : internal abstract class WorkflowActionExecutor : ReflectingExecutor, - IMessageHandler + IMessageHandler { public const string RootActionId = "(root)"; + private static readonly ImmutableHashSet s_mutableScopes = + new HashSet + { + VariableScopeNames.Topic, + VariableScopeNames.Global, + }.ToImmutableHashSet(); + private string? _parentId; - private DeclarativeWorkflowState? _state; - protected WorkflowActionExecutor(DialogAction model) + protected WorkflowActionExecutor(DialogAction model, DeclarativeWorkflowState state) : base(model.Id.Value) { if (!model.HasRequiredProperties) @@ -41,6 +49,7 @@ protected WorkflowActionExecutor(DialogAction model) } this.Model = model; + this.State = state; } public DialogAction Model { get; } @@ -49,16 +58,10 @@ protected WorkflowActionExecutor(DialogAction model) internal ILogger Logger { get; set; } = NullLogger.Instance; - internal DeclarativeWorkflowOptions? Options { get; set; } - - protected DeclarativeWorkflowState State - { - get => this._state ?? throw new WorkflowExecutionException("Context not assigned"); - private set { this._state = value; } - } + protected DeclarativeWorkflowState State { get; } /// - public async ValueTask HandleAsync(ExecutionResultMessage message, IWorkflowContext context) + public async ValueTask HandleAsync(DeclarativeExecutorResult message, IWorkflowContext context) { if (this.Model.Disabled) { @@ -66,15 +69,11 @@ public async ValueTask HandleAsync(ExecutionResultMessage message, IWorkflowCont return; } - WorkflowScopes scopes = await context.GetScopedStateAsync(default).ConfigureAwait(false); - this.State = new DeclarativeWorkflowState(this.Options.CreateRecalcEngine(), scopes); - try { object? result = await this.ExecuteAsync(context, cancellationToken: default).ConfigureAwait(false); - await context.SetScopedStateAsync(scopes, default).ConfigureAwait(false); - await context.SendMessageAsync(new ExecutionResultMessage(this.Id, result)).ConfigureAwait(false); + await context.SendMessageAsync(new DeclarativeExecutorResult(this.Id, result)).ConfigureAwait(false); } catch (WorkflowExecutionException exception) { @@ -90,15 +89,14 @@ public async ValueTask HandleAsync(ExecutionResultMessage message, IWorkflowCont protected abstract ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default); - protected void AssignTarget(PropertyPath targetPath, FormulaValue result) + protected async ValueTask AssignAsync(PropertyPath targetPath, FormulaValue result, IWorkflowContext context) { - if (!VariableScopeNames.Global.Equals(targetPath.VariableScopeName, StringComparison.OrdinalIgnoreCase) && - !VariableScopeNames.Topic.Equals(targetPath.VariableScopeName, StringComparison.OrdinalIgnoreCase)) + if (!s_mutableScopes.Contains(Throw.IfNull(targetPath.VariableScopeName))) { - throw new UnsupportedVariableException($"Unsupported variable scope: {targetPath.VariableScopeName}"); + throw new InvalidScopeException($"Invalid scope: {targetPath.VariableScopeName}"); } - this.State.Set(targetPath, result); + await this.State.SetAsync(targetPath, result, context).ConfigureAwait(false); #if DEBUG string? resultValue = result.Format(); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs index e442b63479..573e931f9c 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs @@ -2,10 +2,8 @@ using System; using System.Threading.Tasks; -using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Agents.Workflows.Reflection; -using Microsoft.Bot.ObjectModel; using Microsoft.Extensions.AI; namespace Microsoft.Agents.Workflows.Declarative.Interpreter; @@ -13,19 +11,16 @@ namespace Microsoft.Agents.Workflows.Declarative.Interpreter; /// /// The root executor for a declarative workflow. /// -internal sealed class DeclarativeWorkflowExecutor(string workflowId, AdaptiveDialog workflowElement, Func inputTransform) : +internal sealed class DeclarativeWorkflowExecutor(string workflowId, DeclarativeWorkflowState state, Func inputTransform) : ReflectingExecutor>(workflowId), IMessageHandler where TInput : notnull { public async ValueTask HandleAsync(TInput message, IWorkflowContext context) { - WorkflowScopes scopes = await context.GetScopedStateAsync(default).ConfigureAwait(false); - ChatMessage input = inputTransform.Invoke(message); - scopes.Initialize(workflowElement, input); + await state.SetLastMessageAsync(context, input).ConfigureAwait(false); - await context.SetScopedStateAsync(scopes, default).ConfigureAwait(false); - await context.SendMessageAsync(new ExecutionResultMessage(this.Id)).ConfigureAwait(false); + await context.SendMessageAsync(new DeclarativeExecutorResult(this.Id)).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs index 2365dc8833..2578ce5194 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs @@ -1,6 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; @@ -12,6 +17,14 @@ namespace Microsoft.Agents.Workflows.Declarative.Interpreter; internal sealed class DeclarativeWorkflowState { + private static readonly ImmutableHashSet s_mutableScopes = + new HashSet + { + VariableScopeNames.Topic, + VariableScopeNames.Global, + VariableScopeNames.System, + }.ToImmutableHashSet(); + private readonly RecalcEngine _engine; private readonly WorkflowScopes _scopes; private WorkflowExpressionEngine? _expressionEngine; @@ -48,17 +61,38 @@ public FormulaValue Get(PropertyPath variablePath) => public FormulaValue Get(string scope, string varName) => this._scopes.Get(varName, scope); - public void Set(PropertyPath variablePath, FormulaValue value) => - this.Set(Throw.IfNull(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName), value); + public ValueTask SetAsync(PropertyPath variablePath, FormulaValue value, IWorkflowContext context) => + this.SetAsync(Throw.IfNull(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName), value, context); - public void Set(string scopeName, string varName, FormulaValue value) + public async ValueTask SetAsync(string scopeName, string varName, FormulaValue value, IWorkflowContext context) { - this._scopes.Set(varName, scopeName, value); + if (!s_mutableScopes.Contains(scopeName)) + { + throw new InvalidScopeException($"Invalid scope: {scopeName}"); + } + this._scopes.Set(varName, value, scopeName); this._scopes.Bind(this._engine, scopeName); + + await context.QueueStateUpdateAsync(varName, value.ToObject(), scopeName).ConfigureAwait(false); } public string? Format(IEnumerable template) => this._engine.Format(template); public string? Format(TemplateLine? line) => this._engine.Format(line); + + public async ValueTask RestoreAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + await Task.WhenAll(s_mutableScopes.Select(scopeName => ReadScopeAsync(scopeName).AsTask())).ConfigureAwait(false); + + async ValueTask ReadScopeAsync(string scopeName) + { + HashSet keys = await context.ReadStateKeysAsync(scopeName).ConfigureAwait(false); + foreach (string key in keys) + { + object? value = await context.ReadStateAsync(key, scopeName).ConfigureAwait(false); + this._scopes.Set(key, value.ToFormulaValue(), scopeName); + } + } + } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs index 5a557bc649..7bcf0b9868 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs @@ -1,39 +1,30 @@ // Copyright (c) Microsoft. All rights reserved. -using System; +using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Reflection; namespace Microsoft.Agents.Workflows.Declarative.Interpreter; -internal sealed class DelegateActionExecutor : ReflectingExecutor, IMessageHandler -{ - private readonly Func? _action; +internal delegate ValueTask DelegateAction(IWorkflowContext context, CancellationToken cancellationToken); - public DelegateActionExecutor(string actionId, Action? action = null) - : this(actionId, - action is null ? - null : - () => - { - action?.Invoke(); - return default; - }) - { } +internal sealed class DelegateActionExecutor : ReflectingExecutor, IMessageHandler +{ + private readonly DelegateAction? _action; - public DelegateActionExecutor(string actionId, Func? action = null) + public DelegateActionExecutor(string actionId, DelegateAction? action = null) : base(actionId) { this._action = action; } - public async ValueTask HandleAsync(ExecutionResultMessage message, IWorkflowContext context) + public async ValueTask HandleAsync(DeclarativeExecutorResult message, IWorkflowContext context) { if (this._action is not null) { - await this._action.Invoke().ConfigureAwait(false); + await this._action.Invoke(context, default).ConfigureAwait(false); } - await context.SendMessageAsync(new ExecutionResultMessage(this.Id)).ConfigureAwait(false); + await context.SendMessageAsync(new DeclarativeExecutorResult(this.Id)).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 28dc246b04..d1fb71ae50 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -13,15 +13,18 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor { private readonly WorkflowBuilder _workflowBuilder; private readonly DeclarativeWorkflowModel _workflowModel; - private readonly DeclarativeWorkflowOptions _options; + private readonly DeclarativeWorkflowOptions _workflowOptions; + private readonly DeclarativeWorkflowState _workflowState; public WorkflowActionVisitor( Executor rootAction, - DeclarativeWorkflowOptions workflowContext) + DeclarativeWorkflowState state, + DeclarativeWorkflowOptions options) { - this._workflowModel = new DeclarativeWorkflowModel(rootAction); this._workflowBuilder = new WorkflowBuilder(rootAction); - this._options = workflowContext; + this._workflowModel = new DeclarativeWorkflowModel(rootAction); + this._workflowOptions = options; + this._workflowState = state; } public bool HasUnsupportedActions { get; private set; } @@ -93,7 +96,7 @@ protected override void Visit(ConditionGroup item) { this.Trace(item); - ConditionGroupExecutor action = new(item); + ConditionGroupExecutor action = new(item, this._workflowState); this.ContinueWith(action); this.ContinuationFor(action.Id, action.ParentId); @@ -134,10 +137,10 @@ protected override void Visit(Foreach item) { this.Trace(item); - ForeachExecutor action = new(item); + ForeachExecutor action = new(item, this._workflowState); string loopId = ForeachExecutor.Steps.Next(action.Id); this.ContinueWith(action, condition: null, CompletionHandler); // Foreach - this.ContinueWith(this.CreateStep(loopId, action.TakeNext), action.Id); // Loop Increment + this.ContinueWith(this.CreateStep(loopId, action.TakeNextAsync), action.Id); // Loop Increment string continuationId = this.ContinuationFor(action.Id, action.ParentId); // Action continuation this._workflowModel.AddLink(loopId, continuationId, (_) => !action.HasValue); DelegateActionExecutor startAction = this.CreateStep(ForeachExecutor.Steps.Start(action.Id)); // Action start @@ -147,7 +150,7 @@ protected override void Visit(Foreach item) void CompletionHandler() { string endActionsId = ForeachExecutor.Steps.End(action.Id); // Loop continuation - this.ContinueWith(this.CreateStep(endActionsId, action.Reset), action.Id); + this.ContinueWith(this.CreateStep(endActionsId, action.ResetAsync), action.Id); this._workflowModel.AddLink(endActionsId, loopId); } } @@ -193,63 +196,63 @@ protected override void Visit(AnswerQuestionWithAI item) { this.Trace(item); - this.ContinueWith(new AnswerQuestionWithAIExecutor(item, this._options.AgentProvider)); + this.ContinueWith(new AnswerQuestionWithAIExecutor(item, this._workflowOptions.AgentProvider, this._workflowState)); } protected override void Visit(SetVariable item) { this.Trace(item); - this.ContinueWith(new SetVariableExecutor(item)); + this.ContinueWith(new SetVariableExecutor(item, this._workflowState)); } protected override void Visit(SetTextVariable item) { this.Trace(item); - this.ContinueWith(new SetTextVariableExecutor(item)); + this.ContinueWith(new SetTextVariableExecutor(item, this._workflowState)); } protected override void Visit(ClearAllVariables item) { this.Trace(item); - this.ContinueWith(new ClearAllVariablesExecutor(item)); + this.ContinueWith(new ClearAllVariablesExecutor(item, this._workflowState)); } protected override void Visit(ResetVariable item) { this.Trace(item); - this.ContinueWith(new ResetVariableExecutor(item)); + this.ContinueWith(new ResetVariableExecutor(item, this._workflowState)); } protected override void Visit(EditTable item) { this.Trace(item); - this.ContinueWith(new EditTableExecutor(item)); + this.ContinueWith(new EditTableExecutor(item, this._workflowState)); } protected override void Visit(EditTableV2 item) { this.Trace(item); - this.ContinueWith(new EditTableV2Executor(item)); + this.ContinueWith(new EditTableV2Executor(item, this._workflowState)); } protected override void Visit(ParseValue item) { this.Trace(item); - this.ContinueWith(new ParseValueExecutor(item)); + this.ContinueWith(new ParseValueExecutor(item, this._workflowState)); } protected override void Visit(SendActivity item) { this.Trace(item); - this.ContinueWith(new SendActivityExecutor(item)); + this.ContinueWith(new SendActivityExecutor(item, this._workflowState)); } #region Not supported @@ -436,8 +439,7 @@ private void ContinueWith( Func? condition = null, Action? completionHandler = null) { - executor.Logger = this._options.LoggerFactory.CreateLogger(executor.Id); - executor.Options = this._options; + executor.Logger = this._workflowOptions.LoggerFactory.CreateLogger(executor.Id); this.ContinueWith(executor, executor.ParentId, condition, completionHandler); } @@ -471,7 +473,7 @@ private string ContinuationFor(string actionId, string parentId) private void RestartAfter(string actionId, string parentId) => this._workflowModel.AddNode(this.CreateStep($"{actionId}_Continue"), parentId); - private DelegateActionExecutor CreateStep(string actionId, Action? stepAction = null) + private DelegateActionExecutor CreateStep(string actionId, DelegateAction? stepAction = null) { DelegateActionExecutor stepExecutor = new(actionId, stepAction); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs index 2d38878216..ecac09e83f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs @@ -16,7 +16,8 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; -internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, WorkflowAgentProvider agentProvider) : DeclarativeActionExecutor(model) +internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, WorkflowAgentProvider agentProvider, DeclarativeWorkflowState state) + : DeclarativeActionExecutor(model, state) { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { @@ -86,7 +87,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, W AgentRunResponse agentResponse = agentResponseUpdates.ToAgentRunResponse(); ChatMessage response = agentResponse.Messages.Last(); - this.State.SetLastMessage(response); + await this.State.SetLastMessageAsync(context, response).ConfigureAwait(false); if (this.Model.AutoSend) { await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(response, agentResponse.Usage)).ConfigureAwait(false); @@ -97,18 +98,18 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, W { if (this.Model.AutoSend) // ISSUE #485: Conversation implicitly managed until updated OM is available. { - this.State.SetConversationId(conversationId); + await this.State.SetConversationIdAsync(context, conversationId).ConfigureAwait(false); } else { - this.State.SetInternalConversationId(conversationId); + await this.State.SetInternalConversationIdAsync(context, conversationId).ConfigureAwait(false); } } PropertyPath? variablePath = this.Model.Variable?.Path; if (variablePath is not null) { - this.AssignTarget(variablePath, response.ToRecord()); + await this.AssignAsync(variablePath, response.ToRecord(), context).ConfigureAwait(false); } return default; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs index 547fb3fc3e..080a985db4 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs @@ -10,7 +10,8 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; -internal sealed class ClearAllVariablesExecutor(ClearAllVariables model) : DeclarativeActionExecutor(model) +internal sealed class ClearAllVariablesExecutor(ClearAllVariables model, DeclarativeWorkflowState state) + : DeclarativeActionExecutor(model, state) { protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs index 5ba73fc40b..a3cbfb9ff4 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs @@ -26,14 +26,14 @@ public static string Item(ConditionGroup model, ConditionItem conditionItem) public static string Else(ConditionGroup model) => model.ElseActions.Id.Value ?? $"{model.Id}_Else"; } - public ConditionGroupExecutor(ConditionGroup model) - : base(model) + public ConditionGroupExecutor(ConditionGroup model, DeclarativeWorkflowState state) + : base(model, state) { } public bool IsMatch(ConditionItem conditionItem, object? result) { - if (result is not ExecutionResultMessage message) + if (result is not DeclarativeExecutorResult message) { return false; } @@ -43,7 +43,7 @@ public bool IsMatch(ConditionItem conditionItem, object? result) public bool IsElse(object? result) { - if (result is not ExecutionResultMessage message) + if (result is not DeclarativeExecutorResult message) { return false; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs index 07b9cfd44d..2c41859309 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs @@ -13,7 +13,7 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; -internal sealed class EditTableExecutor(EditTable model) : DeclarativeActionExecutor(model) +internal sealed class EditTableExecutor(EditTable model, DeclarativeWorkflowState state) : DeclarativeActionExecutor(model, state) { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { @@ -33,7 +33,7 @@ internal sealed class EditTableExecutor(EditTable model) : DeclarativeActionExec EvaluationResult addResult = this.State.ExpressionEngine.GetValue(addItemValue); RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), addResult.Value.ToFormulaValue()); await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); - this.AssignTarget(variablePath, newRecord); + await this.AssignAsync(variablePath, newRecord, context).ConfigureAwait(false); break; case TableChangeType.Remove: ValueExpression removeItemValue = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); @@ -41,19 +41,19 @@ internal sealed class EditTableExecutor(EditTable model) : DeclarativeActionExec if (removeResult.Value is TableDataValue removeItemTable) { await tableValue.RemoveAsync(removeItemTable?.Values.Select(row => row.ToRecordValue()), all: true, cancellationToken).ConfigureAwait(false); - this.AssignTarget(variablePath, RecordValue.Empty()); + await this.AssignAsync(variablePath, RecordValue.Empty(), context).ConfigureAwait(false); } break; case TableChangeType.Clear: await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false); - this.AssignTarget(variablePath, FormulaValue.NewBlank()); + await this.AssignAsync(variablePath, FormulaValue.NewBlank(), context).ConfigureAwait(false); break; case TableChangeType.TakeFirst: RecordValue? firstRow = tableValue.Rows.FirstOrDefault()?.Value; if (firstRow is not null) { await tableValue.RemoveAsync([firstRow], all: true, cancellationToken).ConfigureAwait(false); - this.AssignTarget(variablePath, firstRow); + await this.AssignAsync(variablePath, firstRow, context).ConfigureAwait(false); } break; case TableChangeType.TakeLast: @@ -61,7 +61,7 @@ internal sealed class EditTableExecutor(EditTable model) : DeclarativeActionExec if (lastRow is not null) { await tableValue.RemoveAsync([lastRow], all: true, cancellationToken).ConfigureAwait(false); - this.AssignTarget(variablePath, lastRow); + await this.AssignAsync(variablePath, lastRow, context).ConfigureAwait(false); } break; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs index 9a84f450eb..60fc91780a 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs @@ -13,7 +13,7 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; -internal sealed class EditTableV2Executor(EditTableV2 model) : DeclarativeActionExecutor(model) +internal sealed class EditTableV2Executor(EditTableV2 model, DeclarativeWorkflowState state) : DeclarativeActionExecutor(model, state) { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { @@ -32,12 +32,12 @@ internal sealed class EditTableV2Executor(EditTableV2 model) : DeclarativeAction EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(addItemValue); RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), expressionResult.Value.ToFormulaValue()); await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); - this.AssignTarget(variablePath, newRecord); + await this.AssignAsync(variablePath, newRecord, context).ConfigureAwait(false); } else if (changeType is ClearItemsOperation) { await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false); - this.AssignTarget(variablePath, FormulaValue.NewBlank()); + await this.AssignAsync(variablePath, FormulaValue.NewBlank(), context).ConfigureAwait(false); } else if (changeType is RemoveItemOperation removeItemOperation) { @@ -46,7 +46,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model) : DeclarativeAction if (expressionResult.Value.ToFormulaValue() is TableValue removeItemTable) { await tableValue.RemoveAsync(removeItemTable?.Rows.Select(row => row.Value), all: true, cancellationToken).ConfigureAwait(false); - this.AssignTarget(variablePath, FormulaValue.NewBlank()); + await this.AssignAsync(variablePath, FormulaValue.NewBlank(), context).ConfigureAwait(false); } } else if (changeType is TakeLastItemOperation) @@ -55,7 +55,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model) : DeclarativeAction if (lastRow is not null) { await tableValue.RemoveAsync([lastRow], all: true, cancellationToken).ConfigureAwait(false); - this.AssignTarget(variablePath, lastRow); + await this.AssignAsync(variablePath, lastRow, context).ConfigureAwait(false); } } else if (changeType is TakeFirstItemOperation) @@ -64,7 +64,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model) : DeclarativeAction if (firstRow is not null) { await tableValue.RemoveAsync([firstRow], all: true, cancellationToken).ConfigureAwait(false); - this.AssignTarget(variablePath, firstRow); + await this.AssignAsync(variablePath, firstRow, context).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs index 5c6580d855..8d3850cdf5 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ForeachExecutor.cs @@ -24,15 +24,15 @@ public static class Steps private int _index; private FormulaValue[] _values; - public ForeachExecutor(Foreach model) - : base(model) + public ForeachExecutor(Foreach model, DeclarativeWorkflowState state) + : base(model, state) { this._values = []; } public bool HasValue { get; private set; } - protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { this._index = 0; @@ -54,29 +54,29 @@ public ForeachExecutor(Foreach model) } } - this.Reset(); + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); return default; } - public void TakeNext() + public async ValueTask TakeNextAsync(IWorkflowContext context, CancellationToken cancellationToken) { if (this.HasValue = this._index < this._values.Length) { FormulaValue value = this._values[this._index]; - this.State.Set(Throw.IfNull(this.Model.Value), value); + await this.State.SetAsync(Throw.IfNull(this.Model.Value), value, context).ConfigureAwait(false); if (this.Model.Index is not null) { - this.State.Set(this.Model.Index.Path, FormulaValue.New(this._index)); + await this.State.SetAsync(this.Model.Index.Path, FormulaValue.New(this._index), context).ConfigureAwait(false); } this._index++; } } - public void Reset() + public async ValueTask ResetAsync(IWorkflowContext context, CancellationToken cancellationToken) { this.State.Reset(Throw.IfNull(this.Model.Value)); if (this.Model.Index is not null) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs index 51caec89f8..6104388a38 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs @@ -13,10 +13,10 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; -internal sealed class ParseValueExecutor(ParseValue model) : - DeclarativeActionExecutor(model) +internal sealed class ParseValueExecutor(ParseValue model, DeclarativeWorkflowState state) : + DeclarativeActionExecutor(model, state) { - protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); ValueExpression valueExpression = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}"); @@ -54,7 +54,7 @@ internal sealed class ParseValueExecutor(ParseValue model) : throw new WorkflowExecutionException($"Unable to parse {expressionResult.Value.GetType().Name}"); } - this.AssignTarget(variablePath, parsedResult); + await this.AssignAsync(variablePath, parsedResult, context).ConfigureAwait(false); return default; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs index bc6dfe8e57..953233762c 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs @@ -10,8 +10,8 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; -internal sealed class ResetVariableExecutor(ResetVariable model) : - DeclarativeActionExecutor(model) +internal sealed class ResetVariableExecutor(ResetVariable model, DeclarativeWorkflowState state) : + DeclarativeActionExecutor(model, state) { protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs index 78a552fdb8..a4c7981e88 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs @@ -9,8 +9,8 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; -internal sealed class SendActivityExecutor(SendActivity model) : - DeclarativeActionExecutor(model) +internal sealed class SendActivityExecutor(SendActivity model, DeclarativeWorkflowState state) : + DeclarativeActionExecutor(model, state) { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs index 7ed14802d9..2ee97a7b25 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetTextVariableExecutor.cs @@ -9,21 +9,22 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; -internal sealed class SetTextVariableExecutor(SetTextVariable model) : DeclarativeActionExecutor(model) +internal sealed class SetTextVariableExecutor(SetTextVariable model, DeclarativeWorkflowState state) + : DeclarativeActionExecutor(model, state) { - protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); if (this.Model.Value is null) { - this.AssignTarget(variablePath, FormulaValue.NewBlank()); + await this.AssignAsync(variablePath, FormulaValue.NewBlank(), context).ConfigureAwait(false); } else { FormulaValue expressionResult = FormulaValue.New(this.State.Format(this.Model.Value)); - this.AssignTarget(variablePath, expressionResult); + await this.AssignAsync(variablePath, expressionResult, context).ConfigureAwait(false); } return default; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs index 4ebf29806c..e1c6ec8857 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SetVariableExecutor.cs @@ -11,21 +11,22 @@ namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; -internal sealed class SetVariableExecutor(SetVariable model) : DeclarativeActionExecutor(model) +internal sealed class SetVariableExecutor(SetVariable model, DeclarativeWorkflowState state) + : DeclarativeActionExecutor(model, state) { - protected override ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}"); if (this.Model.Value is null) { - this.AssignTarget(variablePath, FormulaValue.NewBlank()); + await this.AssignAsync(variablePath, FormulaValue.NewBlank(), context).ConfigureAwait(false); } else { EvaluationResult expressionResult = this.State.ExpressionEngine.GetValue(this.Model.Value); - this.AssignTarget(variablePath, expressionResult.Value.ToFormulaValue()); + await this.AssignAsync(variablePath, expressionResult.Value.ToFormulaValue(), context).ConfigureAwait(false); } return default; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs index 30372c4650..f025164eae 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/RecalcEngineFactory.cs @@ -16,7 +16,7 @@ public static RecalcEngine Create( foreach (string scopeName in VariableScopeNames.AllScopes) { - engine.UpdateVariable(scopeName, FormulaValue.NewBlank()); + engine.UpdateVariable(scopeName, RecordValue.Empty()); } return engine; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs index 7b8513ebdb..83096bcb13 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/SystemScope.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Globalization; +using System.Threading.Tasks; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Bot.ObjectModel; @@ -47,49 +48,49 @@ public static IEnumerable GetNames() yield return Names.UserLanguage; } - public static void InitializeSystem(this WorkflowScopes scopes, ChatMessage inputMessage) + public static void InitializeSystem(this WorkflowScopes scopes) { - scopes.Set(Names.Activity, VariableScopeNames.System, RecordValue.Empty()); - scopes.Set(Names.Bot, VariableScopeNames.System, RecordValue.Empty()); + scopes.Set(Names.Activity, RecordValue.Empty(), VariableScopeNames.System); + scopes.Set(Names.Bot, RecordValue.Empty(), VariableScopeNames.System); - scopes.Set(Names.LastMessage, VariableScopeNames.System, inputMessage.ToRecord()); - Set(Names.LastMessageId, inputMessage.MessageId); - Set(Names.LastMessageText, inputMessage.Text); + scopes.Set(Names.LastMessage, FormulaType.String.NewBlank(), VariableScopeNames.System); + Set(Names.LastMessageId); + Set(Names.LastMessageText); scopes.Set( Names.Conversation, - VariableScopeNames.System, RecordValue.NewRecordFromFields( new NamedValue("Id", FormulaType.String.NewBlank()), new NamedValue("LocalTimeZone", FormulaValue.New(TimeZoneInfo.Local.StandardName)), new NamedValue("LocalTimeZoneOffset", FormulaValue.New(TimeZoneInfo.Local.GetUtcOffset(DateTime.UtcNow))), - new NamedValue("InTestMode", FormulaValue.New(false)))); - scopes.Set(Names.ConversationId, VariableScopeNames.System, FormulaType.String.NewBlank()); - scopes.Set(Names.InternalId, VariableScopeNames.System, FormulaType.String.NewBlank()); + new NamedValue("InTestMode", FormulaValue.New(false))), + VariableScopeNames.System); + scopes.Set(Names.ConversationId, FormulaType.String.NewBlank(), VariableScopeNames.System); + scopes.Set(Names.InternalId, FormulaType.String.NewBlank(), VariableScopeNames.System); scopes.Set( Names.Recognizer, - VariableScopeNames.System, RecordValue.NewRecordFromFields( new NamedValue("Id", FormulaType.String.NewBlank()), - new NamedValue("Text", FormulaType.String.NewBlank()))); + new NamedValue("Text", FormulaType.String.NewBlank())), + VariableScopeNames.System); scopes.Set( Names.User, - VariableScopeNames.System, RecordValue.NewRecordFromFields( - new NamedValue("Language", StringValue.New(CultureInfo.CurrentCulture.TwoLetterISOLanguageName)))); - scopes.Set(Names.UserLanguage, VariableScopeNames.System, StringValue.New(CultureInfo.CurrentCulture.TwoLetterISOLanguageName)); + new NamedValue("Language", StringValue.New(CultureInfo.CurrentCulture.TwoLetterISOLanguageName))), + VariableScopeNames.System); + scopes.Set(Names.UserLanguage, StringValue.New(CultureInfo.CurrentCulture.TwoLetterISOLanguageName), VariableScopeNames.System); - void Set(string key, string? value) + void Set(string key, string? value = null) { if (string.IsNullOrEmpty(value)) { - scopes.Set(key, VariableScopeNames.System, FormulaType.String.NewBlank()); + scopes.Set(key, FormulaType.String.NewBlank(), VariableScopeNames.System); } else { - scopes.Set(key, VariableScopeNames.System, FormulaValue.New(value)); + scopes.Set(key, FormulaValue.New(value), VariableScopeNames.System); } } } @@ -97,31 +98,24 @@ void Set(string key, string? value) public static FormulaValue GetConversationId(this DeclarativeWorkflowState state) => state.Get(VariableScopeNames.System, Names.ConversationId); - public static void SetConversationId(this DeclarativeWorkflowState state, string conversationId) + public static async ValueTask SetConversationIdAsync(this DeclarativeWorkflowState state, IWorkflowContext context, string conversationId) { RecordValue conversation = (RecordValue)state.Get(VariableScopeNames.System, Names.Conversation); conversation.UpdateField("Id", FormulaValue.New(conversationId)); - state.Set(VariableScopeNames.System, Names.Conversation, conversation); - state.Set(VariableScopeNames.System, Names.ConversationId, FormulaValue.New(conversationId)); + await state.SetAsync(VariableScopeNames.System, Names.Conversation, conversation, context).ConfigureAwait(false); + await state.SetAsync(VariableScopeNames.System, Names.ConversationId, FormulaValue.New(conversationId), context).ConfigureAwait(false); } public static FormulaValue GetInternalConversationId(this DeclarativeWorkflowState state) => state.Get(VariableScopeNames.System, Names.InternalId); - public static void SetInternalConversationId(this DeclarativeWorkflowState state, string conversationId) => - state.Set(VariableScopeNames.System, Names.InternalId, FormulaValue.New(conversationId)); + public static ValueTask SetInternalConversationIdAsync(this DeclarativeWorkflowState state, IWorkflowContext context, string conversationId) => + state.SetAsync(VariableScopeNames.System, Names.InternalId, FormulaValue.New(conversationId), context); - public static void SetLastMessage(this WorkflowScopes scopes, ChatMessage message) + public static async ValueTask SetLastMessageAsync(this DeclarativeWorkflowState state, IWorkflowContext context, ChatMessage message) { - scopes.Set(Names.LastMessage, VariableScopeNames.System, message.ToRecord()); - scopes.Set(Names.LastMessageId, VariableScopeNames.System, message.MessageId is null ? FormulaValue.NewBlank(FormulaType.String) : FormulaValue.New(message.MessageId)); - scopes.Set(Names.LastMessageText, VariableScopeNames.System, FormulaValue.New(message.Text)); - } - - public static void SetLastMessage(this DeclarativeWorkflowState state, ChatMessage message) - { - state.Set(VariableScopeNames.System, Names.LastMessage, message.ToRecord()); - state.Set(VariableScopeNames.System, Names.LastMessageId, message.MessageId is null ? FormulaValue.NewBlank(FormulaType.String) : FormulaValue.New(message.MessageId)); - state.Set(VariableScopeNames.System, Names.LastMessageText, FormulaValue.New(message.Text)); + await state.SetAsync(VariableScopeNames.System, Names.LastMessage, message.ToRecord(), context).ConfigureAwait(false); + await state.SetAsync(VariableScopeNames.System, Names.LastMessageId, message.MessageId is null ? FormulaValue.NewBlank(FormulaType.String) : FormulaValue.New(message.MessageId), context).ConfigureAwait(false); + await state.SetAsync(VariableScopeNames.System, Names.LastMessageText, FormulaValue.New(message.Text), context).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs index 1cd5487aa1..7a7d6dbf16 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs @@ -7,7 +7,6 @@ using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.Bot.ObjectModel.Analysis; using Microsoft.Bot.ObjectModel.PowerFx; -using Microsoft.Extensions.AI; using Microsoft.PowerFx.Types; namespace Microsoft.Agents.Workflows.Declarative.PowerFx; @@ -16,9 +15,9 @@ internal static class WorkflowDiagnostics { private static readonly WorkflowFeatureConfiguration s_semanticFeatureConfig = new(); - public static void Initialize(this WorkflowScopes scopes, AdaptiveDialog workflowElement, ChatMessage inputMessage) + public static void Initialize(this WorkflowScopes scopes, TElement workflowElement) where TElement : BotElement, IDialogBase { - scopes.InitializeSystem(inputMessage); + scopes.InitializeSystem(); SemanticModel semanticModel = workflowElement.GetSemanticModel(new PowerFxExpressionChecker(s_semanticFeatureConfig), s_semanticFeatureConfig); scopes.InitializeEnvironment(semanticModel); @@ -31,7 +30,7 @@ private static void InitializeEnvironment(this WorkflowScopes scopes, SemanticMo { string? environmentValue = Environment.GetEnvironmentVariable(variableName); FormulaValue variableValue = string.IsNullOrEmpty(environmentValue) ? FormulaType.String.NewBlank() : FormulaValue.New(environmentValue); - scopes.Set(variableName, VariableScopeNames.Environment, variableValue); + scopes.Set(variableName, variableValue, VariableScopeNames.Environment); } } @@ -54,7 +53,7 @@ private static void InitializeDefaults(this WorkflowScopes scopes, SemanticModel } } - scopes.Set(variableDiagnostic.Path.VariableName, variableDiagnostic.Path.VariableScopeName ?? WorkflowScopes.DefaultScopeName, defaultValue); + scopes.Set(variableDiagnostic.Path.VariableName, defaultValue, variableDiagnostic.Path.VariableScopeName ?? WorkflowScopes.DefaultScopeName); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs index 21b185846f..70b594e50d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs @@ -239,7 +239,7 @@ private EvaluationResult GetValue(EnumExpression try { - return new EvaluationResult(ObjectExpressionParser.Parse(formulaValue.ToDataValue()), expressionResult.Sensitivity); + return new EvaluationResult(ObjectExpressionParser.Parse(formulaValue.ToRecord()), expressionResult.Sensitivity); } catch (Exception exception) { @@ -282,7 +282,7 @@ private static ImmutableArray ParseArrayResults(FormulaValue val throw new InvalidExpressionOutputTypeException(value.GetDataType(), DataType.TableFromEnumerable()); } - TableDataValue tableDataValue = tableValue.ToDataValue(); + TableDataValue tableDataValue = tableValue.ToTable(); try { List list = []; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs index 47c9ccd600..67a2271418 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Linq; using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx.Types; @@ -14,6 +15,22 @@ internal sealed class WorkflowScope(string scopeName) : Dictionary scopeName; + public void Reset() + { + foreach (string variableName in this.Keys.ToArray()) + { + this.Reset(variableName); + } + } + + public void Reset(string variableName) + { + if (this.TryGetValue(variableName, out FormulaValue? value)) + { + this[variableName] = value.Type.NewBlank(); + } + } + public RecordValue BuildRecord() { return FormulaValue.NewRecordFromFields(GetFields()); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs index d25c58ac2a..55154c9047 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs @@ -1,10 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Collections; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; -using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx; using Microsoft.PowerFx.Types; @@ -14,7 +12,7 @@ namespace Microsoft.Agents.Workflows.Declarative.PowerFx; /// /// Contains all action scopes for a process. /// -internal sealed class WorkflowScopes : IEnumerable +internal sealed class WorkflowScopes { // ISSUE #488 - Update default scope for workflows to `Workflow` (instead of `Topic`) public const string DefaultScopeName = VariableScopeNames.Topic; @@ -36,9 +34,21 @@ WorkflowScope GetScope(string scopeName) } } - IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + public FormulaValue Get(string variableName, string? scopeName = null) + { + if (this._scopes[scopeName ?? WorkflowScopes.DefaultScopeName].TryGetValue(variableName, out FormulaValue? value)) + { + return value; + } + + return FormulaValue.NewBlank(); + } - public IEnumerator GetEnumerator() => this._scopes.Values.GetEnumerator(); + public void Clear(string scopeName) => this._scopes[scopeName].Reset(); + + public void Reset(string variableName, string? scopeName = null) => this._scopes[scopeName ?? WorkflowScopes.DefaultScopeName].Reset(variableName); + + public void Set(string variableName, FormulaValue value, string? scopeName = null) => this._scopes[scopeName ?? WorkflowScopes.DefaultScopeName][variableName] = value; public RecordValue BuildRecord(string scopeName) => this._scopes[scopeName].BuildRecord(); @@ -76,37 +86,4 @@ void Bind(string scopeName) engine.UpdateVariable(scopeName, scopeRecord); } } - - public FormulaValue Get(string variableName, string? scopeName = null) - { - if (this._scopes[scopeName ?? WorkflowScopes.DefaultScopeName].TryGetValue(variableName, out FormulaValue? value)) - { - return value; - } - - return FormulaValue.NewBlank(); - } - - public void Clear(string scopeName) - { - foreach (string variableName in this._scopes[scopeName].Keys.ToArray()) - { - FormulaType variableType = this._scopes[scopeName][variableName].Type; - this.Set(variableName, scopeName, variableType.NewBlank()); - } - } - - public void Reset(string variableName) => this.Reset(variableName, WorkflowScopes.DefaultScopeName); - - public void Reset(string variableName, string scopeName) - { - if (this._scopes[scopeName].TryGetValue(variableName, out FormulaValue? value)) - { - this.Set(variableName, scopeName, FormulaValue.NewBlank(value.Type)); - } - } - - public void Set(string variableName, FormulaValue value) => this.Set(variableName, WorkflowScopes.DefaultScopeName, value); - - public void Set(string variableName, string scopeName, FormulaValue value) => this._scopes[scopeName][variableName] = value; } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index c88a66bfff..cf6f618ecc 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -196,12 +196,13 @@ public void UnsupportedAction(Type type) Actions = [unsupportedAction] } }; + AdaptiveDialog dialog = dialogBuilder.Build(); WorkflowScopes scopes = new(); Mock mockAgentProvider = new(MockBehavior.Strict); - DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object); - WorkflowActionVisitor visitor = new(new RootExecutor(), workflowContext); - WorkflowElementWalker walker = new(dialogBuilder.Build(), visitor); + DeclarativeWorkflowOptions options = new(mockAgentProvider.Object); + WorkflowActionVisitor visitor = new(new RootExecutor(), new DeclarativeWorkflowState(RecalcEngineFactory.Create()), options); + WorkflowElementWalker walker = new(dialog, visitor); Assert.True(visitor.HasUnsupportedActions); } @@ -245,7 +246,7 @@ private async Task RunWorkflow(string workflowPath, TInput workflowInput { if (workflowEvent is ExecutorInvokeEvent invokeEvent) { - ExecutionResultMessage? message = invokeEvent.Data as ExecutionResultMessage; + DeclarativeExecutorResult? message = invokeEvent.Data as DeclarativeExecutorResult; this.Output.WriteLine($"EXEC: {invokeEvent.ExecutorId} << {message?.ExecutorId ?? "?"} [{message?.Result ?? "-"}]"); } else if (workflowEvent is DeclarativeWorkflowMessageEvent messageEvent) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs index 6b93898c16..bf450b41a2 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Extensions/FormulaValueExtensionsTests.cs @@ -15,11 +15,12 @@ public class FormulaValueExtensionsTests public void BooleanValue() { BooleanValue formulaValue = FormulaValue.New(true); - BooleanDataValue dataValue = formulaValue.ToDataValue(); - Assert.Equal(formulaValue.Value, dataValue.Value); + DataValue dataValue = formulaValue.ToDataValue(); + BooleanDataValue typedValue = Assert.IsType(dataValue); + Assert.Equal(formulaValue.Value, typedValue.Value); BooleanValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); - Assert.Equal(dataValue.Value, formulaCopy.Value); + Assert.Equal(typedValue.Value, formulaCopy.Value); Assert.Equal(bool.TrueString, formulaValue.Format()); } @@ -30,11 +31,12 @@ public void StringValues() StringValue formulaValue = FormulaValue.New("test value"); Assert.Equal(StringDataType.Instance, formulaValue.GetDataType()); - StringDataValue dataValue = formulaValue.ToDataValue(); - Assert.Equal(formulaValue.Value, dataValue.Value); + DataValue dataValue = formulaValue.ToDataValue(); + StringDataValue typedValue = Assert.IsType(dataValue); + Assert.Equal(formulaValue.Value, typedValue.Value); - StringValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); - Assert.Equal(dataValue.Value, formulaCopy.Value); + StringValue formulaCopy = Assert.IsType(typedValue.ToFormulaValue()); + Assert.Equal(typedValue.Value, formulaCopy.Value); Assert.Equal(formulaValue.Value, formulaValue.Format()); } @@ -45,11 +47,12 @@ public void DecimalValues() DecimalValue formulaValue = FormulaValue.New(45.3m); Assert.Equal(NumberDataType.Instance, formulaValue.GetDataType()); - NumberDataValue dataValue = formulaValue.ToDataValue(); - Assert.Equal(formulaValue.Value, dataValue.Value); + DataValue dataValue = formulaValue.ToDataValue(); + NumberDataValue typedValue = Assert.IsType(dataValue); + Assert.Equal(formulaValue.Value, typedValue.Value); - DecimalValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); - Assert.Equal(dataValue.Value, formulaCopy.Value); + DecimalValue formulaCopy = Assert.IsType(typedValue.ToFormulaValue()); + Assert.Equal(typedValue.Value, formulaCopy.Value); Assert.Equal("45.3", formulaValue.Format()); } @@ -60,11 +63,12 @@ public void NumberValues() NumberValue formulaValue = FormulaValue.New(3.1415926535897); Assert.Equal(FloatDataType.Instance, formulaValue.GetDataType()); - FloatDataValue dataValue = formulaValue.ToDataValue(); - Assert.Equal(formulaValue.Value, dataValue.Value); + DataValue dataValue = formulaValue.ToDataValue(); + FloatDataValue typedValue = Assert.IsType(dataValue); + Assert.Equal(formulaValue.Value, typedValue.Value); - NumberValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); - Assert.Equal(dataValue.Value, formulaCopy.Value); + NumberValue formulaCopy = Assert.IsType(typedValue.ToFormulaValue()); + Assert.Equal(typedValue.Value, formulaCopy.Value); Assert.Equal("3.1415926535897", formulaValue.Format()); } @@ -95,11 +99,12 @@ public void DateValues() DateValue formulaValue = FormulaValue.NewDateOnly(timestamp); Assert.Equal(DataType.Date, formulaValue.GetDataType()); - DateDataValue dataValue = formulaValue.ToDataValue(); - Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), dataValue.Value); + DataValue dataValue = formulaValue.ToDataValue(); + DateDataValue typedValue = Assert.IsType(dataValue); + Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), typedValue.Value); DateValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); - Assert.Equal(dataValue.Value, formulaCopy.GetConvertedValue(TimeZoneInfo.Utc)); + Assert.Equal(typedValue.Value, formulaCopy.GetConvertedValue(TimeZoneInfo.Utc)); Assert.Equal($"{timestamp}", formulaValue.Format()); } @@ -111,11 +116,12 @@ public void DateTimeValues() DateTimeValue formulaValue = FormulaValue.New(timestamp); Assert.Equal(DataType.DateTime, formulaValue.GetDataType()); - DateTimeDataValue dataValue = formulaValue.ToDataValue(); - Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), dataValue.Value); + DataValue dataValue = formulaValue.ToDataValue(); + DateTimeDataValue typedValue = Assert.IsType(dataValue); + Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), typedValue.Value); - DateTimeValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); - Assert.Equal(dataValue.Value, formulaCopy.GetConvertedValue(TimeZoneInfo.Utc)); + DateTimeValue formulaCopy = Assert.IsType(typedValue.ToFormulaValue()); + Assert.Equal(typedValue.Value, formulaCopy.GetConvertedValue(TimeZoneInfo.Utc)); Assert.Equal($"{timestamp}", formulaValue.Format()); } @@ -126,11 +132,12 @@ public void TimeValues() TimeValue formulaValue = FormulaValue.New(TimeSpan.Parse("10:35")); Assert.Equal(DataType.Time, formulaValue.GetDataType()); - TimeDataValue dataValue = formulaValue.ToDataValue(); - Assert.Equal(formulaValue.Value, dataValue.Value); + DataValue dataValue = formulaValue.ToDataValue(); + TimeDataValue typedValue = Assert.IsType(dataValue); + Assert.Equal(formulaValue.Value, typedValue.Value); - TimeValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); - Assert.Equal(dataValue.Value, formulaCopy.Value); + TimeValue formulaCopy = Assert.IsType(typedValue.ToFormulaValue()); + Assert.Equal(typedValue.Value, formulaCopy.Value); Assert.Equal("10:35:00", formulaValue.Format()); } @@ -144,7 +151,7 @@ public void RecordValues() new NamedValue("FieldC", FormulaValue.New("Value3"))); Assert.Equal(DataType.EmptyRecord, formulaValue.GetDataType()); - RecordDataValue dataValue = formulaValue.ToDataValue(); + RecordDataValue dataValue = formulaValue.ToRecord(); Assert.Equal(formulaValue.Fields.Count(), dataValue.Properties.Count); foreach (KeyValuePair property in dataValue.Properties) { @@ -178,7 +185,7 @@ public void TableValues() new NamedValue("FieldC", FormulaValue.New("Value3"))); TableValue formulaValue = TableValue.NewTable(recordValue.Type, [recordValue]); - TableDataValue dataValue = formulaValue.ToDataValue(); + TableDataValue dataValue = formulaValue.ToTable(); Assert.Equal(formulaValue.Rows.Count(), dataValue.Values.Length); TableValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue(), exactMatch: false); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ClearAllVariablesExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ClearAllVariablesExecutorTest.cs index 5eee7a31e4..af3a31c735 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ClearAllVariablesExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ClearAllVariablesExecutorTest.cs @@ -25,7 +25,7 @@ public async Task ClearWorkflowScope() VariablesToClear.ConversationScopedVariables); // Act - ClearAllVariablesExecutor action = new(model); + ClearAllVariablesExecutor action = new(model, this.GetState()); await this.Execute(action); // Assert @@ -43,7 +43,7 @@ public async Task ClearUndefinedScope() VariablesToClear.UserScopedVariables); // Act - ClearAllVariablesExecutor action = new(model); + ClearAllVariablesExecutor action = new(model, this.GetState()); await this.Execute(action); // Assert diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ParseValueExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ParseValueExecutorTest.cs index b22a49c01a..35f8494d93 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ParseValueExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ParseValueExecutorTest.cs @@ -32,7 +32,7 @@ public async Task ParseTable() @"{ ""key1"": ""val1"" }"); // Act - ParseValueExecutor action = new(model); + ParseValueExecutor action = new(model, this.GetState()); await this.Execute(action); // Assert @@ -51,7 +51,7 @@ public async Task ParseBoolean() "True"); // Act - ParseValueExecutor action = new(model); + ParseValueExecutor action = new(model, this.GetState()); await this.Execute(action); // Assert @@ -70,7 +70,7 @@ public async Task ParseNumber() "42"); // Act - ParseValueExecutor action = new(model); + ParseValueExecutor action = new(model, this.GetState()); await this.Execute(action); // Assert @@ -89,7 +89,7 @@ public async Task ParseString() "Hello, World!"); // Act - ParseValueExecutor action = new(model); + ParseValueExecutor action = new(model, this.GetState()); await this.Execute(action); // Assert diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ResetVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ResetVariableExecutorTest.cs index ca058cdc66..d705d4ebb8 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ResetVariableExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/ResetVariableExecutorTest.cs @@ -26,7 +26,7 @@ public async Task ResetDefinedValue() FormatVariablePath("MyVar1")); // Act - ResetVariableExecutor action = new(model); + ResetVariableExecutor action = new(model, this.GetState()); await this.Execute(action); // Assert @@ -47,7 +47,7 @@ public async Task ResetUndefinedValue() FormatVariablePath("NoVar")); // Act - ResetVariableExecutor action = new(model); + ResetVariableExecutor action = new(model, this.GetState()); await this.Execute(action); // Assert diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs index 895eb0742a..c687c1179c 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs @@ -22,7 +22,7 @@ public async Task CaptureActivity() "Test activity message"); // Act - SendActivityExecutor action = new(model); + SendActivityExecutor action = new(model, this.GetState()); WorkflowEvent[] events = await this.Execute(action); // Assert diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetTextVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetTextVariableExecutorTest.cs index 3cf9c2c85c..6ca809d1dd 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetTextVariableExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetTextVariableExecutorTest.cs @@ -24,7 +24,7 @@ public async Task SetLiteralValue() "Text variable value"); // Act - SetTextVariableExecutor action = new(model); + SetTextVariableExecutor action = new(model, this.GetState()); await this.Execute(action); // Assert @@ -45,7 +45,7 @@ public async Task UpdateExistingValue() "New value"); // Act - SetTextVariableExecutor action = new(model); + SetTextVariableExecutor action = new(model, this.GetState()); await this.Execute(action); // Assert diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetVariableExecutorTest.cs index b282c46feb..12b5125031 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetVariableExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetVariableExecutorTest.cs @@ -17,7 +17,7 @@ public sealed class SetVariableExecutorTest(ITestOutputHelper output) : Workflow public void InvalidModel() { // Arrange, Act, Assert - Assert.Throws(() => new SetVariableExecutor(new SetVariable())); + Assert.Throws(() => new SetVariableExecutor(new SetVariable(), this.GetState())); } [Fact] @@ -183,7 +183,7 @@ private async Task ExecuteTest( this.Scopes.Set(variableName, FormulaValue.New(33)); // Act - SetVariableExecutor action = new(model); + SetVariableExecutor action = new(model, this.GetState()); await this.Execute(action); // Assert diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs index d20c3db8e5..13ce04e1b9 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs @@ -20,6 +20,8 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor { internal WorkflowScopes Scopes { get; } = new(); + internal DeclarativeWorkflowState GetState() => new(RecalcEngineFactory.Create(), this.Scopes); + protected ActionId CreateActionId() => new($"{this.GetType().Name}_{Guid.NewGuid():N}"); protected string FormatDisplayName(string name) => $"{this.GetType().Name}_{name}"; @@ -76,8 +78,7 @@ internal sealed class TestWorkflowExecutor() : { public async ValueTask HandleAsync(WorkflowScopes message, IWorkflowContext context) { - await context.SetScopedStateAsync(message, default).ConfigureAwait(false); - await context.SendMessageAsync(new ExecutionResultMessage(this.Id)).ConfigureAwait(false); + await context.SendMessageAsync(new DeclarativeExecutorResult(this.Id)).ConfigureAwait(false); } } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs index 5aa491ce9d..690a20614c 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/RecalcEngineFactoryTests.cs @@ -1,14 +1,61 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; +using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Agents.Workflows.Declarative.PowerFx; using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; using Xunit.Abstractions; namespace Microsoft.Agents.Workflows.Declarative.UnitTests.PowerFx; public class RecalcEngineFactoryTests(ITestOutputHelper output) : RecalcEngineTest(output) { + [Fact] + public void VariableUpdateTest() + { + RecalcEngine engine = this.CreateEngine(); + + FormulaValue evalResult; + + engine.UpdateVariable("single", FormulaValue.New(1)); + evalResult = engine.Eval("single"); + Console.WriteLine($"# {evalResult.Format()}"); + + RecordValue recordSub = + FormulaValue.NewRecordFromFields( + new NamedValue("sub", FormulaValue.New(3.14))); + RecordValue recordRoot = + FormulaValue.NewRecordFromFields( + new NamedValue("another", FormulaValue.NewBlank()), + new NamedValue("val", FormulaValue.New(2.82)), + new NamedValue("root", recordSub)); + engine.DeleteFormula("Topic"); + engine.UpdateVariable("Topic", recordRoot); + evalResult = engine.Eval("Topic"); + Console.WriteLine($"# {evalResult.Format()}"); + evalResult = engine.Eval("Topic.val"); + Console.WriteLine($"# {evalResult.Format()}"); + evalResult = engine.Eval("Topic.root"); + Console.WriteLine($"# {evalResult.Format()}"); + evalResult = engine.Eval("Topic.root.sub"); + Console.WriteLine($"# {evalResult.Format()}"); + //recordRoot.UpdateField("another", FormulaValue.New("abc")); + RecordValue recordRoot2 = + FormulaValue.NewRecordFromFields( + new NamedValue("another", FormulaValue.New("abc")), + new NamedValue("val", FormulaValue.New(2.82)), + new NamedValue("root", recordSub)); + engine.DeleteFormula("Topic"); + engine.UpdateVariable("Topic", recordRoot2); + evalResult = engine.Eval("Topic.another"); + Console.WriteLine($"# {evalResult.Format()}"); + engine.UpdateVariable("Topic.another", FormulaValue.New(-1)); + evalResult = engine.Eval("Topic.another"); + Console.WriteLine($"# {evalResult.Format()}"); + } + [Fact] public void DefaultNotNull() { diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs index e6b30410d7..53cd9983f5 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs @@ -34,15 +34,15 @@ private static class Variables public WorkflowExpressionEngineTests(ITestOutputHelper output) : base(output) { - this.Scopes.Set(Variables.GlobalValue, VariableScopeNames.Global, FormulaValue.New(255)); - this.Scopes.Set(Variables.BoolValue, VariableScopeNames.Topic, FormulaValue.New(true)); - this.Scopes.Set(Variables.StringValue, VariableScopeNames.Topic, FormulaValue.New("Hello World")); - this.Scopes.Set(Variables.IntValue, VariableScopeNames.Topic, FormulaValue.New(long.MaxValue)); - this.Scopes.Set(Variables.NumberValue, VariableScopeNames.Topic, FormulaValue.New(33.3)); - this.Scopes.Set(Variables.EnumValue, VariableScopeNames.Topic, FormulaValue.New(nameof(VariablesToClear.ConversationScopedVariables))); - this.Scopes.Set(Variables.ObjectValue, VariableScopeNames.Topic, ObjectData); - this.Scopes.Set(Variables.ArrayValue, VariableScopeNames.Topic, TableData); - this.Scopes.Set(Variables.BlankValue, VariableScopeNames.Topic, FormulaValue.NewBlank()); + this.Scopes.Set(Variables.GlobalValue, FormulaValue.New(255), VariableScopeNames.Global); + this.Scopes.Set(Variables.BoolValue, FormulaValue.New(true), VariableScopeNames.Topic); + this.Scopes.Set(Variables.StringValue, FormulaValue.New("Hello World"), VariableScopeNames.Topic); + this.Scopes.Set(Variables.IntValue, FormulaValue.New(long.MaxValue), VariableScopeNames.Topic); + this.Scopes.Set(Variables.NumberValue, FormulaValue.New(33.3), VariableScopeNames.Topic); + this.Scopes.Set(Variables.EnumValue, FormulaValue.New(nameof(VariablesToClear.ConversationScopedVariables)), VariableScopeNames.Topic); + this.Scopes.Set(Variables.ObjectValue, ObjectData, VariableScopeNames.Topic); + this.Scopes.Set(Variables.ArrayValue, TableData, VariableScopeNames.Topic); + this.Scopes.Set(Variables.BlankValue, FormulaValue.NewBlank(), VariableScopeNames.Topic); } #region Unsupported Expression Tests @@ -184,7 +184,7 @@ public void StringExpressionGetValueForRecord() { // Arrange RecordValue state = FormulaValue.NewRecordFromFields([new NamedValue("test", FormulaValue.New("value"))]); - this.Scopes.Set("TestRecord", VariableScopeNames.Global, state); + this.Scopes.Set("TestRecord", state, VariableScopeNames.Global); // Arrange, Act & Assert this.EvaluateExpression( @@ -463,7 +463,7 @@ public void ObjectExpressionGetValueForVariable(bool useState) // Arrange, Act & Assert this.EvaluateExpression( ObjectExpression.Variable(PropertyPath.TopicVariable(Variables.ObjectValue)), - expectedValue: ObjectData.ToDataValue(), + expectedValue: ObjectData.ToRecord(), useState); } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs index 6f9ff254de..c09a448656 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/WorkflowScopesTests.cs @@ -47,7 +47,7 @@ public void BuildRecordContainsSetValues() // Arrange WorkflowScopes scopes = new(); FormulaValue testValue = FormulaValue.New("test"); - scopes.Set("key1", VariableScopeNames.Topic, testValue); + scopes.Set("key1", testValue, VariableScopeNames.Topic); // Act RecordValue record = scopes.BuildRecord(VariableScopeNames.Topic); @@ -67,19 +67,19 @@ public void BuildRecordForAllScopeTypes() FormulaValue testValue = FormulaValue.New("test"); // Act & Assert - scopes.Set("envKey", VariableScopeNames.Environment, testValue); + scopes.Set("envKey", testValue, VariableScopeNames.Environment); RecordValue envRecord = scopes.BuildRecord(VariableScopeNames.Environment); Assert.Single(envRecord.Fields); - scopes.Set("topicKey", VariableScopeNames.Topic, testValue); + scopes.Set("topicKey", testValue, VariableScopeNames.Topic); RecordValue topicRecord = scopes.BuildRecord(VariableScopeNames.Topic); Assert.Single(topicRecord.Fields); - scopes.Set("globalKey", VariableScopeNames.Global, testValue); + scopes.Set("globalKey", testValue, VariableScopeNames.Global); RecordValue globalRecord = scopes.BuildRecord(VariableScopeNames.Global); Assert.Single(globalRecord.Fields); - scopes.Set("systemKey", VariableScopeNames.System, testValue); + scopes.Set("systemKey", testValue, VariableScopeNames.System); RecordValue systemRecord = scopes.BuildRecord(VariableScopeNames.System); Assert.Single(systemRecord.Fields); } @@ -90,7 +90,7 @@ public void GetWithImplicitScope() // Arrange WorkflowScopes scopes = new(); FormulaValue testValue = FormulaValue.New("test"); - scopes.Set("key1", VariableScopeNames.Topic, testValue); + scopes.Set("key1", testValue, VariableScopeNames.Topic); // Act FormulaValue result = scopes.Get("key1"); @@ -105,7 +105,7 @@ public void GetWithSpecifiedScope() // Arrange WorkflowScopes scopes = new(); FormulaValue testValue = FormulaValue.New("test"); - scopes.Set("key1", VariableScopeNames.Global, testValue); + scopes.Set("key1", testValue, VariableScopeNames.Global); // Act FormulaValue result = scopes.Get("key1", VariableScopeNames.Global); @@ -137,7 +137,7 @@ public void SetSpecifiedScope() FormulaValue testValue = FormulaValue.New("test"); // Act - scopes.Set("key1", VariableScopeNames.System, testValue); + scopes.Set("key1", testValue, VariableScopeNames.System); // Assert FormulaValue result = scopes.Get("key1", VariableScopeNames.System); @@ -153,8 +153,8 @@ public void SetOverwritesExistingValue() FormulaValue newValue = FormulaValue.New("new"); // Act - scopes.Set("key1", VariableScopeNames.Topic, initialValue); - scopes.Set("key1", VariableScopeNames.Topic, newValue); + scopes.Set("key1", initialValue, VariableScopeNames.Topic); + scopes.Set("key1", newValue, VariableScopeNames.Topic); // Assert FormulaValue result = scopes.Get("key1", VariableScopeNames.Topic); From 4340920441ccbccdd46a8d2a79a09c411bece0fa Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 28 Aug 2025 12:51:42 -0700 Subject: [PATCH 222/232] Cleanup --- .../Interpreter/DeclarativeWorkflowState.cs | 1 - .../PowerFx/WorkflowScope.cs | 58 ----------------- .../PowerFx/WorkflowScopes.cs | 64 +++++++++++++++---- 3 files changed, 52 insertions(+), 71 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs index 2578ce5194..aae1403ecd 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs deleted file mode 100644 index 67a2271418..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScope.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Linq; -using Microsoft.Agents.Workflows.Declarative.Extensions; -using Microsoft.Bot.ObjectModel; -using Microsoft.PowerFx.Types; - -namespace Microsoft.Agents.Workflows.Declarative.PowerFx; - -/// -/// The set of variables for a specific action scope. -/// -internal sealed class WorkflowScope(string scopeName) : Dictionary -{ - public string Name => scopeName; - - public void Reset() - { - foreach (string variableName in this.Keys.ToArray()) - { - this.Reset(variableName); - } - } - - public void Reset(string variableName) - { - if (this.TryGetValue(variableName, out FormulaValue? value)) - { - this[variableName] = value.Type.NewBlank(); - } - } - - public RecordValue BuildRecord() - { - return FormulaValue.NewRecordFromFields(GetFields()); - - IEnumerable GetFields() - { - foreach (KeyValuePair kvp in this) - { - yield return new NamedValue(kvp.Key, kvp.Value); - } - } - } - - public RecordDataValue BuildState() - { - RecordDataValue.Builder recordBuilder = new(); - - foreach (KeyValuePair kvp in this) - { - recordBuilder.Properties.Add(kvp.Key, kvp.Value.ToDataValue()); - } - - return recordBuilder.Build(); - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs index 55154c9047..b68e76d6f6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowScopes.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using Microsoft.Agents.Workflows.Declarative.Extensions; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx; using Microsoft.PowerFx.Types; @@ -19,19 +20,9 @@ internal sealed class WorkflowScopes private readonly ImmutableDictionary _scopes; - public WorkflowScopes(Dictionary? scopes = null) + public WorkflowScopes() { - this._scopes = VariableScopeNames.AllScopes.ToDictionary(scopeName => scopeName, scopeName => GetScope(scopeName)).ToImmutableDictionary(); - - WorkflowScope GetScope(string scopeName) - { - if (scopes is not null && scopes.TryGetValue(scopeName, out WorkflowScope? scope)) - { - return scope; - } - - return new WorkflowScope(scopeName); - } + this._scopes = VariableScopeNames.AllScopes.ToDictionary(scopeName => scopeName, scopeName => new WorkflowScope(scopeName)).ToImmutableDictionary(); } public FormulaValue Get(string variableName, string? scopeName = null) @@ -86,4 +77,53 @@ void Bind(string scopeName) engine.UpdateVariable(scopeName, scopeRecord); } } + + /// + /// The set of variables for a specific action scope. + /// + private sealed class WorkflowScope(string scopeName) : Dictionary + { + public string Name => scopeName; + + public void Reset() + { + foreach (string variableName in this.Keys.ToArray()) + { + this.Reset(variableName); + } + } + + public void Reset(string variableName) + { + if (this.TryGetValue(variableName, out FormulaValue? value)) + { + this[variableName] = value.Type.NewBlank(); + } + } + + public RecordValue BuildRecord() + { + return FormulaValue.NewRecordFromFields(GetFields()); + + IEnumerable GetFields() + { + foreach (KeyValuePair kvp in this) + { + yield return new NamedValue(kvp.Key, kvp.Value); + } + } + } + + public RecordDataValue BuildState() + { + RecordDataValue.Builder recordBuilder = new(); + + foreach (KeyValuePair kvp in this) + { + recordBuilder.Properties.Add(kvp.Key, kvp.Value.ToDataValue()); + } + + return recordBuilder.Build(); + } + } } From f8274859c8d42751c28df5ab20811751dd4244a1 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 28 Aug 2025 15:22:59 -0700 Subject: [PATCH 223/232] Exception cleanup --- .../DeclarativeWorkflowBuilder.cs | 4 +- ...ption.cs => DeclarativeActionException.cs} | 16 +-- .../Exceptions/DeclarativeModelException.cs | 35 +++++ .../Exceptions/InvalidScopeException.cs | 35 ----- .../Exceptions/InvalidSegmentException.cs | 35 ----- .../Exceptions/UnknownActionException.cs | 35 ----- .../Exceptions/UnknownDataTypeException.cs | 35 ----- .../UnsupportedVariableException.cs | 35 ----- .../Exceptions/WorkflowModelException.cs | 35 ----- .../Extensions/BotElementExtensions.cs | 4 +- .../Extensions/FormulaValueExtensions.cs | 130 ++++++++---------- .../Extensions/RecordDataTypeExtensions.cs | 3 +- .../Extensions/TemplateExtensions.cs | 2 +- .../Interpreter/DeclarativeActionExecutor.cs | 16 ++- .../Interpreter/DeclarativeWorkflowModel.cs | 14 +- .../Interpreter/DeclarativeWorkflowState.cs | 8 +- .../Interpreter/WorkflowActionVisitor.cs | 4 +- .../ObjectModel/EditTableExecutor.cs | 2 +- .../ObjectModel/EditTableV2Executor.cs | 2 +- .../ObjectModel/ParseValueExecutor.cs | 24 ++-- .../PowerFx/WorkflowDiagnostics.cs | 2 +- .../DeclarativeWorkflowExceptionTest.cs | 44 +----- .../DeclarativeWorkflowTest.cs | 2 +- .../Interpreter/WorkflowModelTest.cs | 10 +- .../ObjectModel/SetVariableExecutorTest.cs | 2 +- .../PowerFx/TemplateExtensionsTests.cs | 2 +- 26 files changed, 166 insertions(+), 370 deletions(-) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/{WorkflowExecutionException.cs => DeclarativeActionException.cs} (54%) create mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeModelException.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidSegmentException.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownActionException.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownDataTypeException.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnsupportedVariableException.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowModelException.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index c35c6c099e..67cf1b56ae 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -48,12 +48,12 @@ public static Workflow Build( Func? inputTransform = null) where TInput : notnull { - BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new UnknownActionException("Unable to parse workflow."); + BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new DeclarativeModelException("Unable to parse workflow."); // ISSUE #486 - Use "Workflow" element for Foundry. if (rootElement is not AdaptiveDialog workflowElement) { - throw new UnknownActionException($"Unsupported root element: {rootElement.GetType().Name}. Expected an {nameof(AdaptiveDialog)}."); + throw new DeclarativeModelException($"Unsupported root element: {rootElement.GetType().Name}. Expected an {nameof(AdaptiveDialog)}."); } string rootId = WorkflowActionVisitor.RootId(workflowElement.BeginDialog?.Id.Value ?? "workflow"); diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowExecutionException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeActionException.cs similarity index 54% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowExecutionException.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeActionException.cs index e562ff77d3..6849a57bf2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowExecutionException.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeActionException.cs @@ -5,31 +5,31 @@ namespace Microsoft.Agents.Workflows.Declarative; /// -/// Represents an exception that occurs when an action is invalid or cannot be processed. +/// Represents an exception that occurs during action execution. /// -public sealed class WorkflowExecutionException : DeclarativeWorkflowException +public sealed class DeclarativeActionException : DeclarativeWorkflowException { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public WorkflowExecutionException() + public DeclarativeActionException() { } /// - /// Initializes a new instance of the class with a specified error message. + /// Initializes a new instance of the class with a specified error message. /// /// The error message that explains the reason for the exception. - public WorkflowExecutionException(string? message) : base(message) + public DeclarativeActionException(string? message) : base(message) { } /// - /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. /// /// The error message that explains the reason for the exception. /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. - public WorkflowExecutionException(string? message, Exception? innerException) : base(message, innerException) + public DeclarativeActionException(string? message, Exception? innerException) : base(message, innerException) { } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeModelException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeModelException.cs new file mode 100644 index 0000000000..f6b6adfb11 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/DeclarativeModelException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows.Declarative; + +/// +/// Represents an exception that occurs when the declarative model is not supported. +/// +public class DeclarativeModelException : DeclarativeWorkflowException +{ + /// + /// Initializes a new instance of the class. + /// + public DeclarativeModelException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public DeclarativeModelException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public DeclarativeModelException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs deleted file mode 100644 index ec339ef298..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidScopeException.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Agents.Workflows.Declarative; - -/// -/// Represents an exception that occurs when an action is invalid or cannot be processed. -/// -public sealed class InvalidScopeException : DeclarativeWorkflowException -{ - /// - /// Initializes a new instance of the class. - /// - public InvalidScopeException() - { - } - - /// - /// Initializes a new instance of the class with a specified error message. - /// - /// The error message that explains the reason for the exception. - public InvalidScopeException(string? message) : base(message) - { - } - - /// - /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. - /// - /// The error message that explains the reason for the exception. - /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. - public InvalidScopeException(string? message, Exception? innerException) : base(message, innerException) - { - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidSegmentException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidSegmentException.cs deleted file mode 100644 index 310ff2f89f..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/InvalidSegmentException.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Agents.Workflows.Declarative; - -/// -/// Represents an exception that occurs when an action is invalid or cannot be processed. -/// -public sealed class InvalidSegmentException : DeclarativeWorkflowException -{ - /// - /// Initializes a new instance of the class. - /// - public InvalidSegmentException() - { - } - - /// - /// Initializes a new instance of the class with a specified error message. - /// - /// The error message that explains the reason for the exception. - public InvalidSegmentException(string? message) : base(message) - { - } - - /// - /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. - /// - /// The error message that explains the reason for the exception. - /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. - public InvalidSegmentException(string? message, Exception? innerException) : base(message, innerException) - { - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownActionException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownActionException.cs deleted file mode 100644 index 0f7e512034..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownActionException.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Agents.Workflows.Declarative; - -/// -/// Represents an exception that occurs when an action is invalid or cannot be processed. -/// -public sealed class UnknownActionException : DeclarativeWorkflowException -{ - /// - /// Initializes a new instance of the class. - /// - public UnknownActionException() - { - } - - /// - /// Initializes a new instance of the class with a specified error message. - /// - /// The error message that explains the reason for the exception. - public UnknownActionException(string? message) : base(message) - { - } - - /// - /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. - /// - /// The error message that explains the reason for the exception. - /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. - public UnknownActionException(string? message, Exception? innerException) : base(message, innerException) - { - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownDataTypeException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownDataTypeException.cs deleted file mode 100644 index 129de6ff41..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnknownDataTypeException.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Agents.Workflows.Declarative; - -/// -/// Represents an exception that occurs when an unknown data type is encountered. -/// -public sealed class UnknownDataTypeException : DeclarativeWorkflowException -{ - /// - /// Initializes a new instance of the class. - /// - public UnknownDataTypeException() - { - } - - /// - /// Initializes a new instance of the class with a specified error message. - /// - /// The error message that explains the reason for the exception. - public UnknownDataTypeException(string? message) : base(message) - { - } - - /// - /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. - /// - /// The error message that explains the reason for the exception. - /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. - public UnknownDataTypeException(string? message, Exception? innerException) : base(message, innerException) - { - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnsupportedVariableException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnsupportedVariableException.cs deleted file mode 100644 index 4e406fda91..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/UnsupportedVariableException.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Agents.Workflows.Declarative; - -/// -/// Represents an exception that occurs when the specific scope is invalid. -/// -public sealed class UnsupportedVariableException : DeclarativeWorkflowException -{ - /// - /// Initializes a new instance of the class. - /// - public UnsupportedVariableException() - { - } - - /// - /// Initializes a new instance of the class with a specified error message. - /// - /// The error message that explains the reason for the exception. - public UnsupportedVariableException(string? message) : base(message) - { - } - - /// - /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. - /// - /// The error message that explains the reason for the exception. - /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. - public UnsupportedVariableException(string? message, Exception? innerException) : base(message, innerException) - { - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowModelException.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowModelException.cs deleted file mode 100644 index 8bb08fb649..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Exceptions/WorkflowModelException.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Agents.Workflows.Declarative; - -/// -/// Represents an exception that occurs when building the process workflow. -/// -public class WorkflowModelException : DeclarativeWorkflowException -{ - /// - /// Initializes a new instance of the class. - /// - public WorkflowModelException() - { - } - - /// - /// Initializes a new instance of the class with a specified error message. - /// - /// The error message that explains the reason for the exception. - public WorkflowModelException(string? message) : base(message) - { - } - - /// - /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. - /// - /// The error message that explains the reason for the exception. - /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. - public WorkflowModelException(string? message, Exception? innerException) : base(message, innerException) - { - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs index fe6587bdaa..bb409944f2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/BotElementExtensions.cs @@ -13,9 +13,9 @@ public static string GetId(this BotElement element) return element switch { DialogAction action => action.Id.Value, - ConditionItem conditionItem => conditionItem.Id ?? throw new WorkflowModelException($"Undefined identifier for {nameof(ConditionItem)} that is member of {conditionItem.GetParentId() ?? "(root)"}."), + ConditionItem conditionItem => conditionItem.Id ?? throw new DeclarativeModelException($"Undefined identifier for {nameof(ConditionItem)} that is member of {conditionItem.GetParentId() ?? "(root)"}."), OnActivity activity => activity.Id.Value, - _ => throw new UnknownActionException($"Unknown element type: {element.GetType().Name}"), + _ => throw new DeclarativeModelException($"Unknown identify for element type: {element.GetType().Name}"), }; } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs index 9bd57fdb44..568f3ba104 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -4,12 +4,12 @@ using System.Collections; using System.Collections.Generic; using System.Collections.Immutable; +using System.Dynamic; using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx.Types; -using BindingFlags = System.Reflection.BindingFlags; using BlankType = Microsoft.PowerFx.Types.BlankType; namespace Microsoft.Agents.Workflows.Declarative.Extensions; @@ -20,12 +20,11 @@ internal static class FormulaValueExtensions public static FormulaValue NewBlank(this FormulaType? type) => FormulaValue.NewBlank(type ?? FormulaType.Blank); - public static FormulaValue ToFormulaValue(this object? value) - { - Type? type = value?.GetType(); - return value switch + public static FormulaValue ToFormulaValue(this object? value) => + value switch { null => FormulaValue.NewBlank(), + FormulaValue formulaValue => formulaValue, bool booleanValue => FormulaValue.New(booleanValue), int decimalValue => FormulaValue.New(decimalValue), long decimalValue => FormulaValue.New(decimalValue), @@ -36,26 +35,27 @@ public static FormulaValue ToFormulaValue(this object? value) DateTime dateonlyValue when dateonlyValue.TimeOfDay == TimeSpan.Zero => FormulaValue.NewDateOnly(dateonlyValue), DateTime datetimeValue => FormulaValue.New(datetimeValue), TimeSpan timeValue => FormulaValue.New(timeValue), - object when typeof(IEnumerable).IsAssignableFrom(type) => ((IEnumerable)value).ToTableValue(type), - _ => value.ToRecordValue(type), + object when value is IEnumerable tableValue => tableValue.ToTable(), + ExpandoObject expandoValue => expandoValue.ToRecord(), + _ => throw new DeclarativeModelException($"Unsupported variable type: {value.GetType().Name}"), }; - } - public static FormulaType ToFormulaType(this Type? type) => - type switch + public static FormulaType GetFormulaType(this object? value) => + value switch { null => FormulaType.Blank, - Type when type == typeof(bool) => FormulaType.Boolean, - Type when type == typeof(int) => FormulaType.Decimal, - Type when type == typeof(long) => FormulaType.Decimal, - Type when type == typeof(float) => FormulaType.Decimal, - Type when type == typeof(decimal) => FormulaType.Decimal, - Type when type == typeof(double) => FormulaType.Number, - Type when type == typeof(string) => FormulaType.String, - Type when type == typeof(DateTime) => FormulaType.DateTime, - Type when type == typeof(TimeSpan) => FormulaType.Time, - Type when typeof(IEnumerable).IsAssignableFrom(type) => type.ToTableType(), - _ => type.ToRecordType(), + bool => FormulaType.Boolean, + int => FormulaType.Decimal, + long => FormulaType.Decimal, + float => FormulaType.Decimal, + decimal => FormulaType.Decimal, + double => FormulaType.Number, + string => FormulaType.String, + DateTime => FormulaType.DateTime, + TimeSpan => FormulaType.Time, + object when value is IEnumerable tableValue => tableValue.ToTableType(), + ExpandoObject expandoValue => expandoValue.ToRecordType(), + _ => FormulaType.Unknown, }; public static DataValue ToDataValue(this FormulaValue value) => @@ -72,7 +72,7 @@ public static DataValue ToDataValue(this FormulaValue value) => VoidValue voidValue => DataValue.Blank(), RecordValue recordValue => recordValue.ToRecord(), TableValue tableValue => tableValue.ToTable(), - _ => throw new NotSupportedException($"Unsupported FormulaValue type: {value.GetType().Name}"), + _ => throw new DeclarativeModelException($"Unsupported variable type: {value.GetType().Name}"), }; public static DataType GetDataType(this FormulaValue value) => @@ -143,7 +143,7 @@ public static TableDataValue ToTable(this TableValue value) => public static RecordDataValue ToRecord(this RecordValue value) => RecordDataValue.RecordFromFields(value.OriginalFields.Select(field => field.GetKeyValuePair()).ToImmutableArray()); - public static RecordDataType ToDataType(this RecordType record) + private static RecordDataType ToDataType(this RecordType record) { RecordDataType recordType = new(); foreach (string fieldName in record.FieldNames) @@ -153,7 +153,7 @@ public static RecordDataType ToDataType(this RecordType record) return recordType; } - public static TableDataType ToDataType(this TableType table) + private static TableDataType ToDataType(this TableType table) { TableDataType tableType = new(); foreach (string fieldName in table.FieldNames) @@ -163,85 +163,65 @@ public static TableDataType ToDataType(this TableType table) return tableType; } - private static RecordType ToRecordType(this Type? type) + private static RecordType ToRecordType(this ExpandoObject value) { RecordType recordType = RecordType.Empty(); - - if (type is not null) + foreach (KeyValuePair property in value) { -#pragma warning disable IL2070 // might not behave correctly in a trimmed deployment. // %%% REFLECTION - foreach ((string Name, Type Type) property in - type.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Select(property => (property.Name, property.PropertyType))) -#pragma warning restore IL2070 // might not behave correctly in a trimmed deployment. - { - recordType.Add(property.Name, property.Type.ToFormulaType()); - } + recordType.Add(property.Key, property.Value.GetFormulaType()); } - return recordType; } - private static RecordValue ToRecordValue(this object value, Type? type) - { - type ??= value.GetType(); - - if (value is not RecordValue recordValue) - { -#pragma warning disable IL2070 // might not behave correctly in a trimmed deployment. // %%% REFLECTION - IEnumerable propertyValues = - type.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Select(property => new NamedValue(property.Name, property.GetValue(value).ToFormulaValue())); -#pragma warning restore IL2070 // might not behave correctly in a trimmed deployment. - recordValue = FormulaValue.NewRecordFromFields(propertyValues); - } - - return recordValue; - } + private static RecordValue ToRecord(this ExpandoObject value) => + FormulaValue.NewRecordFromFields( + value.Select( + property => new NamedValue(property.Key, property.Value.ToFormulaValue()))); - private static TableType ToTableType(this Type type) + private static TableType ToTableType(this IEnumerable value) { - TableType tableType = TableType.Empty(); + Type valueType = value.GetType(); + Type? elementType = valueType.GetElementType() ?? valueType.GetGenericArguments().FirstOrDefault(); - Type? elementType = type.GetElementType() ?? type.GetGenericArguments().FirstOrDefault(); if (elementType is not null) { -#pragma warning disable IL2070 // might not behave correctly in a trimmed deployment. // %%% REFLECTION - foreach ((string Name, Type Type) property in - type.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Select(property => (property.Name, property.PropertyType))) -#pragma warning restore IL2070 // might not behave correctly in a trimmed deployment. + if (elementType != typeof(ExpandoObject)) + { + throw new DeclarativeModelException($"Invalid table element: {elementType.Name}"); + } + + foreach (ExpandoObject element in value) { - tableType.Add(property.Name, property.Type.ToFormulaType()); + return element.ToRecordType().ToTable(); } } - return tableType; + return TableType.Empty(); } - private static TableValue ToTableValue(this IEnumerable value, Type type) + private static TableValue ToTable(this IEnumerable value) { - Type? elementType = type.GetElementType() ?? type.GetGenericArguments().FirstOrDefault(); + Type valueType = value.GetType(); + Type? elementType = valueType.GetElementType() ?? valueType.GetGenericArguments().FirstOrDefault(); - if (type is null) + if (elementType is null) { return FormulaValue.NewTable(RecordType.EmptySealed()); } - return FormulaValue.NewTable(elementType.ToRecordType(), GetRecords()); - - IEnumerable GetRecords() + if (elementType != typeof(ExpandoObject)) { - foreach (object elementValue in value) - { - yield return elementValue.ToRecordValue(elementType); - } + throw new DeclarativeModelException($"Invalid table element: {elementType.Name}"); } + + List records = [.. value.OfType().Select(element => element.ToRecord())]; + + return FormulaValue.NewTable(value.ToTableType().ToRecord(), records); } private static KeyValuePair GetKeyValuePair(this NamedValue value) => new(value.Name, value.Value.ToDataValue()); - public static JsonNode ToJson(this FormulaValue value) => + private static JsonNode ToJson(this FormulaValue value) => value switch { BooleanValue booleanValue => JsonValue.Create(booleanValue.Value), @@ -258,7 +238,7 @@ public static JsonNode ToJson(this FormulaValue value) => _ => $"[{value.GetType().Name}]", }; - public static JsonArray ToJson(this TableValue value) + private static JsonArray ToJson(this TableValue value) { return new([.. GetJsonElements()]); @@ -272,7 +252,7 @@ IEnumerable GetJsonElements() } } - public static JsonObject ToJson(this RecordValue value) + private static JsonObject ToJson(this RecordValue value) { JsonObject jsonObject = []; foreach (NamedValue field in value.OriginalFields) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs index 7dc5a46aa7..977a0217db 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/RecordDataTypeExtensions.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; @@ -30,7 +31,7 @@ IEnumerable ParseValues() TimeDataType => TimeValue.New(propertyElement.GetDateTimeOffset().TimeOfDay), RecordDataType recordType => recordType.ParseRecord(propertyElement), TableDataType tableType => ParseTable(tableType, propertyElement), - _ => throw new UnknownDataTypeException($"Unsupported data type '{property.Value.Type}' for property '{property.Key}'"), + _ => throw new InvalidOperationException($"Unsupported data type '{property.Value.Type}' for property '{property.Key}'"), }; yield return new NamedValue(property.Key, parsedValue); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/TemplateExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/TemplateExtensions.cs index 62a8568cfb..9a41554a18 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/TemplateExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Extensions/TemplateExtensions.cs @@ -44,6 +44,6 @@ internal static class TemplateExtensions } } - throw new InvalidSegmentException($"Unsupported segment type: {segment.GetType().Name}"); + throw new DeclarativeModelException($"Unsupported segment type: {segment.GetType().Name}"); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs index 4ee2c97f20..0d0138f261 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -45,7 +45,7 @@ protected WorkflowActionExecutor(DialogAction model, DeclarativeWorkflowState st { if (!model.HasRequiredProperties) { - throw new WorkflowModelException($"Missing required properties for element: {model.GetId()} ({model.GetType().Name})."); + throw new DeclarativeModelException($"Missing required properties for element: {model.GetId()} ({model.GetType().Name})."); } this.Model = model; @@ -69,13 +69,15 @@ public async ValueTask HandleAsync(DeclarativeExecutorResult message, IWorkflowC return; } + await this.State.RestoreAsync(context, default).ConfigureAwait(false); + try { object? result = await this.ExecuteAsync(context, cancellationToken: default).ConfigureAwait(false); await context.SendMessageAsync(new DeclarativeExecutorResult(this.Id, result)).ConfigureAwait(false); } - catch (WorkflowExecutionException exception) + catch (DeclarativeActionException exception) { Debug.WriteLine($"ERROR [{this.Id}] {exception.GetType().Name}\n{exception.Message}"); throw; @@ -83,7 +85,7 @@ public async ValueTask HandleAsync(DeclarativeExecutorResult message, IWorkflowC catch (Exception exception) { Debug.WriteLine($"ERROR [{this.Id}] {exception.GetType().Name}\n{exception.Message}"); - throw new WorkflowExecutionException($"Unhandled workflow failure - #{this.Id} ({this.Model.GetType().Name})", exception); + throw new DeclarativeActionException($"Unhandled workflow failure - #{this.Id} ({this.Model.GetType().Name})", exception); } } @@ -93,7 +95,7 @@ protected async ValueTask AssignAsync(PropertyPath targetPath, FormulaValue resu { if (!s_mutableScopes.Contains(Throw.IfNull(targetPath.VariableScopeName))) { - throw new InvalidScopeException($"Invalid scope: {targetPath.VariableScopeName}"); + throw new DeclarativeModelException($"Invalid scope: {targetPath.VariableScopeName}"); } await this.State.SetAsync(targetPath, result, context).ConfigureAwait(false); @@ -109,4 +111,10 @@ protected async ValueTask AssignAsync(PropertyPath targetPath, FormulaValue resu """); #endif } + + protected DeclarativeActionException Exception(string text, Exception? exception = null) + { + string message = $"Unexpected workflow failure during {this.Model.GetType().Name} [{this.Id}]: {text}"; + return exception is null ? new(message) : new(message, exception); + } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowModel.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowModel.cs index ff674e28f0..1d8f511f55 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowModel.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowModel.cs @@ -30,7 +30,7 @@ public int GetDepth(string? nodeId) if (!this.Nodes.TryGetValue(nodeId, out ModelNode? sourceNode)) { - throw new UnknownActionException($"Unresolved step: {nodeId}."); + throw new DeclarativeModelException($"Unresolved step: {nodeId}."); } return sourceNode.Depth; @@ -40,7 +40,7 @@ public void AddNode(Executor executor, string parentId, Action? completionHandle { if (!this.Nodes.TryGetValue(parentId, out ModelNode? parentNode)) { - throw new UnknownActionException($"Unresolved parent for {executor.Id}: {parentId}."); + throw new DeclarativeModelException($"Unresolved parent for {executor.Id}: {parentId}."); } ModelNode stepNode = this.DefineNode(executor, parentNode, executor.GetType(), completionHandler); @@ -52,12 +52,12 @@ public void AddLinkFromPeer(string parentId, string targetId, Func? condi { if (!this.Nodes.TryGetValue(sourceId, out ModelNode? sourceNode)) { - throw new UnknownActionException($"Unresolved step: {sourceId}."); + throw new DeclarativeModelException($"Unresolved step: {sourceId}."); } this.Links.Add(new ModelLink(sourceNode, targetId, condition)); @@ -91,7 +91,7 @@ public void ConnectNodes(WorkflowBuilder workflowBuilder) { if (!this.Nodes.TryGetValue(link.TargetId, out ModelNode? targetNode)) { - throw new WorkflowModelException($"Unresolved target for {link.Source.Id}: {link.TargetId}."); + throw new DeclarativeModelException($"Unresolved target for {link.Source.Id}: {link.TargetId}."); } Debug.WriteLine($"> CONNECT: {link.Source.Id} => {link.TargetId}{(link.Condition is null ? string.Empty : " (?)")}"); @@ -120,7 +120,7 @@ private ModelNode DefineNode(Executor executor, ModelNode? parentNode = null, Ty { if (!this.Nodes.TryGetValue(itemId, out ModelNode? itemNode)) { - throw new UnknownActionException($"Unresolved child: {itemId}."); + throw new DeclarativeModelException($"Unresolved child: {itemId}."); } if (itemNode.ExecutorType == typeof(TAction)) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs index aae1403ecd..fbbc77c48d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowState.cs @@ -27,6 +27,7 @@ internal sealed class DeclarativeWorkflowState private readonly RecalcEngine _engine; private readonly WorkflowScopes _scopes; private WorkflowExpressionEngine? _expressionEngine; + private int _isInitialized; public DeclarativeWorkflowState(RecalcEngine engine, WorkflowScopes? scopes = null) { @@ -67,7 +68,7 @@ public async ValueTask SetAsync(string scopeName, string varName, FormulaValue v { if (!s_mutableScopes.Contains(scopeName)) { - throw new InvalidScopeException($"Invalid scope: {scopeName}"); + throw new DeclarativeModelException($"Invalid scope: {scopeName}"); } this._scopes.Set(varName, value, scopeName); @@ -82,6 +83,11 @@ public async ValueTask SetAsync(string scopeName, string varName, FormulaValue v public async ValueTask RestoreAsync(IWorkflowContext context, CancellationToken cancellationToken) { + if (Interlocked.CompareExchange(ref this._isInitialized, 1, 0) == 1) + { + return; + } + await Task.WhenAll(s_mutableScopes.Select(scopeName => ReadScopeAsync(scopeName).AsTask())).ConfigureAwait(false); async ValueTask ReadScopeAsync(string scopeName) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index d1fb71ae50..c33b76a3d6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -459,7 +459,7 @@ private void ContinueWith( private static string GetParentId(BotElement item) => item.GetParentId() ?? - throw new UnknownActionException($"Missing parent ID for action element: {item.GetId()} [{item.GetType().Name}]."); + throw new DeclarativeModelException($"Missing parent ID for action element: {item.GetId()} [{item.GetType().Name}]."); private string ContinuationFor(string parentId) => this.ContinuationFor(parentId, parentId); @@ -505,6 +505,6 @@ private void Trace(DialogAction item) private static string FormatParent(BotElement element) => element.Parent is null ? - throw new WorkflowModelException($"Undefined parent for {element.GetType().Name} that is member of {element.GetId()}.") : + throw new DeclarativeModelException($"Undefined parent for {element.GetType().Name} that is member of {element.GetId()}.") : $"{element.Parent.GetType().Name} ({element.GetParentId()})"; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs index 2c41859309..6b262bd229 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs @@ -22,7 +22,7 @@ internal sealed class EditTableExecutor(EditTable model, DeclarativeWorkflowStat FormulaValue table = this.State.Get(variablePath); if (table is not TableValue tableValue) { - throw new WorkflowExecutionException($"Require '{variablePath.Format()}' to be a table, not: '{table.GetType().Name}'."); + throw new DeclarativeActionException($"Require '{variablePath.Format()}' to be a table, not: '{table.GetType().Name}'."); } TableChangeType changeType = this.Model.ChangeType.Value; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs index 60fc91780a..8b771f4e50 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs @@ -22,7 +22,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model, DeclarativeWorkflow FormulaValue table = this.State.Get(variablePath); if (table is not TableValue tableValue) { - throw new WorkflowExecutionException($"Require '{variablePath.Format()}' to be a table, not: '{table.GetType().Name}'."); + throw new DeclarativeActionException($"Require '{variablePath.Format()}' to be a table, not: '{table.GetType().Name}'."); } EditTableOperation? changeType = this.Model.ChangeType; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs index 6104388a38..377c472a20 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/ParseValueExecutor.cs @@ -1,6 +1,7 @@  // Copyright (c) Microsoft. All rights reserved. +using System; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -51,19 +52,26 @@ internal sealed class ParseValueExecutor(ParseValue model, DeclarativeWorkflowSt if (parsedResult is null) { - throw new WorkflowExecutionException($"Unable to parse {expressionResult.Value.GetType().Name}"); + throw this.Exception("Unable to parse value."); } await this.AssignAsync(variablePath, parsedResult, context).ConfigureAwait(false); return default; - } - private static RecordValue ParseRecord(RecordDataType recordType, string rawText) - { - string jsonText = rawText.TrimJsonDelimiter(); - JsonDocument json = JsonDocument.Parse(jsonText); - JsonElement currentElement = json.RootElement; - return recordType.ParseRecord(currentElement); + RecordValue ParseRecord(RecordDataType recordType, string rawText) + { + string jsonText = rawText.TrimJsonDelimiter(); + JsonDocument json = JsonDocument.Parse(jsonText); + JsonElement currentElement = json.RootElement; + try + { + return recordType.ParseRecord(currentElement); + } + catch (Exception exception) + { + throw this.Exception("Failed to parse value.", exception); + } + } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs index 7a7d6dbf16..6a1ad46628 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs @@ -49,7 +49,7 @@ private static void InitializeDefaults(this WorkflowScopes scopes, SemanticModel { if (!SystemScope.AllNames.Contains(variableDiagnostic.Path.VariableName)) { - throw new UnsupportedVariableException($"Variable '{variableDiagnostic.Path.VariableName}' is not a supported system variable."); + throw new DeclarativeModelException($"Variable '{variableDiagnostic.Path.VariableName}' is not a supported system variable."); } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowExceptionTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowExceptionTest.cs index d60f4f8280..cd9f0c28d8 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowExceptionTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowExceptionTest.cs @@ -10,52 +10,20 @@ namespace Microsoft.Agents.Workflows.Declarative.UnitTests; /// public sealed class DeclarativeWorkflowExceptionTest(ITestOutputHelper output) : WorkflowTest(output) { - [Fact] - public void InvalidScopeException() - { - AssertDefault(() => throw new UnsupportedVariableException()); - AssertMessage((message) => throw new UnsupportedVariableException(message)); - AssertInner((message, inner) => throw new UnsupportedVariableException(message, inner)); - } - - [Fact] - public void InvalidSegmentException() - { - AssertDefault(() => throw new InvalidSegmentException()); - AssertMessage((message) => throw new InvalidSegmentException(message)); - AssertInner((message, inner) => throw new InvalidSegmentException(message, inner)); - } - - [Fact] - public void UnknownActionException() - { - AssertDefault(() => throw new UnknownActionException()); - AssertMessage((message) => throw new UnknownActionException(message)); - AssertInner((message, inner) => throw new UnknownActionException(message, inner)); - } - - [Fact] - public void UnknownDataTypeException() - { - AssertDefault(() => throw new UnknownDataTypeException()); - AssertMessage((message) => throw new UnknownDataTypeException(message)); - AssertInner((message, inner) => throw new UnknownDataTypeException(message, inner)); - } - [Fact] public void WorkflowExecutionException() { - AssertDefault(() => throw new WorkflowExecutionException()); - AssertMessage((message) => throw new WorkflowExecutionException(message)); - AssertInner((message, inner) => throw new WorkflowExecutionException(message, inner)); + AssertDefault(() => throw new DeclarativeActionException()); + AssertMessage((message) => throw new DeclarativeActionException(message)); + AssertInner((message, inner) => throw new DeclarativeActionException(message, inner)); } [Fact] public void WorkflowModelException() { - AssertDefault(() => throw new WorkflowModelException()); - AssertMessage((message) => throw new WorkflowModelException(message)); - AssertInner((message, inner) => throw new WorkflowModelException(message, inner)); + AssertDefault(() => throw new DeclarativeModelException()); + AssertMessage((message) => throw new DeclarativeModelException(message)); + AssertInner((message, inner) => throw new DeclarativeModelException(message, inner)); } private static void AssertDefault(Action throwAction) where TException : Exception diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index cf6f618ecc..55f6be1820 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -29,7 +29,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow [InlineData("BadKind.yaml")] public async Task InvalidWorkflow(string workflowFile) { - await Assert.ThrowsAsync(() => this.RunWorkflow(workflowFile)); + await Assert.ThrowsAsync(() => this.RunWorkflow(workflowFile)); this.AssertNotExecuted("end_all"); } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Interpreter/WorkflowModelTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Interpreter/WorkflowModelTest.cs index fe9af0c044..75e8056e70 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Interpreter/WorkflowModelTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/Interpreter/WorkflowModelTest.cs @@ -24,7 +24,7 @@ public async Task GetDepthForDefault() public async Task GetDepthForMissingNode() { DeclarativeWorkflowModel model = new(this.CreateExecutor("root")); - Assert.Throws(() => model.GetDepth("missing")); + Assert.Throws(() => model.GetDepth("missing")); } [Fact] @@ -34,21 +34,21 @@ public async Task ConnectMissingNode() DeclarativeWorkflowModel model = new(rootExecutor); model.AddLink("root", "missing"); WorkflowBuilder workflowBuilder = new(rootExecutor); - Assert.Throws(() => model.ConnectNodes(workflowBuilder)); + Assert.Throws(() => model.ConnectNodes(workflowBuilder)); } [Fact] public async Task AddToMissingParent() { DeclarativeWorkflowModel model = new(this.CreateExecutor("root")); - Assert.Throws(() => model.AddNode(this.CreateExecutor("next"), "missing")); + Assert.Throws(() => model.AddNode(this.CreateExecutor("next"), "missing")); } [Fact] public async Task LinkFromMissingSource() { DeclarativeWorkflowModel model = new(this.CreateExecutor("root")); - Assert.Throws(() => model.AddLink("missing", "anything")); + Assert.Throws(() => model.AddLink("missing", "anything")); } [Fact] @@ -56,7 +56,7 @@ public async Task LocateMissingParent() { DeclarativeWorkflowModel model = new(this.CreateExecutor("root")); Assert.Null(model.LocateParent(null)); - Assert.Throws(() => model.LocateParent("missing")); + Assert.Throws(() => model.LocateParent("missing")); } private TestExecutor CreateExecutor(string id) => new(id); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetVariableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetVariableExecutorTest.cs index 12b5125031..a6ff29a597 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetVariableExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SetVariableExecutorTest.cs @@ -17,7 +17,7 @@ public sealed class SetVariableExecutorTest(ITestOutputHelper output) : Workflow public void InvalidModel() { // Arrange, Act, Assert - Assert.Throws(() => new SetVariableExecutor(new SetVariable(), this.GetState())); + Assert.Throws(() => new SetVariableExecutor(new SetVariable(), this.GetState())); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs index 6fa9667143..cd2f6c05ea 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs @@ -128,7 +128,7 @@ public void FormatExpressionSegmentUndefined() RecalcEngine engine = this.CreateEngine(); // Act & Assert - Assert.Throws(() => engine.Format(line)); + Assert.Throws(() => engine.Format(line)); } [Fact] From db6795e71d1017bd4c6b92cc66118badee42822a Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 28 Aug 2025 15:25:52 -0700 Subject: [PATCH 224/232] Exception message --- .../DeclarativeWorkflowBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 67cf1b56ae..5a01cd263f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -48,7 +48,7 @@ public static Workflow Build( Func? inputTransform = null) where TInput : notnull { - BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new DeclarativeModelException("Unable to parse workflow."); + BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new DeclarativeModelException("Workflow undefined."); // ISSUE #486 - Use "Workflow" element for Foundry. if (rootElement is not AdaptiveDialog workflowElement) From 45509e92c6ad780b642f4b93b29aaaa88581b6f8 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 28 Aug 2025 15:32:51 -0700 Subject: [PATCH 225/232] Clean-up --- .../ObjectModel/EditTableExecutor.cs | 2 +- .../ObjectModel/EditTableV2Executor.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs index 6b262bd229..0c09b270fe 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableExecutor.cs @@ -22,7 +22,7 @@ internal sealed class EditTableExecutor(EditTable model, DeclarativeWorkflowStat FormulaValue table = this.State.Get(variablePath); if (table is not TableValue tableValue) { - throw new DeclarativeActionException($"Require '{variablePath.Format()}' to be a table, not: '{table.GetType().Name}'."); + throw this.Exception($"Require '{variablePath.Format()}' to be a table, not: '{table.GetType().Name}'."); } TableChangeType changeType = this.Model.ChangeType.Value; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs index 8b771f4e50..c18e87dce6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/EditTableV2Executor.cs @@ -22,7 +22,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model, DeclarativeWorkflow FormulaValue table = this.State.Get(variablePath); if (table is not TableValue tableValue) { - throw new DeclarativeActionException($"Require '{variablePath.Format()}' to be a table, not: '{table.GetType().Name}'."); + throw this.Exception($"Require '{variablePath.Format()}' to be a table, not: '{table.GetType().Name}'."); } EditTableOperation? changeType = this.Model.ChangeType; From 5bd44b20be2420e19507da14620d8f1fa6fd2629 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 29 Aug 2025 10:33:08 -0700 Subject: [PATCH 226/232] Sample config update --- dotnet/demos/DeclarativeWorkflow/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index 7b2f123b05..ba1ffdf597 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -240,6 +240,7 @@ private string GetWorkflowInput() private static IConfigurationRoot InitializeConfig() => new ConfigurationBuilder() .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables() .Build(); private static void Notify(string message) From 2706cb85d446a4a2d95a895048e8b5942275a124 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 29 Aug 2025 14:00:03 -0700 Subject: [PATCH 227/232] Update handling of "Env" scope --- dotnet/demos/DeclarativeWorkflow/Program.cs | 41 ++++++++++++++++--- .../DeclarativeWorkflowBuilder.cs | 2 +- .../DeclarativeWorkflowOptions.cs | 6 +++ .../PowerFx/WorkflowDiagnostics.cs | 9 ++-- 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/dotnet/demos/DeclarativeWorkflow/Program.cs b/dotnet/demos/DeclarativeWorkflow/Program.cs index ba1ffdf597..14519c2b90 100644 --- a/dotnet/demos/DeclarativeWorkflow/Program.cs +++ b/dotnet/demos/DeclarativeWorkflow/Program.cs @@ -20,14 +20,16 @@ namespace Demo.DeclarativeWorkflow; /// HOW TO: Create a workflow from a declartive (yaml based) definition. /// /// +/// Configuration +/// Define FOUNDRY_PROJECT_ENDPOINT as a user-secret or environment variable that +/// points to your Foundry project endpoint. +/// Usage /// Provide the path to the workflow definition file as the first argument. /// All other arguments are intepreted as a queue of inputs. /// When no input is queued, interactive input is requested from the console. /// internal sealed class Program { - private const string DefaultWorkflow = "HelloWorld.yaml"; - public static async Task Main(string[] args) { Program program = new(args); @@ -42,7 +44,11 @@ private async Task ExecuteAsync() Stopwatch timer = Stopwatch.StartNew(); // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. - DeclarativeWorkflowOptions options = new(new FoundryAgentProvider(this.FoundryEndpoint, new AzureCliCredential())); + DeclarativeWorkflowOptions options = + new(new FoundryAgentProvider(this.FoundryEndpoint, new AzureCliCredential())) + { + Configuration = this.Configuration + }; Workflow workflow = DeclarativeWorkflowBuilder.Build(this.WorkflowFile, options); Notify($"\nWORKFLOW: Defined {timer.Elapsed}"); @@ -57,6 +63,9 @@ private async Task ExecuteAsync() Notify("\nWORKFLOW: Done!"); } + private const string DefaultWorkflow = "HelloWorld.yaml"; + private const string ConfigKeyFoundryEndpoint = "FOUNDRY_PROJECT_ENDPOINT"; + private static readonly Dictionary s_nameCache = []; private static readonly HashSet s_fileCache = []; @@ -73,7 +82,7 @@ private Program(string[] args) this.Configuration = InitializeConfig(); - this.FoundryEndpoint = this.Configuration["AzureAI:Endpoint"] ?? throw new InvalidOperationException("Undefined configuration: AzureAI:Endpoint"); + this.FoundryEndpoint = this.Configuration[ConfigKeyFoundryEndpoint] ?? throw new InvalidOperationException($"Undefined configuration setting: {ConfigKeyFoundryEndpoint}"); this.FoundryClient = new PersistentAgentsClient(this.FoundryEndpoint, new AzureCliCredential()); } @@ -183,7 +192,12 @@ private static string ParseWorkflowFile(string[] args) if (!File.Exists(workflowFile) && !Path.IsPathFullyQualified(workflowFile)) { - workflowFile = Path.Combine(@"..\..\..\..\..\..\Workflows", workflowFile); + string? repoFolder = GetRepoFolder(); + if (repoFolder is not null) + { + workflowFile = Path.Combine(repoFolder, "Workflows", workflowFile); + workflowFile = Path.ChangeExtension(workflowFile, ".yaml"); + } } if (!File.Exists(workflowFile)) @@ -192,6 +206,23 @@ private static string ParseWorkflowFile(string[] args) } return workflowFile; + + static string? GetRepoFolder() + { + DirectoryInfo? current = new(Directory.GetCurrentDirectory()); + + while (current is not null) + { + if (Directory.Exists(Path.Combine(current.FullName, ".git"))) + { + return current.FullName; + } + + current = current.Parent; + } + + return null; + } } private string GetWorkflowInput() diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index 5a01cd263f..97a7d93328 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -59,7 +59,7 @@ public static Workflow Build( string rootId = WorkflowActionVisitor.RootId(workflowElement.BeginDialog?.Id.Value ?? "workflow"); WorkflowScopes scopes = new(); - scopes.Initialize(WrapWithBot(workflowElement)); + scopes.Initialize(WrapWithBot(workflowElement), options.Configuration); DeclarativeWorkflowState state = new(options.CreateRecalcEngine(), scopes); DeclarativeWorkflowExecutor rootExecutor = new(rootId, diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs index 68c2e93641..1f61d27253 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowOptions.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.Diagnostics; @@ -16,6 +17,11 @@ public sealed class DeclarativeWorkflowOptions(WorkflowAgentProvider agentProvid /// public WorkflowAgentProvider AgentProvider { get; } = Throw.IfNull(agentProvider, nameof(agentProvider)); + /// + /// Defines the configuration settings for the workflow. + /// + public IConfiguration? Configuration { get; init; } + /// /// Optionally identifies a continued workflow conversation. /// diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs index 6a1ad46628..5bf22d92cf 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/PowerFx/WorkflowDiagnostics.cs @@ -7,6 +7,7 @@ using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.Bot.ObjectModel.Analysis; using Microsoft.Bot.ObjectModel.PowerFx; +using Microsoft.Extensions.Configuration; using Microsoft.PowerFx.Types; namespace Microsoft.Agents.Workflows.Declarative.PowerFx; @@ -15,20 +16,20 @@ internal static class WorkflowDiagnostics { private static readonly WorkflowFeatureConfiguration s_semanticFeatureConfig = new(); - public static void Initialize(this WorkflowScopes scopes, TElement workflowElement) where TElement : BotElement, IDialogBase + public static void Initialize(this WorkflowScopes scopes, TElement workflowElement, IConfiguration? configuration) where TElement : BotElement, IDialogBase { scopes.InitializeSystem(); SemanticModel semanticModel = workflowElement.GetSemanticModel(new PowerFxExpressionChecker(s_semanticFeatureConfig), s_semanticFeatureConfig); - scopes.InitializeEnvironment(semanticModel); + scopes.InitializeEnvironment(semanticModel, configuration); scopes.InitializeDefaults(semanticModel, workflowElement.SchemaName.Value); } - private static void InitializeEnvironment(this WorkflowScopes scopes, SemanticModel semanticModel) + private static void InitializeEnvironment(this WorkflowScopes scopes, SemanticModel semanticModel, IConfiguration? configuration) { foreach (string variableName in semanticModel.GetAllEnvironmentVariablesReferencedInTheBot()) { - string? environmentValue = Environment.GetEnvironmentVariable(variableName); + string? environmentValue = configuration is not null ? configuration[variableName] : Environment.GetEnvironmentVariable(variableName); FormulaValue variableValue = string.IsNullOrEmpty(environmentValue) ? FormulaType.String.NewBlank() : FormulaValue.New(environmentValue); scopes.Set(variableName, variableValue, VariableScopeNames.Environment); } From 45c896d89514bebdbab53cbf5d5c526b53315150 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 29 Aug 2025 14:19:27 -0700 Subject: [PATCH 228/232] Sample agent templates --- .../{readme.md => README.md} | 36 +- workflows/DeepResearch.yaml | 23 +- workflows/HelloWorld.yaml | 2 +- workflows/MathChat.yaml | 12 +- workflows/Question.yaml | 3 +- workflows/README.md | 13 +- workflows/setup/.gitignore | 405 ++++++++++++++++++ workflows/setup/AnalystAgent.yaml | 10 + workflows/setup/CoderAgent.yaml | 7 + workflows/setup/Create.ps1 | 3 + .../setup/CreateAgents/CreateAgents.csproj | 20 + .../setup/CreateAgents/CreateAgents.slnx | 14 + workflows/setup/CreateAgents/Program.cs | 68 +++ workflows/setup/ManagerAgent.yaml | 5 + workflows/setup/QuestionAgent.yaml | 10 + workflows/setup/StudentAgent.yaml | 10 + workflows/setup/TeacherAgent.yaml | 10 + workflows/setup/WeatherAgent.yaml | 62 +++ workflows/setup/WebAgent.yaml | 15 + 19 files changed, 708 insertions(+), 20 deletions(-) rename dotnet/demos/DeclarativeWorkflow/{readme.md => README.md} (60%) create mode 100644 workflows/setup/.gitignore create mode 100644 workflows/setup/AnalystAgent.yaml create mode 100644 workflows/setup/CoderAgent.yaml create mode 100644 workflows/setup/Create.ps1 create mode 100644 workflows/setup/CreateAgents/CreateAgents.csproj create mode 100644 workflows/setup/CreateAgents/CreateAgents.slnx create mode 100644 workflows/setup/CreateAgents/Program.cs create mode 100644 workflows/setup/ManagerAgent.yaml create mode 100644 workflows/setup/QuestionAgent.yaml create mode 100644 workflows/setup/StudentAgent.yaml create mode 100644 workflows/setup/TeacherAgent.yaml create mode 100644 workflows/setup/WeatherAgent.yaml create mode 100644 workflows/setup/WebAgent.yaml diff --git a/dotnet/demos/DeclarativeWorkflow/readme.md b/dotnet/demos/DeclarativeWorkflow/README.md similarity index 60% rename from dotnet/demos/DeclarativeWorkflow/readme.md rename to dotnet/demos/DeclarativeWorkflow/README.md index cb7252ffcd..5229bd50c2 100644 --- a/dotnet/demos/DeclarativeWorkflow/readme.md +++ b/dotnet/demos/DeclarativeWorkflow/README.md @@ -7,10 +7,18 @@ be executed using the same pattern as any code-based workflow. This demo requires configuration to access agents an [Azure Foundry Project](https://learn.microsoft.com/azure/ai-foundry). +#### Settings + We suggest using .NET [Secret Manager](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets) to avoid the risk of leaking secrets into the repository, branches and pull requests. You can also use environment variables if you prefer. +To set your secrets as an environment variable (PowerShell): + +```pwsh +$env:FOUNDRY_PROJECT_ENDPOINT="https://..." +``` + To set your secrets with .NET Secret Manager: 1. From the root of the respository, navigate the console to the project folder: @@ -34,16 +42,27 @@ To set your secrets with .NET Secret Manager: 4. Define setting that identifies your Azure Foundry Project (endpoint): ``` - dotnet user-secrets set "AzureAI:Endpoint" "https://..." + dotnet user-secrets set "FOUNDRY_PROJECT_ENDPOINT" "https://..." ``` -5. Use [_Azure CLI_](https://learn.microsoft.com/cli/azure/authenticate-azure-cli) to authorize access to your Azure Foundry Project: +#### Authorization + +Use [_Azure CLI_](https://learn.microsoft.com/cli/azure/authenticate-azure-cli) to authorize access to your Azure Foundry Project: ``` az login az account get-access-token ``` +#### Agents + +The sample workflows rely on agents defined in your Azure Foundry Project. + +To create agents, run the [`Create.ps1`](../../../workflows/) script. +This will create the agents used in the sample workflows in your Azure Foundry Project and format a script you can copy and use to configure your environment. + +> Note: `Create.ps1` relies upon the `FOUNDRY_PROJECT_ENDPOINT` setting. + ## Execution Run the demo from the console by specifying a path to a declarative (YAML) workflow file. @@ -51,12 +70,19 @@ The repository has example workflows available in the root [`/workflows`](../../ 1. From the root of the respository, navigate the console to the project folder: - ``` + ```sh cd dotnet/demos/DeclarativeWorkflow + ``` -2. Run the demo with a path to a workflow file: +2. Run the demo referencing a sample workflow by name: + ```sh + dotnet run HelloWorld ``` - dotnet run ../../../workflows/HelloWorld.yaml + +3. Run the demo with a path to any workflow file: + + ```sh + dotnet run c:/myworkflows/HelloWorld.yaml ``` diff --git a/workflows/DeepResearch.yaml b/workflows/DeepResearch.yaml index 3bb79968ef..d9482d5f6f 100644 --- a/workflows/DeepResearch.yaml +++ b/workflows/DeepResearch.yaml @@ -5,11 +5,14 @@ # # 1. Analyst Agent: Able to analyze the current task. # Enable "Bing Grounding Tool" in the agent settings. +# See: ./setup/AnalystAgent.yaml # # 2. Manager Agent: Able to create plans and delegate tasks to other agents. +# See: ./setup/ManagerAgent.yaml # # 3. Research Agent: # Enable "Bing Grounding" in the agent settings. +# See: ./setup/WebAgent.yaml # # With instructions: # @@ -20,9 +23,11 @@ # # 4. Coder Agent: # Enable "Code Interpreter" in the agent settings. +# See: ./setup/CoderAgent.yaml # # 5. Weather Agent: Able to retrieve factual information from the web. # Enable "Open API" in the agent settings using the wttr.json schema. +# See: ./setup/WeatherAgent.yaml # kind: AdaptiveDialog beginDialog: @@ -40,17 +45,17 @@ beginDialog: { name: "WeatherAgent", description: "Able to retrieve weather information", - agentid: "asst_oujtcQzC1TtrzC7mBpQogqMn" + agentid: Env.FOUNDRY_AGENT_RESEARCHWEATHER }, { name: "CoderAgent", description: "Able to write and execute Python code", - agentid: "asst_7QIO6YE66Tt7sNwNhQ7mOfvL" + agentid: Env.FOUNDRY_AGENT_RESEARCHCODER }, { name: "WebAgent", description: "Able to perform generic websearches", - agentid: "asst_Of5U92uEjhiB5CsaUDW5LIX6" + agentid: Env.FOUNDRY_AGENT_RESEARCHWEB } ] @@ -83,7 +88,7 @@ beginDialog: variable: Topic.TaskFacts userInput: =Topic.InputTask additionalInstructions: |- - asst_UDoMUw9SuPCq33HqH95YPT69, + {Env.FOUNDRY_AGENT_RESEARCHANALYST}, In order to help begin addressing the user request, please answer the following pre-survey to the best of your ability. Keep in mind that you are Ken Jennings-level with trivia, and Mensa-level with puzzles, so there should be a deep well to draw from. @@ -114,7 +119,7 @@ beginDialog: variable: Topic.Plan userInput: ="" additionalInstructions: |- - asst_o3BQkfUzEGXMNzVY5X54yHhq, + {Env.FOUNDRY_AGENT_RESEARCHMANAGER}, Your only job is to devise an efficient plan that identifies (by name) how a team member may contribute to addressing the user request. Only select the following team which is listed as "- [Name]: [Description]" @@ -164,7 +169,7 @@ beginDialog: variable: Topic.ProgressLedgerUpdate userInput: =Topic.AgentResponseText additionalInstructions: |- - asst_o3BQkfUzEGXMNzVY5X54yHhq, + {Env.FOUNDRY_AGENT_RESEARCHMANAGER}, Recall we are working on the following request: {Topic.InputTask} @@ -267,7 +272,7 @@ beginDialog: variable: Topic.FinalResponse userInput: =Topic.SeedTask additionalInstructions: |- - asst_o3BQkfUzEGXMNzVY5X54yHhq, + {Env.FOUNDRY_AGENT_RESEARCHMANAGER}, We have completed the task. Based only on the conversation and without adding any new information, synthesize the result of the conversation as a complete response to the user task. The user will only every see this last response and not the entire conversation, so please ensure it is complete and self-contained. @@ -347,7 +352,7 @@ beginDialog: " & Topic.InputTask additionalInstructions: |- - asst_UDoMUw9SuPCq33HqH95YPT69, + {Env.FOUNDRY_AGENT_RESEARCHANALYST}, It's clear we aren't making as much progress as we would like, but we may have learned something new. Please rewrite the following fact sheet, updating it to include anything new we have learned that may be helpful. Example edits can include (but are not limited to) adding new guesses, moving educated guesses to verified facts if appropriate, etc. @@ -369,7 +374,7 @@ beginDialog: variable: Topic.Plan userInput: ="" additionalInstructions: |- - asst_o3BQkfUzEGXMNzVY5X54yHhq, + {Env.FOUNDRY_AGENT_RESEARCHMANAGER}, Please briefly explain what went wrong on this last run (the root cause of the failure), and then come up with a new plan that takes steps and/or includes hints to overcome prior challenges and especially avoids repeating the same mistakes. As before, the new plan should be concise, be expressed in bullet-point form, and consider the following team composition diff --git a/workflows/HelloWorld.yaml b/workflows/HelloWorld.yaml index efb695dc1d..8fc434ed60 100644 --- a/workflows/HelloWorld.yaml +++ b/workflows/HelloWorld.yaml @@ -1,7 +1,7 @@ # # This workflow provides the most basic example of providing a response that includes the user and environment input. # -# No setup is required to run this workflow. +# No agent setup is required to run this workflow. # kind: AdaptiveDialog beginDialog: diff --git a/workflows/MathChat.yaml b/workflows/MathChat.yaml index e115082988..117a241d02 100644 --- a/workflows/MathChat.yaml +++ b/workflows/MathChat.yaml @@ -5,12 +5,20 @@ # For this workflow, two agents are used, each with a prompt specific to their role. # # Student: +# See: ./setup/StudentAgent.yaml +# +# With instructions: +# # Your job is help a math teacher practice teaching by making intentional mistakes. # You Attempt to solve the given math problem, but with intentional mistakes so the teacher can help. # Always incorporate the teacher's advice to fix your next response. # You have the math-skills of a 6th grader. # Teacher: +# See: ./setup/TeacherAgent.yaml +# +# With instructions: +# # Review and coach the student's approach to solving the given math problem. # Don't repeat the solution or try and solve it. # If the student has demonstrated comprehension and responded to all of your feedback, @@ -31,7 +39,7 @@ beginDialog: - kind: AnswerQuestionWithAI id: question_student userInput: =Topic.Project - additionalInstructions: asst_jCrWfBd1XrfVXMtpbfyVoIHC + additionalInstructions: {Env.FOUNDRY_AGENT_STUDENT} - kind: ResetVariable id: reset_project @@ -40,7 +48,7 @@ beginDialog: - kind: AnswerQuestionWithAI id: question_teacher userInput: ="" - additionalInstructions: asst_aArB2g4tFWOTcgmUua062wjM + additionalInstructions: {Env.FOUNDRY_AGENT_TEACHER} - kind: SetVariable id: set_count_increment diff --git a/workflows/Question.yaml b/workflows/Question.yaml index a89fd3a934..22a9d7b6d4 100644 --- a/workflows/Question.yaml +++ b/workflows/Question.yaml @@ -2,6 +2,7 @@ # This workflow demonstrates a single agent interaction based on user input. # # Any Foundry Agent may be used to provide the response. +# See: ./setup/QuestionAgent.yaml # kind: AdaptiveDialog beginDialog: @@ -20,4 +21,4 @@ beginDialog: id: question_demo variable: Topic.Answer userInput: =System.LastMessage.Text - additionalInstructions: asst_orsBf06Bxz9B1hjVjiiQoPqf + additionalInstructions: {Env.FOUNDRY_AGENT_ANSWER} diff --git a/workflows/README.md b/workflows/README.md index 9522c4ae24..0d3b0a3c44 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -1,7 +1,7 @@ # No-Code Workflows This folder contains sample workflow definitions than be ran using the -[Declarative Workflow](./dotnet/demos/DeclarativeWorkflow) demo. +[Declarative Workflow](../dotnet/demos/DeclarativeWorkflow) demo. Each workflow is defined in a single YAML file and contains comments with additional information specific to that workflow. @@ -15,4 +15,13 @@ Workflow workflow = DeclarativeWorkflowBuilder.Build("HelloWorld Workflows may also be hosted in your _Azure Foundry Project_. -> _Python_ support in the works! \ No newline at end of file +> _Python_ support in the works! + +#### Agents + +The sample workflows rely on agents defined in your Azure Foundry Project. + +To create agents, run the [`Create.ps1`](./setup) script. +This will create the agents used in the sample workflows in your Azure Foundry Project and format a script you can copy and use to configure your environment. + +> Note: `Create.ps1` relies upon the `FOUNDRY_PROJECT_ENDPOINT` setting. See [README.md](../dotnet/demos/DeclarativeWorkflow/README.md) from the demo for configuration details. diff --git a/workflows/setup/.gitignore b/workflows/setup/.gitignore new file mode 100644 index 0000000000..ce1409abe9 --- /dev/null +++ b/workflows/setup/.gitignore @@ -0,0 +1,405 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml \ No newline at end of file diff --git a/workflows/setup/AnalystAgent.yaml b/workflows/setup/AnalystAgent.yaml new file mode 100644 index 0000000000..233e65cb22 --- /dev/null +++ b/workflows/setup/AnalystAgent.yaml @@ -0,0 +1,10 @@ +type: foundry_agent +name: ResearchAnalyst +description: Demo agent for DeepResearch workflow +model: + id: ${AzureAI:ModelDeployment} +tools: + - type: bing_grounding + options: + tool_connections: + - ${AzureAI:BingConnectionId} \ No newline at end of file diff --git a/workflows/setup/CoderAgent.yaml b/workflows/setup/CoderAgent.yaml new file mode 100644 index 0000000000..36aa77a7cb --- /dev/null +++ b/workflows/setup/CoderAgent.yaml @@ -0,0 +1,7 @@ +type: foundry_agent +name: ResearchCoder +description: Demo agent for DeepResearch workflow +model: + id: ${AzureAI:ModelDeployment} +tools: + - type: code_interpreter diff --git a/workflows/setup/Create.ps1 b/workflows/setup/Create.ps1 new file mode 100644 index 0000000000..17c7f27fd0 --- /dev/null +++ b/workflows/setup/Create.ps1 @@ -0,0 +1,3 @@ +pushd ./CreateAgents +dotnet run +popd \ No newline at end of file diff --git a/workflows/setup/CreateAgents/CreateAgents.csproj b/workflows/setup/CreateAgents/CreateAgents.csproj new file mode 100644 index 0000000000..4b00b2279f --- /dev/null +++ b/workflows/setup/CreateAgents/CreateAgents.csproj @@ -0,0 +1,20 @@ + + + + Exe + net9.0 + enable + enable + 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 + SKEXP0110 + + + + + + + + + + + diff --git a/workflows/setup/CreateAgents/CreateAgents.slnx b/workflows/setup/CreateAgents/CreateAgents.slnx new file mode 100644 index 0000000000..b4270f563c --- /dev/null +++ b/workflows/setup/CreateAgents/CreateAgents.slnx @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/workflows/setup/CreateAgents/Program.cs b/workflows/setup/CreateAgents/Program.cs new file mode 100644 index 0000000000..ebd9243c50 --- /dev/null +++ b/workflows/setup/CreateAgents/Program.cs @@ -0,0 +1,68 @@ +using Azure.AI.Agents.Persistent; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.AzureAI; +using System.Reflection; +using System.Text; + +// Define FOUNDRY_PROJECT_ENDPOINT as a user-secret or environment variable that +// points to your Foundry project endpoint. + +IConfigurationRoot config = + new ConfigurationBuilder() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables() + .Build(); + +string projectEndpoint = config["FOUNDRY_PROJECT_ENDPOINT"] ?? throw new InvalidOperationException("Undefined configuration: FOUNDRY_PROJECT_ENDPOINT"); +Console.WriteLine($"{Environment.NewLine}Foundry: {projectEndpoint}"); + +StringBuilder scriptBuilder = new(); +StringBuilder secretBuilder = new(); +string[] files = args.Length > 0 ? args : Directory.GetFiles(@"..\", "*.yaml"); +foreach (string file in files) +{ + string agentText = await File.ReadAllTextAsync(file); + + PersistentAgentsClient clientAgents = new(projectEndpoint, new AzureCliCredential()); + + AIProjectClient clientProject = new(new Uri(projectEndpoint), new AzureCliCredential()); + + IKernelBuilder kernelBuilder = Kernel.CreateBuilder(); + kernelBuilder.Services.AddSingleton(clientAgents); + kernelBuilder.Services.AddSingleton(clientProject); + Kernel kernel = kernelBuilder.Build(); + + AzureAIAgentFactory factory = new(); + Agent? agent = await factory.CreateAgentFromYamlAsync(agentText, new AgentCreationOptions() { Kernel = kernel }, config); + if (agent is null) + { + Console.WriteLine("Unexpected failure creating agent..."); + continue; + } + + Console.WriteLine(); + Console.WriteLine(Path.GetFileName(file)); + Console.WriteLine($" Id: {agent?.Id ?? "???"}"); + Console.WriteLine($" Name: {agent?.Name ?? agent?.Id}"); + Console.WriteLine($" Note: {agent?.Description}"); + + scriptBuilder.AppendLine($"$env:FOUNDRY_AGENT_{agent?.Name?.ToUpperInvariant()} = '{agent?.Id}'"); + secretBuilder.AppendLine($"dotnet user-secrets set FOUNDRY_AGENT_{agent?.Name?.ToUpperInvariant()} {agent?.Id}"); +} + +Console.WriteLine(); +Console.WriteLine(); +Console.WriteLine("To set these environment variables in your shell, run:"); +Console.WriteLine(); +Console.WriteLine(scriptBuilder); +Console.WriteLine(); +Console.WriteLine(); +Console.WriteLine("To define user secrets, run:"); +Console.WriteLine(); +Console.WriteLine(secretBuilder); +Console.WriteLine(); diff --git a/workflows/setup/ManagerAgent.yaml b/workflows/setup/ManagerAgent.yaml new file mode 100644 index 0000000000..14f718fdf6 --- /dev/null +++ b/workflows/setup/ManagerAgent.yaml @@ -0,0 +1,5 @@ +type: foundry_agent +name: ResearchManager +description: Demo agent for DeepResearch workflow +model: + id: ${AzureAI:ModelDeployment} diff --git a/workflows/setup/QuestionAgent.yaml b/workflows/setup/QuestionAgent.yaml new file mode 100644 index 0000000000..1952259bd6 --- /dev/null +++ b/workflows/setup/QuestionAgent.yaml @@ -0,0 +1,10 @@ +type: foundry_agent +name: Answer +description: Demo agent for Question workflow +model: + id: ${AzureAI:ModelDeployment} +tools: + - type: bing_grounding + options: + tool_connections: + - ${AzureAI:BingConnectionId} \ No newline at end of file diff --git a/workflows/setup/StudentAgent.yaml b/workflows/setup/StudentAgent.yaml new file mode 100644 index 0000000000..fa9c70c7d7 --- /dev/null +++ b/workflows/setup/StudentAgent.yaml @@ -0,0 +1,10 @@ +type: foundry_agent +name: Student +description: Student agent for MathChat workflow +instructions: |- + Your job is help a math teacher practice teaching by making intentional mistakes. + You Attempt to solve the given math problem, but with intentional mistakes so the teacher can help. + Always incorporate the teacher's advice to fix your next response. + You have the math-skills of a 6th grader. +model: + id: ${AzureAI:ModelDeployment} diff --git a/workflows/setup/TeacherAgent.yaml b/workflows/setup/TeacherAgent.yaml new file mode 100644 index 0000000000..27f2b2c761 --- /dev/null +++ b/workflows/setup/TeacherAgent.yaml @@ -0,0 +1,10 @@ +type: foundry_agent +name: Teacher +description: Teacher agent for MathChat workflow +instructions: |- + Review and coach the student's approach to solving the given math problem. + Don't repeat the solution or try and solve it. + If the student has demonstrated comprehension and responded to all of your feedback, + give the student your congraluations by using the word "congratulations". +model: + id: ${AzureAI:ModelDeployment} diff --git a/workflows/setup/WeatherAgent.yaml b/workflows/setup/WeatherAgent.yaml new file mode 100644 index 0000000000..500901997b --- /dev/null +++ b/workflows/setup/WeatherAgent.yaml @@ -0,0 +1,62 @@ +type: foundry_agent +name: ResearchWeather +description: Demo agent for DeepResearch workflow +model: + id: ${AzureAI:ModelDeployment} +tools: + - type: openapi + id: GetCurrentWeather + description: Retrieves current weather data for a location based on wttr.in. + options: + specification: | + { + "openapi": "3.1.0", + "info": { + "title": "Get weather data", + "description": "Retrieves current weather data for a location based on wttr.in.", + "version": "v1.0.0" + }, + "servers": [ + { + "url": "https://wttr.in" + } + ], + "paths": { + "/{location}": { + "get": { + "description": "Get weather information for a specific location", + "operationId": "GetCurrentWeather", + "parameters": [ + { + "name": "location", + "in": "path", + "description": "City or location to retrieve the weather for", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Location not found" + } + }, + "deprecated": false + } + } + }, + "components": { + "schemas": {} + } + } diff --git a/workflows/setup/WebAgent.yaml b/workflows/setup/WebAgent.yaml new file mode 100644 index 0000000000..024dd68f5f --- /dev/null +++ b/workflows/setup/WebAgent.yaml @@ -0,0 +1,15 @@ +type: foundry_agent +name: ResearchWeb +description: Demo agent for DeepResearch workflow +instructions: |- + Only provide requested information in a way that is throughfully organized and formatted. + Never include any analysis or code. + Never generate a file. + Avoid repeating yourself. +model: + id: ${AzureAI:ModelDeployment} +tools: + - type: bing_grounding + options: + tool_connections: + - ${AzureAI:BingConnectionId} \ No newline at end of file From 04be61313626cdd5fd023a379ffc796bd87a428a Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 29 Aug 2025 14:29:31 -0700 Subject: [PATCH 229/232] Add readme --- workflows/setup/CreateAgents/CreateAgents.slnx | 1 + workflows/setup/README.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 workflows/setup/README.md diff --git a/workflows/setup/CreateAgents/CreateAgents.slnx b/workflows/setup/CreateAgents/CreateAgents.slnx index b4270f563c..7ff049246b 100644 --- a/workflows/setup/CreateAgents/CreateAgents.slnx +++ b/workflows/setup/CreateAgents/CreateAgents.slnx @@ -5,6 +5,7 @@ + diff --git a/workflows/setup/README.md b/workflows/setup/README.md new file mode 100644 index 0000000000..c8e9675ebf --- /dev/null +++ b/workflows/setup/README.md @@ -0,0 +1,14 @@ +# Agent Definitions + +The sample workflows rely on agents defined in your Azure Foundry Project. + +These agent definitions are based on _Semantic Kernel_'s _Declarative Agent_ feature: + +- [Semantic Kernel Agents](https://github.com/microsoft/semantic-kernel/tree/main/dotnet/src/Agents) +- [Declarative Agent Extensions](https://github.com/microsoft/semantic-kernel/tree/main/dotnet/src/Agents/Yaml) +- [Sample](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step08_AzureAIAgent_Declarative.cs) + +To create agents, run the [`Create.ps1`](./Create.ps1) script. +This will create the agents for the sample workflows in your Azure Foundry Project and format a script you can copy and use to configure your environment. + +> Note: `Create.ps1` relies upon the `FOUNDRY_PROJECT_ENDPOINT` setting. See [README.md](../../dotnet/demos/DeclarativeWorkflow/README.md) from the demo for configuration details. From 9b7e55ad7eae4fb7c6c3f917130bf66a5a36122b Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 2 Sep 2025 17:05:44 -0700 Subject: [PATCH 230/232] Event cleanup --- dotnet/samples/DeclarativeWorkflow/Program.cs | 20 +++++------ .../DeclarativeWorkflowEvent.cs | 10 ------ .../DeclarativeWorkflowInvokeEvent.cs | 4 +-- .../DeclarativeWorkflowMessageEvent.cs | 21 ----------- .../DeclarativeWorkflowStreamEvent.cs | 16 --------- .../AnswerQuestionWithAIExecutor.cs | 6 ++-- .../ObjectModel/SendActivityExecutor.cs | 4 ++- .../AgentRunResponseEvent.cs | 26 ++++++++++++++ .../DeclarativeWorkflowEventTest.cs | 36 ------------------- .../DeclarativeWorkflowTest.cs | 6 ++-- .../ObjectModel/SendActivityExecutorTest.cs | 2 +- 11 files changed, 48 insertions(+), 103 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowMessageEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows/AgentRunResponseEvent.cs delete mode 100644 dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowEventTest.cs diff --git a/dotnet/samples/DeclarativeWorkflow/Program.cs b/dotnet/samples/DeclarativeWorkflow/Program.cs index 14519c2b90..3c6460e251 100644 --- a/dotnet/samples/DeclarativeWorkflow/Program.cs +++ b/dotnet/samples/DeclarativeWorkflow/Program.cs @@ -108,15 +108,15 @@ private async Task MonitorWorkflowRunAsync(StreamingRun run) { Debug.WriteLine($"CONVERSATION: {invokeEvent.Data}"); } - else if (evt is DeclarativeWorkflowStreamEvent streamEvent) + else if (evt is AgentRunUpdateEvent streamEvent) { - if (!string.Equals(messageId, streamEvent.Data.MessageId, StringComparison.Ordinal)) + if (!string.Equals(messageId, streamEvent.Update.MessageId, StringComparison.Ordinal)) { - messageId = streamEvent.Data.MessageId; + messageId = streamEvent.Update.MessageId; if (messageId is not null) { - string? agentId = streamEvent.Data.AuthorName; + string? agentId = streamEvent.Update.AuthorName; if (agentId is not null) { if (!s_nameCache.TryGetValue(agentId, out string? realName)) @@ -135,7 +135,7 @@ private async Task MonitorWorkflowRunAsync(StreamingRun run) } } - ChatResponseUpdate? chatUpdate = streamEvent.Data.RawRepresentation as ChatResponseUpdate; + ChatResponseUpdate? chatUpdate = streamEvent.Update.RawRepresentation as ChatResponseUpdate; switch (chatUpdate?.RawRepresentation) { case MessageContentUpdate messageUpdate: @@ -157,24 +157,24 @@ private async Task MonitorWorkflowRunAsync(StreamingRun run) Console.ResetColor(); } } - else if (evt is DeclarativeWorkflowMessageEvent messageEvent) + else if (evt is AgentRunResponseEvent messageEvent) { try { Console.WriteLine(); - if (messageEvent.Data.MessageId is null) + if (messageEvent.Response.AgentId is null) { Console.ForegroundColor = ConsoleColor.Cyan; Console.WriteLine("ACTIVITY:"); Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine(messageEvent.Data?.Text.Trim()); + Console.WriteLine(messageEvent.Response?.Text.Trim()); } else { - if (messageEvent.Usage is not null) + if (messageEvent.Response.Usage is not null) { Console.ForegroundColor = ConsoleColor.DarkGray; - Console.WriteLine($"[Tokens Total: {messageEvent.Usage.TotalTokenCount}, Input: {messageEvent.Usage.InputTokenCount}, Output: {messageEvent.Usage.OutputTokenCount}]"); + Console.WriteLine($"[Tokens Total: {messageEvent.Response.Usage.TotalTokenCount}, Input: {messageEvent.Response.Usage.InputTokenCount}, Output: {messageEvent.Response.Usage.OutputTokenCount}]"); } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvent.cs deleted file mode 100644 index 14532b9dd1..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowEvent.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.Workflows.Declarative; - -/// -/// Base class for events that occur during the execution of a declarative workflow. -/// -public class DeclarativeWorkflowEvent(object? data) : WorkflowEvent(data) -{ -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowInvokeEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowInvokeEvent.cs index e02776989d..676d11e4c8 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowInvokeEvent.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowInvokeEvent.cs @@ -5,10 +5,10 @@ namespace Microsoft.Agents.Workflows.Declarative; /// /// Event that represents a message produced by a declarative workflow. /// -public class DeclarativeWorkflowInvokeEvent(string conversationId) : DeclarativeWorkflowEvent(conversationId) +public class DeclarativeWorkflowInvokeEvent(string executorid, string conversationId) : ExecutorEvent(executorid, conversationId) { /// /// The conversation ID associated with the workflow. /// - public new string Data => conversationId; + public string ConversationId { get; } = conversationId; } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowMessageEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowMessageEvent.cs deleted file mode 100644 index e3227927aa..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowMessageEvent.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.Workflows.Declarative; - -/// -/// Event that represents a message produced by a declarative workflow. -/// -public class DeclarativeWorkflowMessageEvent(ChatMessage message, UsageDetails? usage = null) : DeclarativeWorkflowEvent(message) -{ - /// - /// The message data produced by the workflow, which is a . - /// - public new ChatMessage Data => message; - - /// - /// The usage details associated with the message, if any. - /// - public UsageDetails? Usage => usage; -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs deleted file mode 100644 index dfc299fe69..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowStreamEvent.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Extensions.AI.Agents; - -namespace Microsoft.Agents.Workflows.Declarative; - -/// -/// Event that represents a streamed message produced by a declarative workflow. -/// -public class DeclarativeWorkflowStreamEvent(AgentRunResponseUpdate update) : DeclarativeWorkflowEvent(update) -{ - /// - /// The streamed response data produced by the workflow, which is a . - /// - public new AgentRunResponseUpdate Data => update; -} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs index ecac09e83f..a48b6af2ff 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs @@ -80,7 +80,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, W await AssignConversationId(((ChatResponseUpdate?)update.RawRepresentation)?.ConversationId).ConfigureAwait(false); if (this.Model.AutoSend) { - await context.AddEventAsync(new DeclarativeWorkflowStreamEvent(update)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update)).ConfigureAwait(false); } } @@ -90,7 +90,7 @@ internal sealed class AnswerQuestionWithAIExecutor(AnswerQuestionWithAI model, W await this.State.SetLastMessageAsync(context, response).ConfigureAwait(false); if (this.Model.AutoSend) { - await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(response, agentResponse.Usage)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)).ConfigureAwait(false); } // Assign conversation ID if it wasn't already assigned. @@ -119,7 +119,7 @@ async ValueTask AssignConversationId(string? assignValue) if (assignValue != null && conversationId == null) { conversationId = assignValue; - await context.AddEventAsync(new DeclarativeWorkflowInvokeEvent(conversationId)).ConfigureAwait(false); + await context.AddEventAsync(new DeclarativeWorkflowInvokeEvent(this.Id, conversationId)).ConfigureAwait(false); } } } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs index a4c7981e88..2c581668a4 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs @@ -6,6 +6,7 @@ using Microsoft.Agents.Workflows.Declarative.Interpreter; using Microsoft.Bot.ObjectModel; using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; namespace Microsoft.Agents.Workflows.Declarative.ObjectModel; @@ -25,7 +26,8 @@ internal sealed class SendActivityExecutor(SendActivity model, DeclarativeWorkfl string? activityText = this.State.Format(messageActivity.Text)?.Trim(); templateBuilder.AppendLine(activityText); - await context.AddEventAsync(new DeclarativeWorkflowMessageEvent(new ChatMessage(ChatRole.Assistant, templateBuilder.ToString().Trim()))).ConfigureAwait(false); + AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, templateBuilder.ToString().Trim())]); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); } return default; diff --git a/dotnet/src/Microsoft.Agents.Workflows/AgentRunResponseEvent.cs b/dotnet/src/Microsoft.Agents.Workflows/AgentRunResponseEvent.cs new file mode 100644 index 0000000000..a323f6eb9b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/AgentRunResponseEvent.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI.Agents; + +namespace Microsoft.Agents.Workflows; + +/// +/// Event triggered when an agent run produces an update. +/// +public class AgentRunResponseEvent : ExecutorEvent +{ + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the executor that generated this event. + /// + public AgentRunResponseEvent(string executorId, AgentRunResponse response) : base(executorId, data: response) + { + this.Response = response; + } + + /// + /// Gets the content of the agent response. + /// + public AgentRunResponse Response { get; } +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowEventTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowEventTest.cs deleted file mode 100644 index 55bb7facea..0000000000 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowEventTest.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Extensions.AI; -using Microsoft.Extensions.AI.Agents; -using Xunit.Abstractions; - -namespace Microsoft.Agents.Workflows.Declarative.UnitTests; - -/// -/// Tests and subclasses. -/// -public sealed class DeclarativeWorkflowEventTest(ITestOutputHelper output) : WorkflowTest(output) -{ - /// - /// Tests the class. - /// - [Fact] - public void DeclarativeWorkflowMessageEvent() - { - ChatMessage testMessage = new(ChatRole.Assistant, "test message"); - DeclarativeWorkflowMessageEvent workflowEvent = new(testMessage); - Assert.Equal(testMessage, workflowEvent.Data); - Assert.Null(workflowEvent.Usage); - } - - /// - /// Tests the class. - /// - [Fact] - public void DeclarativeWorkflowStreamEvent() - { - AgentRunResponseUpdate testUpdate = new(ChatRole.Assistant, "test message"); - DeclarativeWorkflowStreamEvent workflowEvent = new(testUpdate); - Assert.Equal(testUpdate, workflowEvent.Data); - } -} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index 55f6be1820..701bdf04d1 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -226,7 +226,7 @@ private void AssertExecuted(string executorId) private void AssertMessage(string message) { - Assert.Contains(this.WorkflowEvents.OfType(), e => string.Equals(e.Data.Text.Trim(), message, StringComparison.Ordinal)); + Assert.Contains(this.WorkflowEvents.OfType(), e => string.Equals(e.Response.Messages[0].Text.Trim(), message, StringComparison.Ordinal)); } private Task RunWorkflow(string workflowPath) => this.RunWorkflow(workflowPath, string.Empty); @@ -249,9 +249,9 @@ private async Task RunWorkflow(string workflowPath, TInput workflowInput DeclarativeExecutorResult? message = invokeEvent.Data as DeclarativeExecutorResult; this.Output.WriteLine($"EXEC: {invokeEvent.ExecutorId} << {message?.ExecutorId ?? "?"} [{message?.Result ?? "-"}]"); } - else if (workflowEvent is DeclarativeWorkflowMessageEvent messageEvent) + else if (workflowEvent is AgentRunResponseEvent messageEvent) { - this.Output.WriteLine($"MESSAGE: {messageEvent.Data.Text.Trim()}"); + this.Output.WriteLine($"MESSAGE: {messageEvent.Response.Messages[0].Text.Trim()}"); } } this.WorkflowEventCounts = this.WorkflowEvents.GroupBy(e => e.GetType()).ToImmutableDictionary(e => e.Key, e => e.Count()); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs index c687c1179c..4337ef2952 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/SendActivityExecutorTest.cs @@ -27,7 +27,7 @@ public async Task CaptureActivity() // Assert this.VerifyModel(model, action); - Assert.Contains(events, e => e is DeclarativeWorkflowMessageEvent); + Assert.Contains(events, e => e is AgentRunResponseEvent); } private SendActivity CreateModel(string displayName, string activityMessage, string? summary = null) From 37b435dcc07eab3c397482a32e77eba047782188 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 2 Sep 2025 17:06:35 -0700 Subject: [PATCH 231/232] Rename event --- dotnet/samples/DeclarativeWorkflow/Program.cs | 2 +- ...arativeWorkflowInvokeEvent.cs => ConversationUpdateEvent.cs} | 2 +- .../ObjectModel/AnswerQuestionWithAIExecutor.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename dotnet/src/Microsoft.Agents.Workflows.Declarative/{DeclarativeWorkflowInvokeEvent.cs => ConversationUpdateEvent.cs} (73%) diff --git a/dotnet/samples/DeclarativeWorkflow/Program.cs b/dotnet/samples/DeclarativeWorkflow/Program.cs index 3c6460e251..c480e96c23 100644 --- a/dotnet/samples/DeclarativeWorkflow/Program.cs +++ b/dotnet/samples/DeclarativeWorkflow/Program.cs @@ -104,7 +104,7 @@ private async Task MonitorWorkflowRunAsync(StreamingRun run) { Debug.WriteLine($"STEP ERROR #{executorFailure.ExecutorId}: {executorFailure.Data?.Message ?? "Unknown"}"); } - else if (evt is DeclarativeWorkflowInvokeEvent invokeEvent) + else if (evt is ConversationUpdateEvent invokeEvent) { Debug.WriteLine($"CONVERSATION: {invokeEvent.Data}"); } diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowInvokeEvent.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ConversationUpdateEvent.cs similarity index 73% rename from dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowInvokeEvent.cs rename to dotnet/src/Microsoft.Agents.Workflows.Declarative/ConversationUpdateEvent.cs index 676d11e4c8..7984ffcd11 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowInvokeEvent.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ConversationUpdateEvent.cs @@ -5,7 +5,7 @@ namespace Microsoft.Agents.Workflows.Declarative; /// /// Event that represents a message produced by a declarative workflow. /// -public class DeclarativeWorkflowInvokeEvent(string executorid, string conversationId) : ExecutorEvent(executorid, conversationId) +public class ConversationUpdateEvent(string executorid, string conversationId) : ExecutorEvent(executorid, conversationId) { /// /// The conversation ID associated with the workflow. diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs index a48b6af2ff..15806e38ac 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/AnswerQuestionWithAIExecutor.cs @@ -119,7 +119,7 @@ async ValueTask AssignConversationId(string? assignValue) if (assignValue != null && conversationId == null) { conversationId = assignValue; - await context.AddEventAsync(new DeclarativeWorkflowInvokeEvent(this.Id, conversationId)).ConfigureAwait(false); + await context.AddEventAsync(new ConversationUpdateEvent(this.Id, conversationId)).ConfigureAwait(false); } } } From 55ad874c1b95fdb1fed8154acbeabbd00e8d9bc7 Mon Sep 17 00:00:00 2001 From: Chris <66376200+crickman@users.noreply.github.com> Date: Wed, 3 Sep 2025 08:54:13 -0700 Subject: [PATCH 232/232] Update workflows/README.md Co-authored-by: Eric Zhu --- workflows/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workflows/README.md b/workflows/README.md index 0d3b0a3c44..c7ceb0d517 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -1,4 +1,4 @@ -# No-Code Workflows +# Declarative Workflows This folder contains sample workflow definitions than be ran using the [Declarative Workflow](../dotnet/demos/DeclarativeWorkflow) demo.