diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorExecutionException.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorExecutionException.cs new file mode 100644 index 0000000000..6701a504e0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorExecutionException.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows.Behaviors; + +/// +/// Exception thrown when a behavior fails during execution. +/// +public sealed class BehaviorExecutionException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public BehaviorExecutionException() + : base("Error executing behavior") + { + this.BehaviorType = string.Empty; + this.Stage = string.Empty; + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message. + public BehaviorExecutionException(string message) + : base(message) + { + this.BehaviorType = string.Empty; + this.Stage = string.Empty; + } + + /// + /// Initializes a new instance of the class with a specified error message and inner exception. + /// + /// The error message. + /// The exception that caused this exception. + public BehaviorExecutionException(string message, Exception innerException) + : base(message, innerException) + { + this.BehaviorType = string.Empty; + this.Stage = string.Empty; + } + + /// + /// Initializes a new instance of the class. + /// + /// The type name of the behavior that failed. + /// The stage at which the behavior failed. + /// The exception that caused the behavior to fail. + public BehaviorExecutionException(string behaviorType, string stage, Exception innerException) + : base($"Error executing behavior '{behaviorType}' at stage '{stage}'", innerException) + { + Throw.IfNull(innerException); + this.BehaviorType = Throw.IfNullOrEmpty(behaviorType); + this.Stage = Throw.IfNullOrEmpty(stage); + } + + /// + /// Gets the type name of the behavior that failed. + /// + public string BehaviorType { get; } + + /// + /// Gets the stage at which the behavior failed. + /// + public string Stage { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorPipeline.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorPipeline.cs new file mode 100644 index 0000000000..5c9b00f6b8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorPipeline.cs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Workflows.Behaviors; + +/// +/// Internal class that manages the execution of behavior pipelines for workflows and executors. +/// +internal sealed class BehaviorPipeline +{ + private readonly List _executorBehaviors; + private readonly List _workflowBehaviors; + + /// + /// Initializes a new instance of the class. + /// + /// The collection of executor behaviors to execute. + /// The collection of workflow behaviors to execute. + public BehaviorPipeline( + IEnumerable executorBehaviors, + IEnumerable workflowBehaviors) + { + this._executorBehaviors = executorBehaviors.ToList(); + this._workflowBehaviors = workflowBehaviors.ToList(); + } + + /// + /// Gets a value indicating whether any executor behaviors are registered. + /// + public bool HasExecutorBehaviors => this._executorBehaviors.Count > 0; + + /// + /// Gets a value indicating whether any workflow behaviors are registered. + /// + public bool HasWorkflowBehaviors => this._workflowBehaviors.Count > 0; + + /// + /// Executes the executor behavior pipeline. + /// + /// The context for the executor execution. + /// The final handler to execute after all behaviors. + /// The cancellation token. + /// The result of the executor execution. + public async ValueTask ExecuteExecutorPipelineAsync( + ExecutorBehaviorContext context, + Func> finalHandler, + CancellationToken cancellationToken) + { + if (this._executorBehaviors.Count == 0) + { + return await finalHandler(cancellationToken).ConfigureAwait(false); + } + + // Build chain from end to start (reverse order) + ExecutorBehaviorContinuation pipeline = new(finalHandler); + + for (int i = this._executorBehaviors.Count - 1; i >= 0; i--) + { + var behavior = this._executorBehaviors[i]; + var continuation = pipeline; + pipeline = new ExecutorBehaviorContinuation((ct) => ExecuteBehaviorWithErrorHandlingAsync(behavior, context, continuation, ct)); + } + + return await pipeline(cancellationToken).ConfigureAwait(false); + } + + /// + /// Executes the workflow behavior pipeline with no return value. + /// + /// The context for the workflow execution. + /// The final handler to execute after all behaviors. + /// The cancellation token. + public async ValueTask ExecuteWorkflowPipelineAsync( + WorkflowBehaviorContext context, + Func finalHandler, + CancellationToken cancellationToken) + { + await this.ExecuteWorkflowPipelineAsync( + context, + async ct => { await finalHandler(ct).ConfigureAwait(false); return 0; }, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Executes the workflow behavior pipeline. + /// + /// The result type of the workflow operation. + /// The context for the workflow execution. + /// The final handler to execute after all behaviors. + /// The cancellation token. + /// The result of the workflow execution. + public async ValueTask ExecuteWorkflowPipelineAsync( + WorkflowBehaviorContext context, + Func> finalHandler, + CancellationToken cancellationToken) + { + if (this._workflowBehaviors.Count == 0) + { + return await finalHandler(cancellationToken).ConfigureAwait(false); + } + + // Build chain from end to start (reverse order) + WorkflowBehaviorContinuation pipeline = new(finalHandler); + + for (int i = this._workflowBehaviors.Count - 1; i >= 0; i--) + { + var behavior = this._workflowBehaviors[i]; + var continuation = pipeline; + pipeline = new WorkflowBehaviorContinuation((ct) => ExecuteBehaviorWithErrorHandlingAsync(behavior, context, continuation, ct)); + } + + return await pipeline(cancellationToken).ConfigureAwait(false); + } + + /// + /// Executes an executor behavior with error handling. + /// + private static async ValueTask ExecuteBehaviorWithErrorHandlingAsync( + IExecutorBehavior behavior, + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + try + { + return await behavior.HandleAsync(context, continuation, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not BehaviorExecutionException) + { + throw new BehaviorExecutionException( + behavior.GetType().FullName ?? "Unknown", + context.Stage.ToString(), + ex + ); + } + } + + /// + /// Executes a workflow behavior with error handling. + /// + private static async ValueTask ExecuteBehaviorWithErrorHandlingAsync( + IWorkflowBehavior behavior, + WorkflowBehaviorContext context, + WorkflowBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + try + { + return await behavior.HandleAsync(context, continuation, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not BehaviorExecutionException) + { + throw new BehaviorExecutionException( + behavior.GetType().FullName ?? "Unknown", + context.Stage.ToString(), + ex + ); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs new file mode 100644 index 0000000000..27d954cd2e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Workflows.Behaviors; + +/// +/// Represents a behavior that wraps executor step execution, allowing custom logic before and after executor operations. +/// +/// +/// Implement this interface to add cross-cutting concerns like logging, telemetry, validation, or performance monitoring +/// at the executor level. Multiple behaviors can be chained together to form a pipeline. +/// Behaviors execute once per executor invocation. Logic placed before await continuation() runs before the executor; +/// logic placed after runs once the executor (and any subsequent behaviors) has completed. +/// +public interface IExecutorBehavior +{ + /// + /// Handles executor execution with the ability to execute logic before and after the next behavior in the pipeline. + /// + /// The context containing information about the current executor execution. + /// The delegate to invoke the next behavior in the pipeline or the actual executor operation. + /// Should be called exactly once. Calling it multiple times will re-execute downstream behaviors and the executor. + /// Logic placed before the call runs before the executor; logic placed after runs once the executor completes. + /// The cancellation token to monitor for cancellation requests. + /// A task representing the asynchronous operation, with the result of the executor operation. + ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken = default); +} + +/// +/// Represents the continuation in the executor behavior pipeline. +/// +/// The cancellation token to monitor for cancellation requests. +/// A task representing the asynchronous operation with the executor result. +public delegate ValueTask ExecutorBehaviorContinuation(CancellationToken cancellationToken); + +/// +/// Provides context information for executor behaviors. +/// +public sealed class ExecutorBehaviorContext +{ + /// + /// Gets the string identifier assigned to the executor being invoked. + /// This is the logical name used to register and route messages to the executor, + /// and is distinct from , which represents the executor's CLR type. + /// + public string ExecutorId { get; init; } = string.Empty; + + /// + /// Gets the type of the executor being invoked. + /// + public required Type ExecutorType { get; init; } + + /// + /// Gets the message being processed by the executor. + /// + public required object Message { get; init; } + + /// + /// Gets the type of the message being processed. + /// + public required Type MessageType { get; init; } + + /// + /// Gets the unique identifier for the workflow execution run. + /// + public string RunId { get; init; } = string.Empty; + + /// + /// Gets the stage of executor execution. + /// + public ExecutorStage Stage { get; init; } + + /// + /// Gets the workflow context for this execution. + /// + public required IWorkflowContext WorkflowContext { get; init; } + + /// + /// Gets the trace context for distributed tracing. + /// + public IReadOnlyDictionary? TraceContext { get; init; } + + /// + /// Gets optional custom properties that can be used to pass additional context. + /// + public IReadOnlyDictionary? Properties { get; init; } +} + +/// +/// Represents the stage of executor execution. +/// +public enum ExecutorStage +{ + /// + /// Before the executor begins processing the message. Behaviors are invoked once per executor call. + /// To perform logic after the executor completes, place code after the await continuation() call + /// in . + /// + PreExecution +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IWorkflowBehavior.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IWorkflowBehavior.cs new file mode 100644 index 0000000000..f9f3ab322d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IWorkflowBehavior.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Workflows.Behaviors; + +/// +/// Represents a behavior that wraps workflow execution, allowing custom logic before and after workflow operations. +/// +/// +/// Implement this interface to add cross-cutting concerns like logging, telemetry, validation, or performance monitoring +/// at the workflow level. Multiple behaviors can be chained together to form a pipeline. +/// +public interface IWorkflowBehavior +{ + /// + /// Handles workflow execution with the ability to execute logic before and after the next behavior in the pipeline. + /// + /// The result type of the workflow operation. + /// The context containing information about the current workflow execution. + /// The delegate to invoke the next behavior in the pipeline or the actual workflow operation. + /// Should be called exactly once. Calling it multiple times will re-execute downstream behaviors and the workflow operation. + /// Logic placed before the call runs before the workflow operation; logic placed after runs once the operation completes. + /// The cancellation token to monitor for cancellation requests. + /// A task representing the asynchronous operation, with the result of the workflow operation. + /// + /// Implementations must return the result produced by , or a compatible + /// value. The concrete is determined by the pipeline caller and may vary across invocations; + /// do not assume a specific type or attempt to cast the result to a different type. + /// + ValueTask HandleAsync( + WorkflowBehaviorContext context, + WorkflowBehaviorContinuation continuation, + CancellationToken cancellationToken = default); +} + +/// +/// Represents the continuation in the workflow behavior pipeline. +/// +/// The result type of the operation. +/// The cancellation token to monitor for cancellation requests. +/// A task representing the asynchronous operation. +public delegate ValueTask WorkflowBehaviorContinuation(CancellationToken cancellationToken); + +/// +/// Provides context information for workflow behaviors. +/// +public sealed class WorkflowBehaviorContext +{ + /// + /// Gets the name of the workflow being executed. + /// + public string WorkflowName { get; init; } = string.Empty; + + /// + /// Gets the optional description of the workflow. + /// + public string? WorkflowDescription { get; init; } + + /// + /// Gets the unique identifier for this workflow execution run. + /// + public string RunId { get; init; } = string.Empty; + + /// + /// Gets the identifier of the starting executor in the workflow. + /// + public string StartExecutorId { get; init; } = string.Empty; + + /// + /// Gets the stage of workflow execution. + /// + public WorkflowStage Stage { get; init; } + + /// + /// Gets optional custom properties that can be used to pass additional context. + /// + public IReadOnlyDictionary? Properties { get; init; } +} + +/// +/// Represents the stage of workflow execution. +/// +public enum WorkflowStage +{ + /// + /// The workflow is starting execution. + /// + Starting, + + /// + /// The workflow is ending execution. + /// + Ending +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/README.md b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/README.md new file mode 100644 index 0000000000..9b0da915ef --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/README.md @@ -0,0 +1,494 @@ +# Pipeline Behaviors + +Pipeline behaviors provide extension points for adding cross-cutting concerns to workflow and executor execution. They enable developers to inject custom logic before and after workflow operations without modifying core workflow code. + +## Overview + +The pipeline behavior system supports two levels of extensibility: + +- **Workflow Behaviors** - Execute logic when workflows start and end +- **Executor Behaviors** - Execute logic when executors process messages + +Multiple behaviors can be chained together, forming a pipeline where each behavior wraps the next, similar to middleware in ASP.NET Core. + +## Key Features + +- ✅ **Chain of Responsibility Pattern** - Multiple behaviors execute in order +- ✅ **Zero Overhead** - Fast path when no behaviors are registered +- ✅ **Type-Safe Context** - Full access to execution information +- ✅ **Exception Wrapping** - Behaviors wrapped with `BehaviorExecutionException` +- ✅ **Async/Await Support** - Full async execution throughout the pipeline +- ✅ **Short-Circuit Capability** - Behaviors can prevent downstream execution + +## Architecture + +### Execution Flow + +``` +Workflow Start + ├─> WorkflowBehavior 1 (Starting) + ├─> WorkflowBehavior 2 (Starting) + │ + ├─> SuperStep Loop + │ └─> Executor Message Processing + │ ├─> ExecutorBehavior 1 (PreExecution) + │ ├─> ExecutorBehavior 2 (PreExecution) + │ ├─> Actual Handler Execution + │ ├─> ExecutorBehavior 2 (PostExecution) + │ └─> ExecutorBehavior 1 (PostExecution) + │ + ├─> WorkflowBehavior 2 (Ending) + └─> WorkflowBehavior 1 (Ending) +``` + +### Pipeline Behavior Interfaces + +#### IExecutorBehavior + +Wraps individual executor step execution: + +```csharp +public interface IExecutorBehavior +{ + ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken = default); +} +``` + +**Context Properties:** +- `ExecutorId` - Unique identifier of the executor +- `ExecutorType` - Type of the executor being invoked +- `Message` - The message being processed +- `MessageType` - Type of the message +- `RunId` - Unique workflow run identifier +- `Stage` - Execution stage: + - `PreExecution` - Before the executor begins processing the message + - `PostExecution` - After the executor completes processing the message +- `WorkflowContext` - Access to workflow operations +- `TraceContext` - Distributed tracing information + +#### IWorkflowBehavior + +Wraps workflow-level execution: + +```csharp +public interface IWorkflowBehavior +{ + ValueTask HandleAsync( + WorkflowBehaviorContext context, + WorkflowBehaviorContinuation continuation, + CancellationToken cancellationToken = default); +} +``` + +**Context Properties:** +- `WorkflowName` - Name of the workflow +- `WorkflowDescription` - Optional description +- `RunId` - Unique workflow run identifier +- `StartExecutorId` - ID of the starting executor +- `Stage` - Workflow execution stage: + - `Starting` - The workflow is beginning execution + - `Ending` - The workflow is completing execution +- `Properties` - Custom properties dictionary + +## Usage Examples + +### Example 1: Logging Behavior + +```csharp +public class LoggingExecutorBehavior : IExecutorBehavior +{ + private readonly ILogger _logger; + + public LoggingExecutorBehavior(ILogger logger) + { + _logger = logger; + } + + public async ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Executing {ExecutorId} with message type {MessageType}", + context.ExecutorId, + context.MessageType.Name); + + var stopwatch = Stopwatch.StartNew(); + + try + { + var result = await continuation(cancellationToken); + + _logger.LogInformation( + "Completed {ExecutorId} in {ElapsedMs}ms", + context.ExecutorId, + stopwatch.ElapsedMilliseconds); + + return result; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed {ExecutorId} after {ElapsedMs}ms", + context.ExecutorId, + stopwatch.ElapsedMilliseconds); + + throw; + } + } +} +``` + +### Example 2: Validation Behavior + +```csharp +public class ValidationBehavior : IExecutorBehavior +{ + public async ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + // Pre-execution validation + if (context.Message is IValidatable validatable) + { + var validationResult = validatable.Validate(); + if (!validationResult.IsValid) + { + throw new ValidationException( + $"Message validation failed for {context.MessageType.Name}: " + + string.Join(", ", validationResult.Errors)); + } + } + + // Continue pipeline + return await continuation(cancellationToken); + } +} +``` + +### Example 3: Workflow Telemetry Behavior + +```csharp +public class WorkflowTelemetryBehavior : IWorkflowBehavior +{ + private readonly IMetrics _metrics; + + public WorkflowTelemetryBehavior(IMetrics metrics) + { + _metrics = metrics; + } + + public async ValueTask HandleAsync( + WorkflowBehaviorContext context, + WorkflowBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + if (context.Stage == WorkflowStage.Starting) + { + _metrics.IncrementCounter("workflow.starts", 1, + new[] { new KeyValuePair("workflow", context.WorkflowName) }); + } + + var result = await continuation(cancellationToken); + + if (context.Stage == WorkflowStage.Ending) + { + _metrics.IncrementCounter("workflow.completions", 1, + new[] { new KeyValuePair("workflow", context.WorkflowName) }); + } + + return result; + } +} +``` + +### Example 4: Retry Behavior + +```csharp +public class RetryBehavior : IExecutorBehavior +{ + private readonly int _maxRetries; + private readonly TimeSpan _retryDelay; + + public RetryBehavior(int maxRetries = 3, TimeSpan? retryDelay = null) + { + _maxRetries = maxRetries; + _retryDelay = retryDelay ?? TimeSpan.FromMilliseconds(100); + } + + public async ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + for (int attempt = 1; attempt <= _maxRetries; attempt++) + { + try + { + return await continuation(cancellationToken); + } + catch (Exception) when (attempt < _maxRetries) + { + // Wait before retrying + await Task.Delay(_retryDelay, cancellationToken); + } + } + + // Final attempt without catching + return await continuation(cancellationToken); + } +} +``` + +## Registration + +Register behaviors using the `WithBehaviors` method on `WorkflowBuilder`: + +```csharp +var workflow = new WorkflowBuilder(startExecutor) + .WithBehaviors(options => + { + // Add executor behaviors (execute for each executor step) + options.AddExecutorBehavior(new LoggingExecutorBehavior(logger)); + options.AddExecutorBehavior(new ValidationBehavior()); + options.AddExecutorBehavior(new RetryBehavior(maxRetries: 3)); + + // Add workflow behaviors (execute at workflow start/end) + options.AddWorkflowBehavior(new WorkflowTelemetryBehavior(metrics)); + }) + .AddEdge(startExecutor, nextExecutor) + .Build(); +``` + +### Registration with Factory Methods + +For behaviors with parameterless constructors: + +```csharp +.WithBehaviors(options => +{ + options.AddExecutorBehavior(); + options.AddWorkflowBehavior(); +}) +``` + +## Execution Order + +Behaviors execute in the order they are registered: + +```csharp +options.AddExecutorBehavior(new Behavior1()); // Outer wrapper +options.AddExecutorBehavior(new Behavior2()); // Middle wrapper +options.AddExecutorBehavior(new Behavior3()); // Inner wrapper (closest to handler) +``` + +**Execution Flow:** +1. Behavior1.HandleAsync (before) +2. Behavior2.HandleAsync (before) +3. Behavior3.HandleAsync (before) +4. **Actual Handler Execution** +5. Behavior3.HandleAsync (after) +6. Behavior2.HandleAsync (after) +7. Behavior1.HandleAsync (after) + +## Error Handling + +All behavior exceptions are automatically wrapped in `BehaviorExecutionException`: + +```csharp +try +{ + // Execute workflow +} +catch (BehaviorExecutionException ex) +{ + Console.WriteLine($"Behavior {ex.BehaviorType} failed at {ex.Stage}"); + Console.WriteLine($"Inner exception: {ex.InnerException?.Message}"); +} +``` + +**Properties:** +- `BehaviorType` - Full type name of the failed behavior +- `Stage` - Execution stage when failure occurred +- `InnerException` - Original exception thrown by the behavior + +## Performance Considerations + +### Zero Overhead When Disabled + +When no behaviors are registered, the pipeline has zero overhead: + +```csharp +if (_executorBehaviors.Count == 0) +{ + return await finalHandler(cancellationToken); // Direct execution +} +``` + +### Minimal Allocation + +- Behaviors are stored as `List` for optimal iteration +- Delegate chain built once per execution +- No allocations in the fast path + +## Common Use Cases + +### 1. **Logging and Diagnostics** +```csharp +- Log executor execution with timing +- Track message types and payloads +- Record workflow lifecycle events +``` + +### 2. **Telemetry and Metrics** +```csharp +- Count workflow starts/completions +- Measure executor execution time +- Track message processing rates +``` + +### 3. **Validation** +```csharp +- Validate messages before execution +- Enforce business rules +- Check preconditions +``` + +### 4. **Resilience** +```csharp +- Implement retry logic +- Add circuit breakers +- Handle transient failures +``` + +### 5. **Security** +```csharp +- Authorization checks +- Audit logging +- Sensitive data masking +``` + +### 6. **Distributed Tracing** +```csharp +- Create OpenTelemetry spans +- Propagate trace context +- Add custom span attributes +``` + +## Best Practices + +### ✅ Do + +- **Keep behaviors focused** - One concern per behavior +- **Handle cancellation** - Respect `CancellationToken` +- **Use dependency injection** - Pass dependencies via constructor +- **Document side effects** - Be clear about what behaviors do +- **Test behaviors independently** - Unit test each behavior in isolation + +### ❌ Don't + +- **Modify message content** - Behaviors should observe, not mutate +- **Catch all exceptions** - Let exceptions propagate (they'll be wrapped) +- **Block threads** - Always use async/await +- **Share state** - Behaviors should be stateless or thread-safe +- **Assume execution order** - Each behavior should work independently + +## Thread Safety + +Behavior instances may be called concurrently when: +- Multiple workflows execute simultaneously +- The same workflow processes multiple messages in parallel + +**Ensure behaviors are thread-safe:** +- Use immutable state +- Synchronize mutable state access +- Avoid shared static fields + +## Advanced Patterns + +### Conditional Execution + +```csharp +public async ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) +{ + // Only apply to specific executor types + if (context.ExecutorType == typeof(CriticalExecutor)) + { + // Apply special handling + } + + return await continuation(cancellationToken); +} +``` + +### Short-Circuiting + +```csharp +public async ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) +{ + // Check cache + if (_cache.TryGet(context.Message, out var cachedResult)) + { + return cachedResult; // Skip execution + } + + var result = await continuation(cancellationToken); + _cache.Set(context.Message, result); + return result; +} +``` + +### Context Enrichment + +```csharp +public async ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) +{ + // Add custom trace attributes + Activity.Current?.SetTag("executor.id", context.ExecutorId); + Activity.Current?.SetTag("message.type", context.MessageType.Name); + + return await continuation(cancellationToken); +} +``` + +## Backward Compatibility + +Pipeline behaviors are completely opt-in: +- Existing workflows work without modification +- No performance impact when behaviors aren't registered +- New functionality doesn't break existing code + +## API Reference + +### Core Types + +- `IWorkflowBehavior` - Workflow-level behavior interface +- `IExecutorBehavior` - Executor-level behavior interface +- `WorkflowBehaviorContext` - Context for workflow behaviors +- `ExecutorBehaviorContext` - Context for executor behaviors +- `WorkflowBehaviorOptions` - Registration API +- `BehaviorExecutionException` - Exception wrapper + +### Enums + +- `WorkflowStage` - Starting, Ending +- `ExecutorStage` - PreExecution, PostExecution + +### Extension Methods + +- `WorkflowBuilder.WithBehaviors(Action)` - Register behaviors diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/WorkflowBehaviorOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/WorkflowBehaviorOptions.cs new file mode 100644 index 0000000000..7c204f8a0f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/WorkflowBehaviorOptions.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows.Behaviors; + +/// +/// Provides options for configuring workflow and executor behaviors. +/// +public sealed class WorkflowBehaviorOptions +{ + internal List ExecutorBehaviors { get; } = new(); + internal List WorkflowBehaviors { get; } = new(); + + /// + /// Registers an executor behavior instance to the pipeline. + /// + /// The executor behavior instance to register. + /// The current options instance for method chaining. + /// Thrown when is null. + public WorkflowBehaviorOptions AddExecutorBehavior(IExecutorBehavior behavior) + { + this.ExecutorBehaviors.Add(Throw.IfNull(behavior)); + return this; + } + + /// + /// Registers a workflow behavior instance to the pipeline. + /// + /// The workflow behavior instance to register. + /// The current options instance for method chaining. + /// Thrown when is null. + public WorkflowBehaviorOptions AddWorkflowBehavior(IWorkflowBehavior behavior) + { + this.WorkflowBehaviors.Add(Throw.IfNull(behavior)); + return this; + } + + /// + /// Registers an executor behavior using a parameterless constructor. + /// + /// The type of executor behavior to register. + /// The current options instance for method chaining. + public WorkflowBehaviorOptions AddExecutorBehavior() + where TBehavior : IExecutorBehavior, new() + { + return this.AddExecutorBehavior(new TBehavior()); + } + + /// + /// Registers a workflow behavior using a parameterless constructor. + /// + /// The type of workflow behavior to register. + /// The current options instance for method chaining. + public WorkflowBehaviorOptions AddWorkflowBehavior() + where TBehavior : IWorkflowBehavior, new() + { + return this.AddWorkflowBehavior(new TBehavior()); + } + + /// + /// Builds a behavior pipeline from the registered behaviors. + /// + /// A new instance. + internal BehaviorPipeline BuildPipeline() + { + return new BehaviorPipeline(this.ExecutorBehaviors, this.WorkflowBehaviors); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs index 3dba017fa7..ab036bbf08 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs @@ -8,6 +8,7 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Behaviors; using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Agents.AI.Workflows.Execution; using Microsoft.Agents.AI.Workflows.Observability; @@ -143,6 +144,38 @@ private set => this.ExecuteAsync(message, messageType, context, WorkflowTelemetryContext.Disabled, cancellationToken); internal async ValueTask ExecuteAsync(object message, TypeId messageType, IWorkflowContext context, WorkflowTelemetryContext telemetryContext, CancellationToken cancellationToken = default) + => await this.ExecuteAsync(message, messageType, context, telemetryContext, behaviorPipeline: null, runId: null, cancellationToken).ConfigureAwait(false); + + internal async ValueTask ExecuteAsync(object message, TypeId messageType, IWorkflowContext context, WorkflowTelemetryContext telemetryContext, BehaviorPipeline? behaviorPipeline, string? runId, CancellationToken cancellationToken = default) + { + // Check if behaviors are configured + if (behaviorPipeline?.HasExecutorBehaviors == true) + { + var behaviorContext = new ExecutorBehaviorContext + { + ExecutorId = this.Id, + ExecutorType = this.GetType(), + Message = message, + MessageType = message.GetType(), + RunId = runId ?? string.Empty, + Stage = ExecutorStage.PreExecution, + WorkflowContext = context, + TraceContext = context.TraceContext, + Properties = null + }; + + return await behaviorPipeline.ExecuteExecutorPipelineAsync( + behaviorContext, + async (ct) => await this.ExecuteCoreAsync(message, messageType, context, telemetryContext, ct).ConfigureAwait(false), + cancellationToken + ).ConfigureAwait(false); + } + + // No behaviors - execute directly (fast path) + return await this.ExecuteCoreAsync(message, messageType, context, telemetryContext, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ExecuteCoreAsync(object message, TypeId messageType, IWorkflowContext context, WorkflowTelemetryContext telemetryContext, CancellationToken cancellationToken) { using var activity = telemetryContext.StartExecutorProcessActivity(this.Id, this.GetType().FullName, messageType.TypeName, message); activity?.CreateSourceLinks(context.TraceContext); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index 58e1890eed..85ea6ee74d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Behaviors; using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Agents.AI.Workflows.Execution; using Microsoft.Agents.AI.Workflows.Observability; @@ -55,6 +56,7 @@ private InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager this.Workflow = Throw.IfNull(workflow); this.RunContext = new InProcessRunnerContext(workflow, this.RunId, withCheckpointing: checkpointManager != null, this.OutgoingEvents, this.StepTracer, existingOwnerSignoff, subworkflow, enableConcurrentRuns); + this.RunContext.SetRunEndingCallback(this.ExecuteWorkflowEndBehaviorsAsync); this.CheckpointManager = checkpointManager; this._knownValidInputTypes = knownValidInputTypes != null @@ -142,7 +144,58 @@ private ValueTask RaiseWorkflowEventAsync(WorkflowEvent workflowEvent) public ValueTask BeginStreamAsync(ExecutionMode mode, CancellationToken cancellationToken = default) { this.RunContext.CheckEnded(); - return new(new AsyncRunHandle(this, this, mode)); + + if (this.Workflow.BehaviorPipeline?.HasWorkflowBehaviors != true) + return new ValueTask(new AsyncRunHandle(this, this, mode)); + + return new ValueTask(this.BeginStreamWithBehaviorsAsync(mode, cancellationToken)); + } + + private async Task BeginStreamWithBehaviorsAsync(ExecutionMode mode, CancellationToken cancellationToken) + { + var context = new WorkflowBehaviorContext + { + WorkflowName = this.Workflow.Name ?? string.Empty, + WorkflowDescription = this.Workflow.Description, + RunId = this.RunId, + StartExecutorId = this.StartExecutorId, + Stage = WorkflowStage.Starting, + Properties = null + }; + + await this.Workflow.BehaviorPipeline!.ExecuteWorkflowPipelineAsync( + context, + (ct) => default, + cancellationToken + ).ConfigureAwait(false); + + return new AsyncRunHandle(this, this, mode); + } + + /// + /// Executes workflow end behaviors if configured. + /// + /// The cancellation token. + internal async ValueTask ExecuteWorkflowEndBehaviorsAsync(CancellationToken cancellationToken = default) + { + if (this.Workflow.BehaviorPipeline?.HasWorkflowBehaviors == true) + { + var context = new WorkflowBehaviorContext + { + WorkflowName = this.Workflow.Name ?? string.Empty, + WorkflowDescription = this.Workflow.Description, + RunId = this.RunId, + StartExecutorId = this.StartExecutorId, + Stage = WorkflowStage.Ending, + Properties = null + }; + + await this.Workflow.BehaviorPipeline.ExecuteWorkflowPipelineAsync( + context, + (ct) => default, + cancellationToken + ).ConfigureAwait(false); + } } public async ValueTask ResumeStreamAsync(ExecutionMode mode, CheckpointInfo fromCheckpoint, CancellationToken cancellationToken = default) @@ -206,6 +259,8 @@ await executor.ExecuteAsync( envelope.MessageType, this.RunContext.BindWorkflowContext(receiverId, envelope.TraceContext), this.TelemetryContext, + this.Workflow.BehaviorPipeline, + this.RunId, cancellationToken ).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index 419d46cd1b..10af4c68e2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -38,6 +38,8 @@ internal sealed class InProcessRunnerContext : IRunnerContext private readonly ConcurrentDictionary _externalRequests = new(); + private Func? _onRunEnding; + public InProcessRunnerContext( Workflow workflow, string runId, @@ -70,6 +72,16 @@ public InProcessRunnerContext( this.ConcurrentRunsEnabled = enableConcurrentRuns; this.OutgoingEvents = outgoingEvents; } + internal void SetRunEndingCallback(Func callback) + { + if (this._onRunEnding is not null) + { + throw new InvalidOperationException("A run-ending callback has already been registered."); + } + + this._onRunEnding = callback; + } + public WorkflowTelemetryContext TelemetryContext => this._workflow.TelemetryContext; public IExternalRequestSink RegisterPort(string executorId, RequestPort port) @@ -423,6 +435,16 @@ public async ValueTask EndRunAsync() { if (Interlocked.Exchange(ref this._runEnded, 1) == 0) { + if (this._onRunEnding is not null) + { + // CancellationToken.None is intentional here. This call originates from IAsyncDisposable.DisposeAsync() + // (token-less by contract), flows through ISuperStepRunner.RequestEndRunAsync() (also token-less), + // and reaches this point with no token in scope. As a result, behaviors registered for + // WorkflowStage.Ending cannot observe cancellation. A proper fix would require adding a + // CancellationToken overload to ISuperStepRunner.RequestEndRunAsync and threading it through. + await this._onRunEnding(CancellationToken.None).ConfigureAwait(false); + } + foreach (string executorId in this._executors.Keys) { Task executorTask = this._executors[executorId]; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj index d01e3de970..68ff38b6a5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj @@ -9,6 +9,8 @@ true true true + true + true diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs index 6b26a403cf..a3166b80d5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Behaviors; using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Agents.AI.Workflows.Execution; using Microsoft.Agents.AI.Workflows.Observability; @@ -82,6 +83,11 @@ public Dictionary ReflectExecutors() /// internal WorkflowTelemetryContext TelemetryContext { get; } + /// + /// Gets the behavior pipeline for the workflow, if configured. + /// + internal BehaviorPipeline? BehaviorPipeline { get; init; } + internal bool AllowConcurrent => this.ExecutorBindings.Values.All(registration => registration.SupportsConcurrentSharedExecution); internal IEnumerable NonConcurrentExecutorIds => diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs index 36a3468e2d..657a660003 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text.Json; using System.Threading; +using Microsoft.Agents.AI.Workflows.Behaviors; using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Agents.AI.Workflows.Observability; using Microsoft.Shared.Diagnostics; @@ -39,6 +40,7 @@ private readonly record struct EdgeConnection(string SourceId, string TargetId) private string? _name; private string? _description; private WorkflowTelemetryContext _telemetryContext = WorkflowTelemetryContext.Disabled; + private WorkflowBehaviorOptions? _behaviorOptions; /// /// Initializes a new instance of the WorkflowBuilder class with the specified starting executor. @@ -144,6 +146,20 @@ internal void SetTelemetryContext(WorkflowTelemetryContext context) this._telemetryContext = Throw.IfNull(context); } + /// + /// Configures pipeline behaviors for the workflow, allowing custom logic to be executed before and after + /// workflow and executor operations. + /// + /// An action to configure the behavior options. + /// The current instance, enabling fluent configuration. + /// is . + public WorkflowBuilder WithBehaviors(Action configure) + { + this._behaviorOptions ??= new WorkflowBehaviorOptions(); + Throw.IfNull(configure).Invoke(this._behaviorOptions); + return this; + } + /// /// Binds the specified executor (via registration) to the workflow, allowing it to participate in workflow execution. /// @@ -575,7 +591,8 @@ private Workflow BuildInternal(bool validateOrphans, Activity? activity = null) ExecutorBindings = this._executorBindings, Edges = this._edges, Ports = this._requestPorts, - OutputExecutors = this._outputExecutors + OutputExecutors = this._outputExecutors, + BehaviorPipeline = this._behaviorOptions?.BuildPipeline() }; // Using the start executor ID as a proxy for the workflow ID diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorExecutionExceptionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorExecutionExceptionTests.cs new file mode 100644 index 0000000000..cf2893cdf3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorExecutionExceptionTests.cs @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Behaviors; + +namespace Microsoft.Agents.AI.Workflows.UnitTests.Behaviors; + +/// +/// Tests for BehaviorExecutionException error handling and wrapping. +/// +public class BehaviorExecutionExceptionTests +{ + [Fact] + public void Constructor_WithAllParameters_InitializesProperties() + { + // Arrange + const string behaviorType = "TestBehavior"; + const string stage = "PreExecution"; + var innerException = new InvalidOperationException("Inner exception"); + + // Act + var exception = new BehaviorExecutionException(behaviorType, stage, innerException); + + // Assert + exception.BehaviorType.Should().Be(behaviorType); + exception.Stage.Should().Be(stage); + exception.InnerException.Should().Be(innerException); + exception.Message.Should().Contain(behaviorType); + exception.Message.Should().Contain(stage); + } + + [Fact] + public void Constructor_WithNullBehaviorType_ThrowsArgumentNullException() + { + // Arrange + var innerException = new InvalidOperationException(); + + // Act + Action act = () => _ = new BehaviorExecutionException(null!, "stage", innerException); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithNullStage_ThrowsArgumentNullException() + { + // Arrange + var innerException = new InvalidOperationException(); + + // Act + Action act = () => _ = new BehaviorExecutionException("behavior", null!, innerException); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithNullInnerException_ThrowsArgumentNullException() + { + // Act + Action act = () => _ = new BehaviorExecutionException("behavior", "stage", null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Message_ContainsBehaviorType() + { + // Arrange + const string behaviorType = "LoggingBehavior"; + var exception = new BehaviorExecutionException( + behaviorType, + "PreExecution", + new InvalidOperationException()); + + // Act + var message = exception.Message; + + // Assert + message.Should().Contain(behaviorType); + } + + [Fact] + public void Message_ContainsStage() + { + // Arrange + const string stage = "PostExecution"; + var exception = new BehaviorExecutionException( + "TestBehavior", + stage, + new InvalidOperationException()); + + // Act + var message = exception.Message; + + // Assert + message.Should().Contain(stage); + } + + [Fact] + public void Exception_IsSerializable() + { + // Arrange + var exception = new BehaviorExecutionException( + "TestBehavior", + "PreExecution", + new InvalidOperationException("Test")); + + // Act & Assert - Just verify the type is marked as serializable + exception.Should().BeAssignableTo(); + } + + [Fact] + public void InnerException_IsPreserved() + { + // Arrange + const string originalMessage = "Original exception message"; + var innerException = new InvalidOperationException(originalMessage); + + // Act + var exception = new BehaviorExecutionException("TestBehavior", "PreExecution", innerException); + + // Assert + exception.InnerException.Should().NotBeNull(); + exception.InnerException!.Message.Should().Be(originalMessage); + } + + [Fact] + public void StackTrace_IsPreserved() + { + // Arrange + Exception? capturedException = null; + try + { + ThrowTestException(); + } + catch (Exception ex) + { + capturedException = ex; + } + + // Act + var wrappedException = new BehaviorExecutionException( + "TestBehavior", + "PreExecution", + capturedException!); + + // Assert + wrappedException.InnerException!.StackTrace.Should().NotBeNullOrEmpty(); + wrappedException.InnerException!.StackTrace.Should().Contain(nameof(ThrowTestException)); + } + + [Fact] + public void BehaviorType_IsAccessible() + { + // Arrange + const string behaviorType = "MyCustomBehavior"; + var exception = new BehaviorExecutionException( + behaviorType, + "PreExecution", + new InvalidOperationException()); + + // Act + var result = exception.BehaviorType; + + // Assert + result.Should().Be(behaviorType); + } + + [Fact] + public void Stage_IsAccessible() + { + // Arrange + const string stage = "PostExecution"; + var exception = new BehaviorExecutionException( + "TestBehavior", + stage, + new InvalidOperationException()); + + // Act + var result = exception.Stage; + + // Assert + result.Should().Be(stage); + } + + private static void ThrowTestException() + { + throw new InvalidOperationException("Test exception"); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorPipelineTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorPipelineTests.cs new file mode 100644 index 0000000000..50f699eac0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorPipelineTests.cs @@ -0,0 +1,446 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Behaviors; + +namespace Microsoft.Agents.AI.Workflows.UnitTests.Behaviors; + +public class BehaviorPipelineTests +{ + [Fact] + public async Task ExecutorPipeline_WithNoBehaviors_ReturnsFastPathAsync() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + var pipeline = options.BuildPipeline(); + var executed = false; + + var context = new ExecutorBehaviorContext + { + ExecutorId = "test-executor", + ExecutorType = typeof(BehaviorPipelineTests), + Message = "test", + MessageType = typeof(string), + RunId = Guid.NewGuid().ToString(), + Stage = ExecutorStage.PreExecution, + WorkflowContext = NullWorkflowContext.Instance + }; + + // Act + var result = await pipeline!.ExecuteExecutorPipelineAsync( + context, + async ct => { executed = true; return await Task.FromResult("result"); }, + CancellationToken.None); + + // Assert + executed.Should().BeTrue(); + result.Should().Be("result"); + } + + [Fact] + public async Task ExecutorPipeline_WithNoBehaviors_FinalHandlerExceptionNotWrappedAsync() + { + // Arrange - no behaviors registered, so exceptions from the core handler should not be wrapped + var options = new WorkflowBehaviorOptions(); + var pipeline = options.BuildPipeline(); + + var context = new ExecutorBehaviorContext + { + ExecutorId = "test-executor", + ExecutorType = typeof(BehaviorPipelineTests), + Message = "test", + MessageType = typeof(string), + RunId = Guid.NewGuid().ToString(), + Stage = ExecutorStage.PreExecution, + WorkflowContext = NullWorkflowContext.Instance + }; + + // Act + Func act = async () => await pipeline!.ExecuteExecutorPipelineAsync( + context, + ct => throw new InvalidOperationException("Core handler error"), + CancellationToken.None); + + // Assert - the raw exception propagates without being wrapped in BehaviorExecutionException + await act.Should().ThrowAsync() + .WithMessage("Core handler error"); + } + + [Fact] + public async Task ExecutorPipeline_WithSingleBehavior_ExecutesBehaviorAsync() + { + // Arrange + var behaviorExecuted = false; + var behavior = new TestExecutorBehavior(ctx => behaviorExecuted = true); + + var options = new WorkflowBehaviorOptions(); + options.AddExecutorBehavior(behavior); + var pipeline = options.BuildPipeline(); + + var context = new ExecutorBehaviorContext + { + ExecutorId = "test-executor", + ExecutorType = typeof(BehaviorPipelineTests), + Message = "test", + MessageType = typeof(string), + RunId = Guid.NewGuid().ToString(), + Stage = ExecutorStage.PreExecution, + WorkflowContext = NullWorkflowContext.Instance + }; + + // Act + await pipeline!.ExecuteExecutorPipelineAsync( + context, + async ct => await Task.FromResult("result"), + CancellationToken.None); + + // Assert + behaviorExecuted.Should().BeTrue(); + } + + [Fact] + public async Task ExecutorPipeline_WithMultipleBehaviors_ExecutesInOrderAsync() + { + // Arrange + var executionOrder = new List(); + var behavior1 = new TestExecutorBehavior(ctx => executionOrder.Add(1)); + var behavior2 = new TestExecutorBehavior(ctx => executionOrder.Add(2)); + var behavior3 = new TestExecutorBehavior(ctx => executionOrder.Add(3)); + + var options = new WorkflowBehaviorOptions(); + options.AddExecutorBehavior(behavior1); + options.AddExecutorBehavior(behavior2); + options.AddExecutorBehavior(behavior3); + var pipeline = options.BuildPipeline(); + + var context = new ExecutorBehaviorContext + { + ExecutorId = "test-executor", + ExecutorType = typeof(BehaviorPipelineTests), + Message = "test", + MessageType = typeof(string), + RunId = Guid.NewGuid().ToString(), + Stage = ExecutorStage.PreExecution, + WorkflowContext = NullWorkflowContext.Instance + }; + + // Act + await pipeline!.ExecuteExecutorPipelineAsync( + context, + async ct => await Task.FromResult("result"), + CancellationToken.None); + + // Assert + executionOrder.Should().Equal(1, 2, 3); + } + + [Fact] + public async Task ExecutorPipeline_BehaviorCanShortCircuit_SkipsRemainingPipelineAsync() + { + // Arrange + var behavior1Executed = false; + var behavior2Executed = false; + var coreExecuted = false; + + var behavior1 = new ShortCircuitingExecutorBehavior(() => { behavior1Executed = true; return "short-circuit"; }); + var behavior2 = new TestExecutorBehavior(ctx => behavior2Executed = true); + + var options = new WorkflowBehaviorOptions(); + options.AddExecutorBehavior(behavior1); + options.AddExecutorBehavior(behavior2); + var pipeline = options.BuildPipeline(); + + var context = new ExecutorBehaviorContext + { + ExecutorId = "test-executor", + ExecutorType = typeof(BehaviorPipelineTests), + Message = "test", + MessageType = typeof(string), + RunId = Guid.NewGuid().ToString(), + Stage = ExecutorStage.PreExecution, + WorkflowContext = NullWorkflowContext.Instance + }; + + // Act + var result = await pipeline!.ExecuteExecutorPipelineAsync( + context, + async ct => { coreExecuted = true; return await Task.FromResult("core-result"); }, + CancellationToken.None); + + // Assert + behavior1Executed.Should().BeTrue(); + behavior2Executed.Should().BeFalse(); + coreExecuted.Should().BeFalse(); + result.Should().Be("short-circuit"); + } + + [Fact] + public async Task ExecutorPipeline_BehaviorThrowsException_WrapsInBehaviorExecutionExceptionAsync() + { + // Arrange + var behavior = new ThrowingExecutorBehavior(); + + var options = new WorkflowBehaviorOptions(); + options.AddExecutorBehavior(behavior); + var pipeline = options.BuildPipeline(); + + var context = new ExecutorBehaviorContext + { + ExecutorId = "test-executor", + ExecutorType = typeof(BehaviorPipelineTests), + Message = "test", + MessageType = typeof(string), + RunId = Guid.NewGuid().ToString(), + Stage = ExecutorStage.PreExecution, + WorkflowContext = NullWorkflowContext.Instance + }; + + // Act + Func act = async () => await pipeline!.ExecuteExecutorPipelineAsync( + context, + async ct => await Task.FromResult("result"), + CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*ThrowingExecutorBehavior*"); + } + + [Fact] + public async Task WorkflowPipeline_WithSingleBehavior_ExecutesBehaviorAsync() + { + // Arrange + var behaviorExecuted = false; + var behavior = new TestWorkflowBehavior(ctx => behaviorExecuted = true); + + var options = new WorkflowBehaviorOptions(); + options.AddWorkflowBehavior(behavior); + var pipeline = options.BuildPipeline(); + + var context = new WorkflowBehaviorContext + { + WorkflowName = "test-workflow", + RunId = Guid.NewGuid().ToString(), + StartExecutorId = "start", + Stage = WorkflowStage.Starting + }; + + // Act + await pipeline!.ExecuteWorkflowPipelineAsync( + context, + async ct => await Task.FromResult(0), + CancellationToken.None); + + // Assert + behaviorExecuted.Should().BeTrue(); + } + + [Fact] + public async Task WorkflowPipeline_WithMultipleBehaviors_ExecutesInOrderAsync() + { + // Arrange + var executionOrder = new List(); + var behavior1 = new TestWorkflowBehavior(ctx => executionOrder.Add(1)); + var behavior2 = new TestWorkflowBehavior(ctx => executionOrder.Add(2)); + var behavior3 = new TestWorkflowBehavior(ctx => executionOrder.Add(3)); + + var options = new WorkflowBehaviorOptions(); + options.AddWorkflowBehavior(behavior1); + options.AddWorkflowBehavior(behavior2); + options.AddWorkflowBehavior(behavior3); + var pipeline = options.BuildPipeline(); + + var context = new WorkflowBehaviorContext + { + WorkflowName = "test-workflow", + RunId = Guid.NewGuid().ToString(), + StartExecutorId = "start", + Stage = WorkflowStage.Starting + }; + + // Act + await pipeline!.ExecuteWorkflowPipelineAsync( + context, + async ct => await Task.FromResult(0), + CancellationToken.None); + + // Assert + executionOrder.Should().Equal(1, 2, 3); + } + + [Fact] + public async Task WorkflowPipeline_BehaviorThrowsException_WrapsInBehaviorExecutionExceptionAsync() + { + // Arrange + var behavior = new ThrowingWorkflowBehavior(); + + var options = new WorkflowBehaviorOptions(); + options.AddWorkflowBehavior(behavior); + var pipeline = options.BuildPipeline(); + + var context = new WorkflowBehaviorContext + { + WorkflowName = "test-workflow", + RunId = Guid.NewGuid().ToString(), + StartExecutorId = "start", + Stage = WorkflowStage.Starting + }; + + // Act + Func act = async () => await pipeline!.ExecuteWorkflowPipelineAsync( + context, + async ct => await Task.FromResult(0), + CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*ThrowingWorkflowBehavior*"); + } + + [Fact] + public void HasExecutorBehaviors_WithBehaviors_ReturnsTrue() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + options.AddExecutorBehavior(new TestExecutorBehavior(_ => { })); + var pipeline = options.BuildPipeline(); + + // Act & Assert + pipeline!.HasExecutorBehaviors.Should().BeTrue(); + } + + [Fact] + public void HasExecutorBehaviors_WithoutBehaviors_ReturnsFalse() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + var pipeline = options.BuildPipeline(); + + // Act & Assert + pipeline!.HasExecutorBehaviors.Should().BeFalse(); + } + + [Fact] + public void HasWorkflowBehaviors_WithBehaviors_ReturnsTrue() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + options.AddWorkflowBehavior(new TestWorkflowBehavior(_ => { })); + var pipeline = options.BuildPipeline(); + + // Act & Assert + pipeline!.HasWorkflowBehaviors.Should().BeTrue(); + } + + [Fact] + public void HasWorkflowBehaviors_WithoutBehaviors_ReturnsFalse() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + var pipeline = options.BuildPipeline(); + + // Act & Assert + pipeline!.HasWorkflowBehaviors.Should().BeFalse(); + } + + // Test helper behaviors + private sealed class TestExecutorBehavior : IExecutorBehavior + { + private readonly Action _action; + + public TestExecutorBehavior(Action action) + { + this._action = action; + } + + public async ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + this._action(context); + return await continuation(cancellationToken); + } + } + + private sealed class ShortCircuitingExecutorBehavior : IExecutorBehavior + { + private readonly Func _resultFactory; + + public ShortCircuitingExecutorBehavior(Func resultFactory) + { + this._resultFactory = resultFactory; + } + + public ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + // Short-circuit: don't call continuation + return new ValueTask(this._resultFactory()); + } + } + + private sealed class ThrowingExecutorBehavior : IExecutorBehavior + { + public ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + throw new InvalidOperationException("Test exception from behavior"); + } + } + + private sealed class TestWorkflowBehavior : IWorkflowBehavior + { + private readonly Action _action; + + public TestWorkflowBehavior(Action action) + { + this._action = action; + } + + public async ValueTask HandleAsync( + WorkflowBehaviorContext context, + WorkflowBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + this._action(context); + return await continuation(cancellationToken); + } + } + + private sealed class ThrowingWorkflowBehavior : IWorkflowBehavior + { + public ValueTask HandleAsync( + WorkflowBehaviorContext context, + WorkflowBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + throw new InvalidOperationException("Test exception from workflow behavior"); + } + } + + private sealed class NullWorkflowContext : IWorkflowContext + { + public static readonly NullWorkflowContext Instance = new(); + + public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) => default; + public ValueTask SendMessageAsync(object message, string? targetId, CancellationToken cancellationToken = default) => default; + public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default) => default; + public ValueTask RequestHaltAsync() => default; + public ValueTask ReadStateAsync(string key, string? scopeName = null, CancellationToken cancellationToken = default) => default; + public ValueTask ReadOrInitStateAsync(string key, Func initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default) => new(initialStateFactory()); + public ValueTask> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default) => new(new HashSet()); + public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default) => default; + public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default) => default; + public IReadOnlyDictionary? TraceContext => null; + public bool ConcurrentRunsEnabled => false; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs new file mode 100644 index 0000000000..bbd6361d27 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs @@ -0,0 +1,472 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Behaviors; + +namespace Microsoft.Agents.AI.Workflows.UnitTests.Behaviors; + +/// +/// End-to-end tests that validate pipeline behaviors work with actual workflows. +/// +public class WorkflowBehaviorEndToEndTests +{ + [Fact] + public async Task Workflow_WithExecutorBehavior_BehaviorExecutesBeforeAndAfterExecutorAsync() + { + // Arrange + var executionLog = new List(); + var behavior = new LoggingExecutorBehavior(executionLog); + + var executor1 = new LoggingExecutor("executor1", executionLog); + var executor2 = new LoggingExecutor("executor2", executionLog); + + var workflow = new WorkflowBuilder(executor1) + .WithBehaviors(options => options.AddExecutorBehavior(behavior)) + .AddEdge(executor1, executor2) + .Build(); + + // Act + await using var run = await InProcessExecution.RunAsync(workflow, "test-input"); + + // Assert + executionLog.Should().ContainInOrder( + "Behavior:PreExecution:executor1", + "Executor:executor1", + "Behavior:PreExecution:executor2", + "Executor:executor2" + ); + } + + [Fact] + public async Task Workflow_WithWorkflowBehavior_BehaviorExecutesAtStartAsync() + { + // Arrange + var executionLog = new List(); + var behavior = new LoggingWorkflowBehavior(executionLog); + + var executor = new LoggingExecutor("executor", executionLog); + + var workflow = new WorkflowBuilder(executor) + .WithBehaviors(options => options.AddWorkflowBehavior(behavior)) + .Build(); + + // Act + await using var run = await InProcessExecution.RunAsync(workflow, "test-input"); + + // Assert - workflow start behavior should execute before executor + executionLog.Should().Contain("WorkflowBehavior:Starting"); + executionLog.Should().Contain("Executor:executor"); + + var startIndex = executionLog.IndexOf("WorkflowBehavior:Starting"); + var executorIndex = executionLog.IndexOf("Executor:executor"); + startIndex.Should().BeLessThan(executorIndex); + } + + [Fact] + public async Task Workflow_WithWorkflowBehavior_BehaviorExecutesAtEndAsync() + { + // Arrange + var executionLog = new List(); + var behavior = new LoggingWorkflowBehavior(executionLog); + var executor = new SimpleExecutor("executor"); + + var workflow = new WorkflowBuilder(executor) + .WithBehaviors(options => options.AddWorkflowBehavior(behavior)) + .Build(); + + // Act - dispose triggers the Ending stage + await using (await InProcessExecution.RunAsync(workflow, "test-input")) + { + } + + // Assert - both Starting and Ending stages should execute, in order + executionLog.Should().Contain("WorkflowBehavior:Starting"); + executionLog.Should().Contain("WorkflowBehavior:Ending"); + + var startIndex = executionLog.IndexOf("WorkflowBehavior:Starting"); + var endIndex = executionLog.IndexOf("WorkflowBehavior:Ending"); + startIndex.Should().BeLessThan(endIndex); + } + + [Fact] + public async Task Workflow_WithBothBehaviorTypes_ExecutesInCorrectOrderAsync() + { + // Arrange + var executionLog = new List(); + var workflowBehavior = new LoggingWorkflowBehavior(executionLog); + var executorBehavior = new LoggingExecutorBehavior(executionLog); + var executor = new LoggingExecutor("executor", executionLog); + + var workflow = new WorkflowBuilder(executor) + .WithBehaviors(options => + { + options.AddWorkflowBehavior(workflowBehavior); + options.AddExecutorBehavior(executorBehavior); + }) + .Build(); + + // Act + await using (await InProcessExecution.RunAsync(workflow, "test-input")) + { + } + + // Assert - Starting → PreExecution (with executor) → Ending + executionLog.Should().ContainInOrder( + "WorkflowBehavior:Starting", + "Behavior:PreExecution:executor", + "Executor:executor", + "WorkflowBehavior:Ending" + ); + } + + [Fact] + public async Task Workflow_WithBehaviors_RunIdIsConsistentAcrossContextsAsync() + { + // Arrange + string? workflowBehaviorRunId = null; + string? executorBehaviorRunId = null; + + var workflowBehavior = new CapturingWorkflowBehavior(ctx => workflowBehaviorRunId = ctx.RunId); + var executorBehavior = new CapturingExecutorBehavior(ctx => executorBehaviorRunId = ctx.RunId); + + var executor = new SimpleExecutor("executor"); + var workflow = new WorkflowBuilder(executor) + .WithBehaviors(options => + { + options.AddWorkflowBehavior(workflowBehavior); + options.AddExecutorBehavior(executorBehavior); + }) + .Build(); + + // Act + string runId; + await using (var run = await InProcessExecution.RunAsync(workflow, "test-input")) + { + runId = run.RunId; + } + + // Assert - all behavior contexts share the same RunId as the run itself + workflowBehaviorRunId.Should().NotBeNullOrEmpty(); + executorBehaviorRunId.Should().NotBeNullOrEmpty(); + workflowBehaviorRunId.Should().Be(runId); + executorBehaviorRunId.Should().Be(runId); + } + + [Fact] + public async Task Workflow_WithMultipleBehaviors_AllBehaviorsExecuteAsync() + { + // Arrange + var executionLog = new List(); + var loggingBehavior = new LoggingExecutorBehavior(executionLog); + var validationBehavior = new ValidationExecutorBehavior(executionLog); + + var executor = new LoggingExecutor("executor", executionLog); + + var workflow = new WorkflowBuilder(executor) + .WithBehaviors(options => + { + options.AddExecutorBehavior(loggingBehavior); + options.AddExecutorBehavior(validationBehavior); + }) + .Build(); + + // Act + await using var run = await InProcessExecution.RunAsync(workflow, "test-input"); + + // Assert - both behaviors should execute + executionLog.Should().Contain("Behavior:PreExecution:executor"); + executionLog.Should().Contain("Validation:PreExecution:executor"); + executionLog.Should().Contain("Executor:executor"); + } + + [Fact] + public async Task Workflow_WithPerformanceMonitoringBehavior_MeasuresExecutionTimeAsync() + { + // Arrange + var measurements = new Dictionary(); + var behavior = new PerformanceMonitoringBehavior(measurements); + + var executor = new DelayExecutor("slow-executor", TimeSpan.FromMilliseconds(50)); + + var workflow = new WorkflowBuilder(executor) + .WithBehaviors(options => options.AddExecutorBehavior(behavior)) + .Build(); + + // Act + await using var run = await InProcessExecution.RunAsync(workflow, "test-input"); + + // Assert + measurements.Should().ContainKey("slow-executor"); + measurements["slow-executor"].Should().BeGreaterThanOrEqualTo(50); + } + + [Fact] + public async Task Workflow_WithoutBehaviors_ExecutesNormallyAsync() + { + // Arrange + var executionLog = new List(); + var executor = new LoggingExecutor("executor", executionLog); + + var workflow = new WorkflowBuilder(executor).Build(); + + // Act + await using var run = await InProcessExecution.RunAsync(workflow, "test-input"); + + // Assert - workflow executes normally without behaviors + executionLog.Should().Contain("Executor:executor"); + } + + [Fact] + public async Task Workflow_BehaviorShortCircuits_ExecutorDoesNotRunAsync() + { + // Arrange + var executionLog = new List(); + var shortCircuitBehavior = new ShortCircuitBehavior("short-circuit-result"); + + var executor = new LoggingExecutor("executor", executionLog); + + var workflow = new WorkflowBuilder(executor) + .WithBehaviors(options => options.AddExecutorBehavior(shortCircuitBehavior)) + .Build(); + + // Act + await using var run = await InProcessExecution.RunAsync(workflow, "test-input"); + + // Assert - executor should not execute due to short-circuit + executionLog.Should().BeEmpty(); + } + + [Fact] + public async Task Workflow_BehaviorThrowsException_EmitsErrorEventAsync() + { + // Arrange + var faultyBehavior = new FaultyBehavior(); + var executor = new SimpleExecutor("executor"); + + var workflow = new WorkflowBuilder(executor) + .WithBehaviors(options => options.AddExecutorBehavior(faultyBehavior)) + .Build(); + + // Act - exceptions from behaviors are caught and emitted as WorkflowErrorEvent, not thrown + await using var run = await InProcessExecution.RunAsync(workflow, "test-input"); + + // Assert + var behaviorException = run.OutgoingEvents.OfType() + .Should().ContainSingle().Which.Exception + .Should().BeOfType().Subject; + + behaviorException.BehaviorType.Should().Contain(nameof(FaultyBehavior)); + behaviorException.Stage.Should().Be(nameof(ExecutorStage.PreExecution)); + } + + // Test Executors + private sealed class LoggingExecutor : Executor + { + private readonly List _log; + + public LoggingExecutor(string id, List log) : base(id) + { + this._log = log; + } + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler(async (message, context, ct) => + { + this._log.Add($"Executor:{this.Id}"); + await context.SendMessageAsync(message, ct); + return message; + }); + } + + private sealed class SimpleExecutor : Executor + { + public SimpleExecutor(string id) : base(id) { } + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler(async (message, context, ct) => + { + await context.SendMessageAsync(message, ct); + return message; + }); + } + + private sealed class DelayExecutor : Executor + { + private readonly TimeSpan _delay; + + public DelayExecutor(string id, TimeSpan delay) : base(id) + { + this._delay = delay; + } + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler(async (message, context, ct) => + { + await Task.Delay(this._delay, ct); + await context.SendMessageAsync(message, ct); + return message; + }); + } + + // Test Behaviors + private sealed class LoggingExecutorBehavior : IExecutorBehavior + { + private readonly List _log; + + public LoggingExecutorBehavior(List log) + { + this._log = log; + } + + public async ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + this._log.Add($"Behavior:{context.Stage}:{context.ExecutorId}"); + return await continuation(cancellationToken); + } + } + + private sealed class LoggingWorkflowBehavior : IWorkflowBehavior + { + private readonly List _log; + + public LoggingWorkflowBehavior(List log) + { + this._log = log; + } + + public async ValueTask HandleAsync( + WorkflowBehaviorContext context, + WorkflowBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + this._log.Add($"WorkflowBehavior:{context.Stage}"); + return await continuation(cancellationToken); + } + } + + private sealed class ValidationExecutorBehavior : IExecutorBehavior + { + private readonly List _log; + + public ValidationExecutorBehavior(List log) + { + this._log = log; + } + + public async ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + this._log.Add($"Validation:{context.Stage}:{context.ExecutorId}"); + if (context.Message == null) + { + throw new InvalidOperationException("Message cannot be null"); + } + return await continuation(cancellationToken); + } + } + + private sealed class PerformanceMonitoringBehavior : IExecutorBehavior + { + private readonly Dictionary _measurements; + + public PerformanceMonitoringBehavior(Dictionary measurements) + { + this._measurements = measurements; + } + + public async ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + if (context.Stage == ExecutorStage.PreExecution) + { + var stopwatch = Stopwatch.StartNew(); + var result = await continuation(cancellationToken); + stopwatch.Stop(); + this._measurements[context.ExecutorId] = stopwatch.ElapsedMilliseconds; + return result; + } + + return await continuation(cancellationToken); + } + } + + private sealed class ShortCircuitBehavior : IExecutorBehavior + { + private readonly object _result; + + public ShortCircuitBehavior(object result) + { + this._result = result; + } + + public ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) => + new ValueTask(this._result); + } + + private sealed class FaultyBehavior : IExecutorBehavior + { + public ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) => + throw new InvalidOperationException("Intentional behavior failure for testing"); + } + + private sealed class CapturingWorkflowBehavior : IWorkflowBehavior + { + private readonly Action _capture; + + public CapturingWorkflowBehavior(Action capture) + { + this._capture = capture; + } + + public async ValueTask HandleAsync( + WorkflowBehaviorContext context, + WorkflowBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + if (context.Stage == WorkflowStage.Starting) + { + this._capture(context); + } + + return await continuation(cancellationToken); + } + } + + private sealed class CapturingExecutorBehavior : IExecutorBehavior + { + private readonly Action _capture; + + public CapturingExecutorBehavior(Action capture) + { + this._capture = capture; + } + + public async ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + this._capture(context); + return await continuation(cancellationToken); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs new file mode 100644 index 0000000000..041e4c189e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Behaviors; + +namespace Microsoft.Agents.AI.Workflows.UnitTests.Behaviors; + +/// +/// Tests for the WorkflowBehaviorOptions API and registration mechanisms. +/// +public class WorkflowBehaviorOptionsTests +{ + [Fact] + public void AddExecutorBehavior_WithInstance_RegistersBehavior() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + var behavior = new TestExecutorBehavior(); + + // Act + options.AddExecutorBehavior(behavior); + var pipeline = options.BuildPipeline(); + + // Assert + pipeline.Should().NotBeNull(); + pipeline!.HasExecutorBehaviors.Should().BeTrue(); + } + + [Fact] + public void AddWorkflowBehavior_WithInstance_RegistersBehavior() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + var behavior = new TestWorkflowBehavior(); + + // Act + options.AddWorkflowBehavior(behavior); + var pipeline = options.BuildPipeline(); + + // Assert + pipeline.Should().NotBeNull(); + pipeline!.HasWorkflowBehaviors.Should().BeTrue(); + } + + [Fact] + public void AddExecutorBehavior_MultipleInstances_RegistersAllBehaviors() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + var behavior1 = new TestExecutorBehavior(); + var behavior2 = new TestExecutorBehavior(); + var behavior3 = new TestExecutorBehavior(); + + // Act + options.AddExecutorBehavior(behavior1); + options.AddExecutorBehavior(behavior2); + options.AddExecutorBehavior(behavior3); + var pipeline = options.BuildPipeline(); + + // Assert + pipeline.Should().NotBeNull(); + pipeline!.HasExecutorBehaviors.Should().BeTrue(); + } + + [Fact] + public void AddWorkflowBehavior_MultipleInstances_RegistersAllBehaviors() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + var behavior1 = new TestWorkflowBehavior(); + var behavior2 = new TestWorkflowBehavior(); + + // Act + options.AddWorkflowBehavior(behavior1); + options.AddWorkflowBehavior(behavior2); + var pipeline = options.BuildPipeline(); + + // Assert + pipeline.Should().NotBeNull(); + pipeline!.HasWorkflowBehaviors.Should().BeTrue(); + } + + [Fact] + public void BuildPipeline_WithNoBehaviors_ReturnsEmptyPipeline() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + + // Act + var pipeline = options.BuildPipeline(); + + // Assert + pipeline.Should().NotBeNull(); + pipeline!.HasExecutorBehaviors.Should().BeFalse(); + pipeline.HasWorkflowBehaviors.Should().BeFalse(); + } + + [Fact] + public async Task WorkflowBuilder_WithBehaviors_ConfiguresBehaviorsAsync() + { + // Arrange + var behavior = new TestExecutorBehavior(); + var executor = new SimpleExecutor("test"); + + // Act + var workflow = new WorkflowBuilder(executor) + .WithBehaviors(options => options.AddExecutorBehavior(behavior)) + .Build(); + + // Assert + workflow.Should().NotBeNull(); + workflow.BehaviorPipeline.Should().NotBeNull(); + workflow.BehaviorPipeline!.HasExecutorBehaviors.Should().BeTrue(); + } + + [Fact] + public async Task WorkflowBuilder_WithBehaviors_SupportsFluentAPIAsync() + { + // Arrange + var executor = new SimpleExecutor("test"); + + // Act + var workflow = new WorkflowBuilder(executor) + .WithBehaviors(options => + { + options.AddExecutorBehavior(new TestExecutorBehavior()); + options.AddWorkflowBehavior(new TestWorkflowBehavior()); + }) + .Build(); + + // Assert + workflow.Should().NotBeNull(); + workflow.BehaviorPipeline.Should().NotBeNull(); + workflow.BehaviorPipeline!.HasExecutorBehaviors.Should().BeTrue(); + workflow.BehaviorPipeline.HasWorkflowBehaviors.Should().BeTrue(); + } + + [Fact] + public async Task WorkflowBuilder_WithoutBehaviors_HasNullPipelineAsync() + { + // Arrange + var executor = new SimpleExecutor("test"); + + // Act + var workflow = new WorkflowBuilder(executor).Build(); + + // Assert + workflow.Should().NotBeNull(); + workflow.BehaviorPipeline.Should().BeNull(); + } + + [Fact] + public void AddExecutorBehavior_NullBehavior_ThrowsArgumentNullException() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + + // Act + Action act = () => options.AddExecutorBehavior(null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void AddWorkflowBehavior_NullBehavior_ThrowsArgumentNullException() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + + // Act + Action act = () => options.AddWorkflowBehavior(null!); + + // Assert + act.Should().Throw(); + } + + // Test helper classes + private sealed class TestExecutorBehavior : IExecutorBehavior + { + public async ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + return await continuation(cancellationToken); + } + } + + private sealed class TestWorkflowBehavior : IWorkflowBehavior + { + public async ValueTask HandleAsync( + WorkflowBehaviorContext context, + WorkflowBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + return await continuation(cancellationToken); + } + } + + private sealed class SimpleExecutor : Executor + { + public SimpleExecutor(string id) : base(id) { } + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler(async (message, context) => + { + await context.SendMessageAsync(message); + return message; + }); + } +}