From 176258a4fe5bb9c14c4712fac8e30c6838f866f2 Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 09:33:59 +0100 Subject: [PATCH 01/27] feat: add pipeline behavior interfaces and contracts Introduces IWorkflowBehavior, IExecutorBehavior, WorkflowBehaviorOptions, and BehaviorExecutionException as the public API surface for pipeline behaviors on workflow and executor execution. Co-Authored-By: Claude Sonnet 4.5 --- .../Behaviors/BehaviorExecutionException.cs | 70 ++++++++++++ .../Behaviors/IExecutorBehavior.cs | 104 ++++++++++++++++++ .../Behaviors/IWorkflowBehavior.cs | 90 +++++++++++++++ .../Behaviors/WorkflowBehaviorOptions.cs | 70 ++++++++++++ 4 files changed, 334 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorExecutionException.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IWorkflowBehavior.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/WorkflowBehaviorOptions.cs 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 00000000000..c712831f3a7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorExecutionException.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +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) + { + if (behaviorType is null) { throw new ArgumentNullException(nameof(behaviorType)); } + if (stage is null) { throw new ArgumentNullException(nameof(stage)); } + if (innerException is null) { throw new ArgumentNullException(nameof(innerException)); } + this.BehaviorType = behaviorType; + this.Stage = 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/IExecutorBehavior.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs new file mode 100644 index 00000000000..dfecee69b5f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs @@ -0,0 +1,104 @@ +// 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. +/// +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. + /// 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 identifier of the executor being invoked. + /// + public string ExecutorId { get; init; } = string.Empty; + + /// + /// Gets the type of the executor being invoked. + /// + public Type ExecutorType { get; init; } = null!; + + /// + /// Gets the message being processed by the executor. + /// + public object Message { get; init; } = null!; + + /// + /// Gets the type of the message being processed. + /// + public Type MessageType { get; init; } = null!; + + /// + /// 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 IWorkflowContext WorkflowContext { get; init; } = null!; + + /// + /// 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. + /// + PreExecution, + + /// + /// After the executor completes processing the message. + /// + PostExecution +} 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 00000000000..f5d9ba7a095 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IWorkflowBehavior.cs @@ -0,0 +1,90 @@ +// 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. + /// The cancellation token to monitor for cancellation requests. + /// A task representing the asynchronous operation, with the result of the workflow operation. + 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/WorkflowBehaviorOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/WorkflowBehaviorOptions.cs new file mode 100644 index 00000000000..994b7892295 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/WorkflowBehaviorOptions.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +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(behavior ?? throw new ArgumentNullException(nameof(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(behavior ?? throw new ArgumentNullException(nameof(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); + } +} From f39dcefb887e61118bbf89c482b82936aab2f6c0 Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 09:34:16 +0100 Subject: [PATCH 02/27] feat: wire pipeline behaviors into workflow execution Adds BehaviorPipeline (internal chain-of-responsibility engine) and integrates it into Workflow, WorkflowBuilder, Executor, and InProcessRunner. Includes a zero-overhead fast path when no behaviors are registered. Co-Authored-By: Claude Sonnet 4.5 --- .../Behaviors/BehaviorPipeline.cs | 148 ++++++++++++++++++ .../Microsoft.Agents.AI.Workflows/Executor.cs | 33 ++++ .../InProc/InProcessRunner.cs | 53 ++++++- .../Microsoft.Agents.AI.Workflows/Workflow.cs | 6 + .../WorkflowBuilder.cs | 18 ++- 5 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorPipeline.cs 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 00000000000..2ca054f29f9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorPipeline.cs @@ -0,0 +1,148 @@ +// 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. + /// + /// 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/Executor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs index 9f092e8e887..b2c309e8046 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs @@ -10,6 +10,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; @@ -255,6 +256,38 @@ protected internal virtual ValueTask InitializeAsync(IWorkflowContext context, C => this.ExecuteCoreAsync(message, messageType, context, WorkflowTelemetryContext.Disabled, cancellationToken); internal async ValueTask ExecuteCoreAsync(object message, TypeId messageType, IWorkflowContext context, WorkflowTelemetryContext telemetryContext, CancellationToken cancellationToken = default) + => await this.ExecuteCoreAsync(message, messageType, context, telemetryContext, behaviorPipeline: null, cancellationToken).ConfigureAwait(false); + + internal async ValueTask ExecuteCoreAsync(object message, TypeId messageType, IWorkflowContext context, WorkflowTelemetryContext telemetryContext, BehaviorPipeline? behaviorPipeline, 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 = Guid.NewGuid().ToString(), // TODO: Get actual run ID from context + Stage = ExecutorStage.PreExecution, + WorkflowContext = context, + TraceContext = context.TraceContext, + Properties = null + }; + + return await behaviorPipeline.ExecuteExecutorPipelineAsync( + behaviorContext, + async (ct) => await this.ExecuteCoreInternalAsync(message, messageType, context, telemetryContext, ct).ConfigureAwait(false), + cancellationToken + ).ConfigureAwait(false); + } + + // No behaviors - execute directly (fast path) + return await this.ExecuteCoreInternalAsync(message, messageType, context, telemetryContext, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ExecuteCoreInternalAsync(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 d3f229a7da9..3f57b87dd99 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; @@ -161,10 +162,57 @@ ValueTask ISuperStepRunner.EnqueueResponseAsync(ExternalResponse response, Cance private ValueTask RaiseWorkflowEventAsync(WorkflowEvent workflowEvent) => this.OutgoingEvents.EnqueueAsync(workflowEvent); - public ValueTask BeginStreamAsync(ExecutionMode mode, CancellationToken cancellationToken = default) + public async ValueTask BeginStreamAsync(ExecutionMode mode, CancellationToken cancellationToken = default) { this.RunContext.CheckEnded(); - return new(new AsyncRunHandle(this, this, mode)); + + // Execute workflow start behaviors + if (this.Workflow.BehaviorPipeline?.HasWorkflowBehaviors == true) + { + var context = new WorkflowBehaviorContext + { + WorkflowName = this.Workflow.Name ?? string.Empty, + WorkflowDescription = this.Workflow.Description, + RunId = this.SessionId, + StartExecutorId = this.StartExecutorId, + Stage = WorkflowStage.Starting, + Properties = null + }; + + await this.Workflow.BehaviorPipeline.ExecuteWorkflowPipelineAsync( + context, + (ct) => new ValueTask(0), + 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.SessionId, + StartExecutorId = this.StartExecutorId, + Stage = WorkflowStage.Ending, + Properties = null + }; + + await this.Workflow.BehaviorPipeline.ExecuteWorkflowPipelineAsync( + context, + (ct) => new ValueTask(0), + cancellationToken + ).ConfigureAwait(false); + } } public ValueTask ResumeStreamAsync(ExecutionMode mode, CheckpointInfo fromCheckpoint, CancellationToken cancellationToken = default) @@ -268,6 +316,7 @@ await executor.ExecuteCoreAsync( messageType, this.RunContext.BindWorkflowContext(receiverId, envelope.TraceContext), this.TelemetryContext, + this.Workflow.BehaviorPipeline, cancellationToken ).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs index eff1cfb9a34..2bdc2dd484a 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 e29abca5abd..0afe5519f5c 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,19 @@ 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. + public WorkflowBuilder WithBehaviors(Action configure) + { + this._behaviorOptions ??= new WorkflowBehaviorOptions(); + configure?.Invoke(this._behaviorOptions); + return this; + } + /// /// Binds the specified executor (via registration) to the workflow, allowing it to participate in workflow execution. /// @@ -571,7 +586,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 From c9dcc2998b5f80297cdde10fcc7f30f8bd18812e Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 09:34:40 +0100 Subject: [PATCH 03/27] test: add unit tests and docs for pipeline behaviors Adds XUnit tests for BehaviorExecutionException, BehaviorPipeline, WorkflowBehaviorOptions, and integration tests for the full behavior pipeline. Includes developer documentation (README.md) covering usage, examples, and API reference. Co-Authored-By: Claude Sonnet 4.5 --- .../Behaviors/README.md | 494 ++++++++++++++++++ .../BehaviorExecutionExceptionTests.cs | 194 +++++++ .../Behaviors/BehaviorPipelineTests.cs | 395 ++++++++++++++ .../WorkflowBehaviorIntegrationTests.cs | 337 ++++++++++++ .../Behaviors/WorkflowBehaviorOptionsTests.cs | 215 ++++++++ 5 files changed, 1635 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/README.md create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorExecutionExceptionTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorPipelineTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorIntegrationTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs 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 00000000000..9b0da915efd --- /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/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 00000000000..cf2893cdf35 --- /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 00000000000..ffe2175c396 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorPipelineTests.cs @@ -0,0 +1,395 @@ +// 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 + }; + + // 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_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 + }; + + // 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 + }; + + // 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 + }; + + // 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 + }; + + // 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"); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorIntegrationTests.cs new file mode 100644 index 00000000000..7078cd97530 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorIntegrationTests.cs @@ -0,0 +1,337 @@ +// 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; + +/// +/// Integration tests that validate pipeline behaviors work end-to-end with actual workflows. +/// +public class WorkflowBehaviorIntegrationTests +{ + [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_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 + run.OutgoingEvents.OfType() + .Should().ContainSingle() + .Which.Exception.Should().BeOfType(); + } + + // 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"); + } +} 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 00000000000..041e4c189e1 --- /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; + }); + } +} From 7e2d95984c3531481ecede604d79daa077f2a03d Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 10:05:54 +0100 Subject: [PATCH 04/27] .NET Workflows: Remove unused ExecutorStage.PostExecution enum value PostExecution was never set in ExecutorBehaviorContext. Behaviors already support post-execution logic naturally by placing code after the `await continuation()` call. Clarify this in docs and remove the dead value. Co-Authored-By: Claude Sonnet 4.5 --- .../Behaviors/IExecutorBehavior.cs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs index dfecee69b5f..bb8bc0ca17a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs @@ -13,6 +13,8 @@ namespace Microsoft.Agents.AI.Workflows.Behaviors; /// /// 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 { @@ -93,12 +95,9 @@ public sealed class ExecutorBehaviorContext public enum ExecutorStage { /// - /// Before the executor begins processing the message. + /// 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, - - /// - /// After the executor completes processing the message. - /// - PostExecution + PreExecution } From 2cd59f236746c3e22d754b674b4fe1c4d3bd1512 Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 10:06:16 +0100 Subject: [PATCH 05/27] .NET Workflows: Fix null configure silently ignored in WithBehaviors Passing null to WithBehaviors previously created an empty WorkflowBehaviorOptions and returned without error. Use Throw.IfNull to match the validation pattern used by all other WorkflowBuilder methods. Co-Authored-By: Claude Sonnet 4.5 --- dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs index 0afe5519f5c..2e7f9edfe9e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs @@ -152,10 +152,11 @@ internal void SetTelemetryContext(WorkflowTelemetryContext context) /// /// An action to configure the behavior options. /// The current instance, enabling fluent configuration. + /// is . public WorkflowBuilder WithBehaviors(Action configure) { this._behaviorOptions ??= new WorkflowBehaviorOptions(); - configure?.Invoke(this._behaviorOptions); + Throw.IfNull(configure).Invoke(this._behaviorOptions); return this; } From 40d77cb23b3fb3b28ed18b807550d781e22c1255 Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 10:06:45 +0100 Subject: [PATCH 06/27] .NET Workflows: Avoid async state machine allocation in BeginStreamAsync The async keyword on BeginStreamAsync caused a state machine to be allocated on every call, even when no behaviors are configured. Split into a sync fast path returning ValueTask directly, and a private async method used only when behaviors are present. Co-Authored-By: Claude Sonnet 4.5 --- .../InProc/InProcessRunner.cs | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index 3f57b87dd99..da53561fab5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -162,29 +162,33 @@ ValueTask ISuperStepRunner.EnqueueResponseAsync(ExternalResponse response, Cance private ValueTask RaiseWorkflowEventAsync(WorkflowEvent workflowEvent) => this.OutgoingEvents.EnqueueAsync(workflowEvent); - public async ValueTask BeginStreamAsync(ExecutionMode mode, CancellationToken cancellationToken = default) + public ValueTask BeginStreamAsync(ExecutionMode mode, CancellationToken cancellationToken = default) { this.RunContext.CheckEnded(); - // Execute workflow start behaviors - if (this.Workflow.BehaviorPipeline?.HasWorkflowBehaviors == true) - { - var context = new WorkflowBehaviorContext - { - WorkflowName = this.Workflow.Name ?? string.Empty, - WorkflowDescription = this.Workflow.Description, - RunId = this.SessionId, - StartExecutorId = this.StartExecutorId, - Stage = WorkflowStage.Starting, - Properties = null - }; + if (this.Workflow.BehaviorPipeline?.HasWorkflowBehaviors != true) + return new ValueTask(new AsyncRunHandle(this, this, mode)); - await this.Workflow.BehaviorPipeline.ExecuteWorkflowPipelineAsync( - context, - (ct) => new ValueTask(0), - cancellationToken - ).ConfigureAwait(false); - } + 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.SessionId, + StartExecutorId = this.StartExecutorId, + Stage = WorkflowStage.Starting, + Properties = null + }; + + await this.Workflow.BehaviorPipeline!.ExecuteWorkflowPipelineAsync( + context, + (ct) => new ValueTask(0), + cancellationToken + ).ConfigureAwait(false); return new AsyncRunHandle(this, this, mode); } From 08e90ee47bd7d054c75772e3c615b45c2744a019 Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 10:07:25 +0100 Subject: [PATCH 07/27] .NET Workflows: Fix RunId correlation in ExecutorBehaviorContext A new GUID was generated per executor invocation, making it impossible for behaviors to correlate calls within the same workflow run. Pass the run ID alongside BehaviorPipeline through the internal ExecuteAsync overload so behaviors always see the correct run identifier. Co-Authored-By: Claude Sonnet 4.5 --- dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs | 6 +++--- .../Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs index b2c309e8046..45f3a068ddb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs @@ -256,9 +256,9 @@ protected internal virtual ValueTask InitializeAsync(IWorkflowContext context, C => this.ExecuteCoreAsync(message, messageType, context, WorkflowTelemetryContext.Disabled, cancellationToken); internal async ValueTask ExecuteCoreAsync(object message, TypeId messageType, IWorkflowContext context, WorkflowTelemetryContext telemetryContext, CancellationToken cancellationToken = default) - => await this.ExecuteCoreAsync(message, messageType, context, telemetryContext, behaviorPipeline: null, cancellationToken).ConfigureAwait(false); + => await this.ExecuteCoreAsync(message, messageType, context, telemetryContext, behaviorPipeline: null, runId: null, cancellationToken).ConfigureAwait(false); - internal async ValueTask ExecuteCoreAsync(object message, TypeId messageType, IWorkflowContext context, WorkflowTelemetryContext telemetryContext, BehaviorPipeline? behaviorPipeline, CancellationToken cancellationToken = default) + internal async ValueTask ExecuteCoreAsync(object message, TypeId messageType, IWorkflowContext context, WorkflowTelemetryContext telemetryContext, BehaviorPipeline? behaviorPipeline, string? runId, CancellationToken cancellationToken = default) { // Check if behaviors are configured if (behaviorPipeline?.HasExecutorBehaviors == true) @@ -269,7 +269,7 @@ protected internal virtual ValueTask InitializeAsync(IWorkflowContext context, C ExecutorType = this.GetType(), Message = message, MessageType = message.GetType(), - RunId = Guid.NewGuid().ToString(), // TODO: Get actual run ID from context + RunId = runId ?? string.Empty, Stage = ExecutorStage.PreExecution, WorkflowContext = context, TraceContext = 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 da53561fab5..279f5834a80 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -321,6 +321,7 @@ await executor.ExecuteCoreAsync( this.RunContext.BindWorkflowContext(receiverId, envelope.TraceContext), this.TelemetryContext, this.Workflow.BehaviorPipeline, + this.SessionId, cancellationToken ).ConfigureAwait(false); } From 59043ba5b2eb15c4720287852fff25ebfef863df Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 10:08:36 +0100 Subject: [PATCH 08/27] .NET Workflows: Wire ExecuteWorkflowEndBehaviorsAsync into workflow teardown WorkflowStage.Ending was unreachable because ExecuteWorkflowEndBehaviorsAsync was defined but never called. Register it as a callback on InProcessRunnerContext so it fires during EndRunAsync, before executor disposal, symmetrically with the Starting behaviors fired in BeginStreamAsync. Co-Authored-By: Claude Sonnet 4.5 --- .../InProc/InProcessRunner.cs | 1 + .../InProc/InProcessRunnerContext.cs | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index 279f5834a80..fe924ae9b82 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -56,6 +56,7 @@ private InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager this.Workflow = Throw.IfNull(workflow); this.RunContext = new InProcessRunnerContext(workflow, this.SessionId, checkpointingEnabled: checkpointManager != null, this.OutgoingEvents, this.StepTracer, existingOwnerSignoff, subworkflow, enableConcurrentRuns); + this.RunContext.SetRunEndingCallback(this.ExecuteWorkflowEndBehaviorsAsync); this.CheckpointManager = checkpointManager; this._knownValidInputTypes = knownValidInputTypes != null diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index f0bb8cac26a..f82a990725a 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 sessionId, @@ -70,6 +72,8 @@ public InProcessRunnerContext( this.ConcurrentRunsEnabled = enableConcurrentRuns; this.OutgoingEvents = outgoingEvents; } + internal void SetRunEndingCallback(Func callback) => this._onRunEnding = callback; + public WorkflowTelemetryContext TelemetryContext => this._workflow.TelemetryContext; public IExternalRequestSink RegisterPort(string executorId, RequestPort port) @@ -449,6 +453,11 @@ public async ValueTask EndRunAsync() { if (Interlocked.Exchange(ref this._runEnded, 1) == 0) { + if (this._onRunEnding is not null) + { + await this._onRunEnding(CancellationToken.None).ConfigureAwait(false); + } + foreach (string executorId in this._executors.Keys) { Task executorTask = this._executors[executorId]; From 14c7574dc08fdf77b000f4c9222ff195fdca29a8 Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 10:18:19 +0100 Subject: [PATCH 09/27] Bug 6: Use required keyword on ExecutorBehaviorContext properties Replace null! initializer workarounds with the required keyword on ExecutorType, Message, MessageType, and WorkflowContext properties so the compiler enforces initialization at the call site. Co-Authored-By: Claude Sonnet 4.5 --- .../Behaviors/IExecutorBehavior.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs index bb8bc0ca17a..ba725864d60 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs @@ -51,17 +51,17 @@ public sealed class ExecutorBehaviorContext /// /// Gets the type of the executor being invoked. /// - public Type ExecutorType { get; init; } = null!; + public required Type ExecutorType { get; init; } /// /// Gets the message being processed by the executor. /// - public object Message { get; init; } = null!; + public required object Message { get; init; } /// /// Gets the type of the message being processed. /// - public Type MessageType { get; init; } = null!; + public required Type MessageType { get; init; } /// /// Gets the unique identifier for the workflow execution run. @@ -76,7 +76,7 @@ public sealed class ExecutorBehaviorContext /// /// Gets the workflow context for this execution. /// - public IWorkflowContext WorkflowContext { get; init; } = null!; + public required IWorkflowContext WorkflowContext { get; init; } /// /// Gets the trace context for distributed tracing. From 79f9f2c5a63eb2c00df274ccf6acba1408aaf237 Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 10:19:20 +0100 Subject: [PATCH 10/27] Bug 7: Add non-generic ExecuteWorkflowPipelineAsync overload Add a ValueTask (void) overload of ExecuteWorkflowPipelineAsync to BehaviorPipeline so callers that don't need a return value can avoid the dummy (ct) => new ValueTask(0) continuation pattern. Update both call sites in InProcessRunner to use ValueTask.CompletedTask. Co-Authored-By: Claude Sonnet 4.5 --- .../Behaviors/BehaviorPipeline.cs | 17 +++++++++++++++++ .../InProc/InProcessRunner.cs | 4 ++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorPipeline.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorPipeline.cs index 2ca054f29f9..5c9b00f6b84 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorPipeline.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorPipeline.cs @@ -69,6 +69,23 @@ public BehaviorPipeline( 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. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index fe924ae9b82..d03a4d06347 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -187,7 +187,7 @@ private async Task BeginStreamWithBehaviorsAsync(ExecutionMode m await this.Workflow.BehaviorPipeline!.ExecuteWorkflowPipelineAsync( context, - (ct) => new ValueTask(0), + (ct) => ValueTask.CompletedTask, cancellationToken ).ConfigureAwait(false); @@ -214,7 +214,7 @@ internal async ValueTask ExecuteWorkflowEndBehaviorsAsync(CancellationToken canc await this.Workflow.BehaviorPipeline.ExecuteWorkflowPipelineAsync( context, - (ct) => new ValueTask(0), + (ct) => ValueTask.CompletedTask, cancellationToken ).ConfigureAwait(false); } From 2aed869bc5fb8c708a8f529c265a1fb428f4c3eb Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 10:25:28 +0100 Subject: [PATCH 11/27] Bug 8: Add tests for Ending stage and RunId consistency Add WorkflowBehavior_BehaviorExecutesAtEndAsync to verify that WorkflowStage.Ending fires during run disposal, and Workflow_WithBehaviors_RunIdIsConsistentAcrossContextsAsync to verify that workflow, executor, and Run all share the same RunId. Also fix net472 build issues introduced by Bugs 6 and 7: - Add InjectRequiredMemberOnLegacy/InjectCompilerFeatureRequiredOnLegacy polyfills to the csproj for the required keyword (Bug 6) - Replace ValueTask.CompletedTask (net5+ only) with default (Bug 7) - Add NullWorkflowContext stub and WorkflowContext initializer to BehaviorPipelineTests to satisfy the now-required property (Bug 6) Co-Authored-By: Claude Sonnet 4.5 --- .../InProc/InProcessRunner.cs | 4 +- .../Microsoft.Agents.AI.Workflows.csproj | 2 + .../Behaviors/BehaviorPipelineTests.cs | 32 +++++- .../WorkflowBehaviorIntegrationTests.cs | 101 ++++++++++++++++++ 4 files changed, 132 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index d03a4d06347..abab9e9858e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -187,7 +187,7 @@ private async Task BeginStreamWithBehaviorsAsync(ExecutionMode m await this.Workflow.BehaviorPipeline!.ExecuteWorkflowPipelineAsync( context, - (ct) => ValueTask.CompletedTask, + (ct) => default, cancellationToken ).ConfigureAwait(false); @@ -214,7 +214,7 @@ internal async ValueTask ExecuteWorkflowEndBehaviorsAsync(CancellationToken canc await this.Workflow.BehaviorPipeline.ExecuteWorkflowPipelineAsync( context, - (ct) => ValueTask.CompletedTask, + (ct) => default, cancellationToken ).ConfigureAwait(false); } 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 032314c657b..71e79ac62d8 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 @@ -10,6 +10,8 @@ true true true + true + true diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorPipelineTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorPipelineTests.cs index ffe2175c396..3477f4c1250 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorPipelineTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorPipelineTests.cs @@ -26,7 +26,8 @@ public async Task ExecutorPipeline_WithNoBehaviors_ReturnsFastPathAsync() Message = "test", MessageType = typeof(string), RunId = Guid.NewGuid().ToString(), - Stage = ExecutorStage.PreExecution + Stage = ExecutorStage.PreExecution, + WorkflowContext = NullWorkflowContext.Instance }; // Act @@ -58,7 +59,8 @@ public async Task ExecutorPipeline_WithSingleBehavior_ExecutesBehaviorAsync() Message = "test", MessageType = typeof(string), RunId = Guid.NewGuid().ToString(), - Stage = ExecutorStage.PreExecution + Stage = ExecutorStage.PreExecution, + WorkflowContext = NullWorkflowContext.Instance }; // Act @@ -93,7 +95,8 @@ public async Task ExecutorPipeline_WithMultipleBehaviors_ExecutesInOrderAsync() Message = "test", MessageType = typeof(string), RunId = Guid.NewGuid().ToString(), - Stage = ExecutorStage.PreExecution + Stage = ExecutorStage.PreExecution, + WorkflowContext = NullWorkflowContext.Instance }; // Act @@ -129,7 +132,8 @@ public async Task ExecutorPipeline_BehaviorCanShortCircuit_SkipsRemainingPipelin Message = "test", MessageType = typeof(string), RunId = Guid.NewGuid().ToString(), - Stage = ExecutorStage.PreExecution + Stage = ExecutorStage.PreExecution, + WorkflowContext = NullWorkflowContext.Instance }; // Act @@ -162,7 +166,8 @@ public async Task ExecutorPipeline_BehaviorThrowsException_WrapsInBehaviorExecut Message = "test", MessageType = typeof(string), RunId = Guid.NewGuid().ToString(), - Stage = ExecutorStage.PreExecution + Stage = ExecutorStage.PreExecution, + WorkflowContext = NullWorkflowContext.Instance }; // Act @@ -392,4 +397,21 @@ public ValueTask HandleAsync( 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/WorkflowBehaviorIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorIntegrationTests.cs index 7078cd97530..d7da72878b5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorIntegrationTests.cs @@ -68,6 +68,65 @@ public async Task Workflow_WithWorkflowBehavior_BehaviorExecutesAtStartAsync() 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_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() { @@ -334,4 +393,46 @@ private sealed class FaultyBehavior : IExecutorBehavior 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); + } + } } From ef55eb6c7432cfa9e2c9e2dffa5c512c3b535de5 Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 10:27:15 +0100 Subject: [PATCH 12/27] Bug 9: Use Throw.IfNull in BehaviorExecutionException Replace manual if-null-throw pattern in the 3-parameter constructor with Throw.IfNull from Microsoft.Shared.Diagnostics, consistent with the rest of the project. Co-Authored-By: Claude Sonnet 4.5 --- .../Behaviors/BehaviorExecutionException.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorExecutionException.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorExecutionException.cs index c712831f3a7..cddd9e708dc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorExecutionException.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorExecutionException.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.Behaviors; @@ -51,11 +52,9 @@ public BehaviorExecutionException(string message, Exception innerException) public BehaviorExecutionException(string behaviorType, string stage, Exception innerException) : base($"Error executing behavior '{behaviorType}' at stage '{stage}'", innerException) { - if (behaviorType is null) { throw new ArgumentNullException(nameof(behaviorType)); } - if (stage is null) { throw new ArgumentNullException(nameof(stage)); } - if (innerException is null) { throw new ArgumentNullException(nameof(innerException)); } - this.BehaviorType = behaviorType; - this.Stage = stage; + Throw.IfNull(innerException); + this.BehaviorType = Throw.IfNull(behaviorType); + this.Stage = Throw.IfNull(stage); } /// From eb2dc74e864e0db318669fcaaaac89f914f9c042 Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 10:29:21 +0100 Subject: [PATCH 13/27] Rename WorkflowBehaviorIntegrationTests to WorkflowBehaviorEndToEndTests The tests exercise an in-process pipeline with no external dependencies, making "end-to-end" a more accurate description than "integration". Co-Authored-By: Claude Sonnet 4.5 --- ...orIntegrationTests.cs => WorkflowBehaviorEndToEndTests.cs} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/{WorkflowBehaviorIntegrationTests.cs => WorkflowBehaviorEndToEndTests.cs} (99%) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs similarity index 99% rename from dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorIntegrationTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs index d7da72878b5..fb00a8f942c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs @@ -12,9 +12,9 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests.Behaviors; /// -/// Integration tests that validate pipeline behaviors work end-to-end with actual workflows. +/// End-to-end tests that validate pipeline behaviors work with actual workflows. /// -public class WorkflowBehaviorIntegrationTests +public class WorkflowBehaviorEndToEndTests { [Fact] public async Task Workflow_WithExecutorBehavior_BehaviorExecutesBeforeAndAfterExecutorAsync() From 6edc02a8bd61634cdad3ff9345fb1f811e4b5052 Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 10:34:13 +0100 Subject: [PATCH 14/27] Add comment explaining CancellationToken.None for end behaviors Document the known limitation that WorkflowStage.Ending behaviors receive CancellationToken.None, explaining why and what a fix would require. Co-Authored-By: Claude Sonnet 4.5 --- .../InProc/InProcessRunnerContext.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index f82a990725a..ef94259a73c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -455,6 +455,11 @@ public async ValueTask EndRunAsync() { 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); } From aacf1c00b877a361cdd4c4f001e3ee08e71e1c6b Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 11:25:11 +0100 Subject: [PATCH 15/27] Bug 11: Use Throw.IfNull in WorkflowBehaviorOptions Replace the ?? throw new ArgumentNullException(...) pattern with Throw.IfNull() from Microsoft.Shared.Diagnostics, consistent with the rest of the project. Co-Authored-By: Claude Sonnet 4.5 --- .../Behaviors/WorkflowBehaviorOptions.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/WorkflowBehaviorOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/WorkflowBehaviorOptions.cs index 994b7892295..4d53b2e272e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/WorkflowBehaviorOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/WorkflowBehaviorOptions.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.Behaviors; @@ -21,7 +21,7 @@ public sealed class WorkflowBehaviorOptions /// Thrown when is null. public WorkflowBehaviorOptions AddExecutorBehavior(IExecutorBehavior behavior) { - this.ExecutorBehaviors.Add(behavior ?? throw new ArgumentNullException(nameof(behavior))); + this.ExecutorBehaviors.Add(Throw.IfNull(behavior)); return this; } @@ -33,7 +33,7 @@ public WorkflowBehaviorOptions AddExecutorBehavior(IExecutorBehavior behavior) /// Thrown when is null. public WorkflowBehaviorOptions AddWorkflowBehavior(IWorkflowBehavior behavior) { - this.WorkflowBehaviors.Add(behavior ?? throw new ArgumentNullException(nameof(behavior))); + this.WorkflowBehaviors.Add(Throw.IfNull(behavior)); return this; } From 53b31c7f26b9f03090a418e51a7ad16a153260e0 Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 11:25:38 +0100 Subject: [PATCH 16/27] Bug 12: Guard against double-registration in SetRunEndingCallback Throw InvalidOperationException if a run-ending callback is registered more than once, preventing silent overwrites of the first registration. Co-Authored-By: Claude Sonnet 4.5 --- .../InProc/InProcessRunnerContext.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index ef94259a73c..b1cea2d6857 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -72,7 +72,15 @@ public InProcessRunnerContext( this.ConcurrentRunsEnabled = enableConcurrentRuns; this.OutgoingEvents = outgoingEvents; } - internal void SetRunEndingCallback(Func callback) => this._onRunEnding = callback; + 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; From 5a92749e1041690cb80dc122210e5d4ed9696a2c Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 11:27:20 +0100 Subject: [PATCH 17/27] Bug 13: Add combined workflow + executor behavior end-to-end test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Workflow_WithBothBehaviorTypes_ExecutesInCorrectOrderAsync to verify that workflow and executor behaviors interleave correctly: Starting → PreExecution → executor → Ending. Also fix spurious XML doc warning in WorkflowBehaviorOptions introduced by Bug 11 (use fully-qualified System.ArgumentNullException in cref). Co-Authored-By: Claude Sonnet 4.5 --- .../Behaviors/WorkflowBehaviorOptions.cs | 4 +-- .../WorkflowBehaviorEndToEndTests.cs | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/WorkflowBehaviorOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/WorkflowBehaviorOptions.cs index 4d53b2e272e..7c204f8a0f9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/WorkflowBehaviorOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/WorkflowBehaviorOptions.cs @@ -18,7 +18,7 @@ public sealed class WorkflowBehaviorOptions /// /// The executor behavior instance to register. /// The current options instance for method chaining. - /// Thrown when is null. + /// Thrown when is null. public WorkflowBehaviorOptions AddExecutorBehavior(IExecutorBehavior behavior) { this.ExecutorBehaviors.Add(Throw.IfNull(behavior)); @@ -30,7 +30,7 @@ public WorkflowBehaviorOptions AddExecutorBehavior(IExecutorBehavior behavior) /// /// The workflow behavior instance to register. /// The current options instance for method chaining. - /// Thrown when is null. + /// Thrown when is null. public WorkflowBehaviorOptions AddWorkflowBehavior(IWorkflowBehavior behavior) { this.WorkflowBehaviors.Add(Throw.IfNull(behavior)); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs index fb00a8f942c..0efba9b4dcc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs @@ -94,6 +94,37 @@ public async Task Workflow_WithWorkflowBehavior_BehaviorExecutesAtEndAsync() 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() { From b660994e74efc8b08f8c24da12307e9582e67184 Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 11:32:43 +0100 Subject: [PATCH 18/27] Bug 14: Document continuation call semantics in IExecutorBehavior Clarify that continuation should be called exactly once, and explain what happens if it is called multiple times or not at all. Co-Authored-By: Claude Sonnet 4.5 --- .../Behaviors/IExecutorBehavior.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs index ba725864d60..94b1484f41d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs @@ -22,7 +22,9 @@ 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. + /// 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( From ee7f921c039e6d9cceb3b8958c3b1f1977868645 Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 11:32:50 +0100 Subject: [PATCH 19/27] Bug 15: Use Throw.IfNullOrEmpty for behaviorType and stage Replace Throw.IfNull with Throw.IfNullOrEmpty so that empty strings produce a clear ArgumentException rather than silently creating a confusing error message like "Error executing behavior '' at stage ''". Co-Authored-By: Claude Sonnet 4.5 --- .../Behaviors/BehaviorExecutionException.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorExecutionException.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorExecutionException.cs index cddd9e708dc..6701a504e05 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorExecutionException.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorExecutionException.cs @@ -53,8 +53,8 @@ public BehaviorExecutionException(string behaviorType, string stage, Exception i : base($"Error executing behavior '{behaviorType}' at stage '{stage}'", innerException) { Throw.IfNull(innerException); - this.BehaviorType = Throw.IfNull(behaviorType); - this.Stage = Throw.IfNull(stage); + this.BehaviorType = Throw.IfNullOrEmpty(behaviorType); + this.Stage = Throw.IfNullOrEmpty(stage); } /// From d0ad65a19e5c23c5a6f9c64941188f49a958acd3 Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 11:32:58 +0100 Subject: [PATCH 20/27] Bug 16: Test that finalHandler exception is not wrapped without behaviors Add ExecutorPipeline_WithNoBehaviors_FinalHandlerExceptionNotWrappedAsync to document and enforce that when no behaviors are registered, exceptions from the core finalHandler propagate as-is, without being wrapped in BehaviorExecutionException. Co-Authored-By: Claude Sonnet 4.5 --- .../Behaviors/BehaviorPipelineTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorPipelineTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorPipelineTests.cs index 3477f4c1250..50f699eac0d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorPipelineTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorPipelineTests.cs @@ -41,6 +41,35 @@ public async Task ExecutorPipeline_WithNoBehaviors_ReturnsFastPathAsync() 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() { From 3fbee529bc3b80b1542fdfd99ed31647344db117 Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 11:33:04 +0100 Subject: [PATCH 21/27] Bug 17: Validate BehaviorType and Stage in exception test Enhance Workflow_BehaviorThrowsException_EmitsErrorEventAsync to also assert that BehaviorExecutionException.BehaviorType and Stage are populated correctly, catching regressions in error context propagation. Co-Authored-By: Claude Sonnet 4.5 --- .../Behaviors/WorkflowBehaviorEndToEndTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs index 0efba9b4dcc..bbd6361d272 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs @@ -257,9 +257,12 @@ public async Task Workflow_BehaviorThrowsException_EmitsErrorEventAsync() await using var run = await InProcessExecution.RunAsync(workflow, "test-input"); // Assert - run.OutgoingEvents.OfType() - .Should().ContainSingle() - .Which.Exception.Should().BeOfType(); + 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 From a9b4cdeb3929fa59b2a1d511c07278fd830b39bd Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 13:07:58 +0100 Subject: [PATCH 22/27] Bug 18: Clarify TResult contract and continuation semantics in IWorkflowBehavior.HandleAsync Co-Authored-By: Claude Sonnet 4.5 --- .../Behaviors/IWorkflowBehavior.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IWorkflowBehavior.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IWorkflowBehavior.cs index f5d9ba7a095..f9f3ab322d5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IWorkflowBehavior.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IWorkflowBehavior.cs @@ -20,9 +20,16 @@ public interface IWorkflowBehavior /// /// 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. + /// 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, From d5503f439de7c05f58affc6ca41c5d28031133af Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Tue, 17 Feb 2026 13:08:04 +0100 Subject: [PATCH 23/27] Bug 19: Distinguish ExecutorId from ExecutorType in ExecutorBehaviorContext doc Co-Authored-By: Claude Sonnet 4.5 --- .../Behaviors/IExecutorBehavior.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs index 94b1484f41d..27d954cd2ec 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs @@ -46,7 +46,9 @@ public interface IExecutorBehavior public sealed class ExecutorBehaviorContext { /// - /// Gets the identifier of the executor being invoked. + /// 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; From f9f4f2abd811d80035886bc1e3e8b17e01c4364f Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Thu, 19 Mar 2026 22:20:43 +0100 Subject: [PATCH 24/27] =?UTF-8?q?Fix=20test=20compilation=20after=20rebase?= =?UTF-8?q?:=20ConfigureRoutes=20=E2=86=92=20ConfigureProtocol,=20RunId=20?= =?UTF-8?q?=E2=86=92=20SessionId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../WorkflowBehaviorEndToEndTests.cs | 20 +++++++++---------- .../Behaviors/WorkflowBehaviorOptionsTests.cs | 6 +++--- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs index bbd6361d272..43f126121eb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs @@ -148,7 +148,7 @@ public async Task Workflow_WithBehaviors_RunIdIsConsistentAcrossContextsAsync() string runId; await using (var run = await InProcessExecution.RunAsync(workflow, "test-input")) { - runId = run.RunId; + runId = run.SessionId; } // Assert - all behavior contexts share the same RunId as the run itself @@ -275,25 +275,25 @@ public LoggingExecutor(string id, List log) : base(id) this._log = log; } - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder.AddHandler(async (message, context, ct) => + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => + protocolBuilder.ConfigureRoutes(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) => + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => + protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(async (message, context, ct) => { await context.SendMessageAsync(message, ct); return message; - }); + })); } private sealed class DelayExecutor : Executor @@ -305,13 +305,13 @@ public DelayExecutor(string id, TimeSpan delay) : base(id) this._delay = delay; } - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder.AddHandler(async (message, context, ct) => + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => + protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(async (message, context, ct) => { await Task.Delay(this._delay, ct); await context.SendMessageAsync(message, ct); return message; - }); + })); } // Test Behaviors diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs index 041e4c189e1..b1133363201 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs @@ -205,11 +205,11 @@ private sealed class SimpleExecutor : Executor { public SimpleExecutor(string id) : base(id) { } - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder.AddHandler(async (message, context) => + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => + protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(async (message, context) => { await context.SendMessageAsync(message); return message; - }); + })); } } From 79d79df3f39bceb6188944e6cef8547f4bbeb486 Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Sun, 2 Aug 2026 18:09:48 +0200 Subject: [PATCH 25/27] Fix analyzer style issues in behavior pipeline wiring - Drop redundant explicit type argument on ExecuteWorkflowPipelineAsync - Add required braces around single-statement if in BeginStreamAsync Co-Authored-By: Claude Opus 5 (1M context) --- .../Microsoft.Agents.AI.Workflows/Behaviors/BehaviorPipeline.cs | 2 +- .../src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorPipeline.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorPipeline.cs index 5c9b00f6b84..d6adb206b9e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorPipeline.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorPipeline.cs @@ -80,7 +80,7 @@ public async ValueTask ExecuteWorkflowPipelineAsync( Func finalHandler, CancellationToken cancellationToken) { - await this.ExecuteWorkflowPipelineAsync( + await this.ExecuteWorkflowPipelineAsync( context, async ct => { await finalHandler(ct).ConfigureAwait(false); return 0; }, cancellationToken).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index 49946e73047..dbcbe63b134 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -164,7 +164,9 @@ public ValueTask BeginStreamAsync(ExecutionMode mode, Cancellati this.RunContext.CheckEnded(); if (this.Workflow.BehaviorPipeline?.HasWorkflowBehaviors != true) + { return new ValueTask(new AsyncRunHandle(this, this, mode)); + } return new ValueTask(this.BeginStreamWithBehaviorsAsync(mode, cancellationToken)); } From 5b97da4ff03380c6efc6803eb4d5657c8ab6dc56 Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Sun, 2 Aug 2026 18:43:21 +0200 Subject: [PATCH 26/27] Address Copilot review feedback Ensure teardown completes when an Ending behavior throws. EndRunAsync awaited the run-ending callback before disposing executors, so a throwing WorkflowStage.Ending behavior escaped IAsyncDisposable.DisposeAsync() and skipped executor disposal and workflow ownership release entirely. The failure is now captured and rethrown after teardown, so it is still surfaced without leaking resources. Make the behavior context property bag usable. Properties was declared IReadOnlyDictionary and hard-coded to null at every construction site, making the documented cross-behavior enrichment scenario impossible. It is now a framework-initialized mutable IDictionary, with lifetime and thread-safety documented on both context types. Correct the README, which documented an ExecutorStage.PostExecution value removed in 7e2d959, and showed the workflow Ending stage unwinding in reverse order when it is a separate pipeline pass entered in registration order. Convert three async tests that never awaited to synchronous, matching the existing sync tests in the same file. Adds end-to-end coverage for property flow between behaviors and for executor disposal when an Ending behavior throws. Co-Authored-By: Claude Opus 5 (1M context) --- .../Behaviors/IExecutorBehavior.cs | 11 +- .../Behaviors/IWorkflowBehavior.cs | 10 +- .../Behaviors/README.md | 35 ++++-- .../Microsoft.Agents.AI.Workflows/Executor.cs | 3 +- .../InProc/InProcessRunner.cs | 6 +- .../InProc/InProcessRunnerContext.cs | 18 ++- .../WorkflowBehaviorEndToEndTests.cs | 109 ++++++++++++++++++ .../Behaviors/WorkflowBehaviorOptionsTests.cs | 6 +- 8 files changed, 172 insertions(+), 26 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs index 27d954cd2ec..37e46cd6a65 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs @@ -88,9 +88,16 @@ public sealed class ExecutorBehaviorContext public IReadOnlyDictionary? TraceContext { get; init; } /// - /// Gets optional custom properties that can be used to pass additional context. + /// Gets a mutable property bag that behaviors can use to pass additional context to one another. /// - public IReadOnlyDictionary? Properties { get; init; } + /// + /// The framework initializes this to an empty dictionary, so behaviors may write to it without a null check. + /// A distinct instance is created for each executor invocation, so values written by an outer behavior are + /// visible to inner behaviors and to code running after await continuation() within the same call, + /// but do not flow to subsequent executor invocations. + /// The dictionary is not thread-safe; behaviors that fan out concurrently must synchronize their own access. + /// + public IDictionary Properties { get; init; } = new Dictionary(); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IWorkflowBehavior.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IWorkflowBehavior.cs index f9f3ab322d5..00003e50ab3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IWorkflowBehavior.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IWorkflowBehavior.cs @@ -75,9 +75,15 @@ public sealed class WorkflowBehaviorContext public WorkflowStage Stage { get; init; } /// - /// Gets optional custom properties that can be used to pass additional context. + /// Gets a mutable property bag that behaviors can use to pass additional context to one another. /// - public IReadOnlyDictionary? Properties { get; init; } + /// + /// The framework initializes this to an empty dictionary, so behaviors may write to it without a null check. + /// A distinct instance is created for each invocation, meaning values written + /// during are not visible during . + /// The dictionary is not thread-safe; behaviors that fan out concurrently must synchronize their own access. + /// + public IDictionary Properties { get; init; } = new Dictionary(); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/README.md b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/README.md index 9b0da915efd..e399b3f0a8d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/README.md +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/README.md @@ -26,21 +26,26 @@ Multiple behaviors can be chained together, forming a pipeline where each behavi ``` Workflow Start - ├─> WorkflowBehavior 1 (Starting) + ├─> WorkflowBehavior 1 (Starting) // entered in registration order ├─> WorkflowBehavior 2 (Starting) │ ├─> SuperStep Loop │ └─> Executor Message Processing - │ ├─> ExecutorBehavior 1 (PreExecution) - │ ├─> ExecutorBehavior 2 (PreExecution) + │ ├─> ExecutorBehavior 1 // code before `await continuation()` + │ ├─> ExecutorBehavior 2 │ ├─> Actual Handler Execution - │ ├─> ExecutorBehavior 2 (PostExecution) - │ └─> ExecutorBehavior 1 (PostExecution) + │ ├─> ExecutorBehavior 2 // code after `await continuation()` + │ └─> ExecutorBehavior 1 │ - ├─> WorkflowBehavior 2 (Ending) - └─> WorkflowBehavior 1 (Ending) + ├─> WorkflowBehavior 1 (Ending) // a separate pipeline pass, also + └─> WorkflowBehavior 2 (Ending) // entered in registration order ``` +Executor behaviors form a single nested chain around the handler, so the first-registered behavior is +the outermost one: it runs first on the way in and last on the way out. The `Starting` and `Ending` +workflow stages are two *independent* pipeline passes — `Ending` is not an unwinding of `Starting`, +so both are entered in registration order. + ### Pipeline Behavior Interfaces #### IExecutorBehavior @@ -63,11 +68,15 @@ public interface IExecutorBehavior - `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 +- `Stage` - Execution stage. Currently only `PreExecution`: the behavior is invoked once per executor + call, before the executor begins processing the message. To run logic after the executor completes, + place it after the `await continuation()` call rather than looking for a separate post-execution stage. - `WorkflowContext` - Access to workflow operations - `TraceContext` - Distributed tracing information +- `Properties` - Mutable `IDictionary`, initialized empty by the framework, for passing + data between behaviors. A fresh instance is created per executor invocation, so values written by an + outer behavior are visible to inner behaviors and to code after `await continuation()` in the same + call, but do not flow to later invocations. Not thread-safe. #### IWorkflowBehavior @@ -91,7 +100,9 @@ public interface IWorkflowBehavior - `Stage` - Workflow execution stage: - `Starting` - The workflow is beginning execution - `Ending` - The workflow is completing execution -- `Properties` - Custom properties dictionary +- `Properties` - Mutable `IDictionary`, initialized empty by the framework, for passing + data between behaviors within a single stage. `Starting` and `Ending` each get their own instance, so + values do not carry across stages. Not thread-safe. ## Usage Examples @@ -487,7 +498,7 @@ Pipeline behaviors are completely opt-in: ### Enums - `WorkflowStage` - Starting, Ending -- `ExecutorStage` - PreExecution, PostExecution +- `ExecutorStage` - PreExecution ### Extension Methods diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs index 45f3a068ddb..e4339ecfa1f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs @@ -272,8 +272,7 @@ protected internal virtual ValueTask InitializeAsync(IWorkflowContext context, C RunId = runId ?? string.Empty, Stage = ExecutorStage.PreExecution, WorkflowContext = context, - TraceContext = context.TraceContext, - Properties = null + TraceContext = context.TraceContext }; return await behaviorPipeline.ExecuteExecutorPipelineAsync( diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index dbcbe63b134..1f9911e26b8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -179,8 +179,7 @@ private async Task BeginStreamWithBehaviorsAsync(ExecutionMode m WorkflowDescription = this.Workflow.Description, RunId = this.SessionId, StartExecutorId = this.StartExecutorId, - Stage = WorkflowStage.Starting, - Properties = null + Stage = WorkflowStage.Starting }; await this.Workflow.BehaviorPipeline!.ExecuteWorkflowPipelineAsync( @@ -206,8 +205,7 @@ internal async ValueTask ExecuteWorkflowEndBehaviorsAsync(CancellationToken canc WorkflowDescription = this.Workflow.Description, RunId = this.SessionId, StartExecutorId = this.StartExecutorId, - Stage = WorkflowStage.Ending, - Properties = null + Stage = WorkflowStage.Ending }; await this.Workflow.BehaviorPipeline.ExecuteWorkflowPipelineAsync( diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index c042a975b98..684c77d9db1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Checkpointing; @@ -512,6 +513,8 @@ public async ValueTask EndRunAsync() { if (Interlocked.Exchange(ref this._runEnded, 1) == 0) { + ExceptionDispatchInfo? runEndingFailure = null; + if (this._onRunEnding is not null) { // CancellationToken.None is intentional here. This call originates from IAsyncDisposable.DisposeAsync() @@ -519,7 +522,18 @@ public async ValueTask EndRunAsync() // 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); + try + { + await this._onRunEnding(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + // Teardown must proceed even when an Ending behavior throws. Letting the exception escape here + // would skip executor disposal and workflow ownership release, leaking resources for the rest of + // the process lifetime. The failure is captured and rethrown once teardown completes, so it is + // surfaced to the caller rather than silently swallowed. + runEndingFailure = ExceptionDispatchInfo.Capture(ex); + } } foreach (string executorId in this._executors.Keys) @@ -542,6 +556,8 @@ public async ValueTask EndRunAsync() await this._workflow.ReleaseOwnershipAsync(this, this._previousOwnership).ConfigureAwait(false); this._ownsWorkflow = false; } + + runEndingFailure?.Throw(); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs index 43f126121eb..fa55ce09a53 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs @@ -265,6 +265,55 @@ public async Task Workflow_BehaviorThrowsException_EmitsErrorEventAsync() behaviorException.Stage.Should().Be(nameof(ExecutorStage.PreExecution)); } + [Fact] + public async Task Workflow_WithExecutorBehaviors_PropertiesFlowBetweenBehaviorsAsync() + { + // Arrange - an outer behavior enriches the context, an inner behavior reads what it wrote + object? observedByInner = null; + var enriching = new EnrichingExecutorBehavior("tenant-id", "contoso"); + var reading = new CapturingExecutorBehavior( + ctx => observedByInner = ctx.Properties.TryGetValue("tenant-id", out var value) ? value : null); + + var executor = new SimpleExecutor("executor"); + var workflow = new WorkflowBuilder(executor) + .WithBehaviors(options => + { + options.AddExecutorBehavior(enriching); + options.AddExecutorBehavior(reading); + }) + .Build(); + + // Act + await using var run = await InProcessExecution.RunAsync(workflow, "test-input"); + + // Assert - the property bag is framework-initialized and shared down the chain + observedByInner.Should().Be("contoso"); + } + + [Fact] + public async Task Workflow_EndingBehaviorThrows_ExecutorsAreStillDisposedAsync() + { + // Arrange + var disposedExecutors = new List(); + var faultyBehavior = new FaultyEndingWorkflowBehavior(); + var executor = new DisposableExecutor("disposable-executor", disposedExecutors); + + var workflow = new WorkflowBuilder(executor) + .WithBehaviors(options => options.AddWorkflowBehavior(faultyBehavior)) + .Build(); + + var run = await InProcessExecution.RunAsync(workflow, "test-input"); + + // Act - the Ending stage runs during disposal + Func disposeRun = async () => await run.DisposeAsync(); + + // Assert - the failure is surfaced to the caller... + await disposeRun.Should().ThrowAsync(); + + // ...but teardown still ran to completion, so executors were disposed rather than leaked. + disposedExecutors.Should().Contain("disposable-executor"); + } + // Test Executors private sealed class LoggingExecutor : Executor { @@ -296,6 +345,29 @@ protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBui })); } + private sealed class DisposableExecutor : Executor, IAsyncDisposable + { + private readonly List _disposed; + + public DisposableExecutor(string id, List disposed) : base(id) + { + this._disposed = disposed; + } + + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => + protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(async (message, context, ct) => + { + await context.SendMessageAsync(message, ct); + return message; + })); + + public ValueTask DisposeAsync() + { + this._disposed.Add(this.Id); + return default; + } + } + private sealed class DelayExecutor : Executor { private readonly TimeSpan _delay; @@ -353,6 +425,43 @@ public async ValueTask HandleAsync( } } + private sealed class EnrichingExecutorBehavior : IExecutorBehavior + { + private readonly string _key; + private readonly object _value; + + public EnrichingExecutorBehavior(string key, object value) + { + this._key = key; + this._value = value; + } + + public async ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + context.Properties[this._key] = this._value; + return await continuation(cancellationToken); + } + } + + private sealed class FaultyEndingWorkflowBehavior : IWorkflowBehavior + { + public async ValueTask HandleAsync( + WorkflowBehaviorContext context, + WorkflowBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + if (context.Stage == WorkflowStage.Ending) + { + throw new InvalidOperationException("Ending behavior failed"); + } + + return await continuation(cancellationToken); + } + } + private sealed class ValidationExecutorBehavior : IExecutorBehavior { private readonly List _log; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs index b1133363201..d6dcb035c02 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs @@ -99,7 +99,7 @@ public void BuildPipeline_WithNoBehaviors_ReturnsEmptyPipeline() } [Fact] - public async Task WorkflowBuilder_WithBehaviors_ConfiguresBehaviorsAsync() + public void WorkflowBuilder_WithBehaviors_ConfiguresBehaviors() { // Arrange var behavior = new TestExecutorBehavior(); @@ -117,7 +117,7 @@ public async Task WorkflowBuilder_WithBehaviors_ConfiguresBehaviorsAsync() } [Fact] - public async Task WorkflowBuilder_WithBehaviors_SupportsFluentAPIAsync() + public void WorkflowBuilder_WithBehaviors_SupportsFluentAPI() { // Arrange var executor = new SimpleExecutor("test"); @@ -139,7 +139,7 @@ public async Task WorkflowBuilder_WithBehaviors_SupportsFluentAPIAsync() } [Fact] - public async Task WorkflowBuilder_WithoutBehaviors_HasNullPipelineAsync() + public void WorkflowBuilder_WithoutBehaviors_HasNullPipeline() { // Arrange var executor = new SimpleExecutor("test"); From 6e7a189325ced4accfaaa5f0e977719898aeadaf Mon Sep 17 00:00:00 2001 From: Gijs Walraven Date: Sun, 2 Aug 2026 19:36:13 +0200 Subject: [PATCH 27/27] Close test coverage gaps in pipeline behaviors Brings the Behaviors public API and its integration points to 100% line and branch coverage (818 tests, up from 790). Workflow pipeline coverage was materially thinner than executor pipeline coverage. Adds the missing counterparts: no-behavior fast path, unwrapped finalHandler exceptions, short-circuiting, TResult propagation, and both paths of the non-generic ExecuteWorkflowPipelineAsync overload that InProcessRunner actually calls for the Starting and Ending stages. Adds coverage for behaviour that was previously asserted nowhere: cancellation token propagation to behaviors and the final handler, result transformation on the way out of the chain, nesting order (first registered is outermost: first in, last out), and the guarantee that an already-wrapped BehaviorExecutionException is not wrapped twice. Adds regression tests for several previously fixed bugs that shipped without one: WithBehaviors(null) throwing, repeated WithBehaviors calls accumulating rather than replacing, the SetRunEndingCallback double-registration guard, and Throw.IfNullOrEmpty rejecting empty behaviorType and stage. Covers the generic AddExecutorBehavior/AddWorkflowBehavior overloads, registration chaining, the three BehaviorExecutionException constructors that no test touched, full population of both context types, property-bag isolation between workflow stages and between executor invocations, and Ending behaviors running in registration order. Extends the teardown regression test to cover synchronous IDisposable executors alongside IAsyncDisposable ones. Co-Authored-By: Claude Opus 5 (1M context) --- .../BehaviorExecutionExceptionTests.cs | 72 +++- .../Behaviors/BehaviorPipelineTests.cs | 366 ++++++++++++++++++ .../WorkflowBehaviorEndToEndTests.cs | 245 +++++++++++- .../Behaviors/WorkflowBehaviorOptionsTests.cs | 81 ++++ 4 files changed, 759 insertions(+), 5 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorExecutionExceptionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorExecutionExceptionTests.cs index cf2893cdf35..5867ad98ade 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorExecutionExceptionTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorExecutionExceptionTests.cs @@ -87,7 +87,7 @@ public void Message_ContainsBehaviorType() public void Message_ContainsStage() { // Arrange - const string stage = "PostExecution"; + const string stage = "Ending"; var exception = new BehaviorExecutionException( "TestBehavior", stage, @@ -174,7 +174,7 @@ public void BehaviorType_IsAccessible() public void Stage_IsAccessible() { // Arrange - const string stage = "PostExecution"; + const string stage = "Ending"; var exception = new BehaviorExecutionException( "TestBehavior", stage, @@ -187,6 +187,74 @@ public void Stage_IsAccessible() result.Should().Be(stage); } + [Fact] + public void Constructor_Parameterless_UsesDefaultMessageAndEmptyMetadata() + { + // Act + var exception = new BehaviorExecutionException(); + + // Assert + exception.Message.Should().Be("Error executing behavior"); + exception.BehaviorType.Should().BeEmpty(); + exception.Stage.Should().BeEmpty(); + exception.InnerException.Should().BeNull(); + } + + [Fact] + public void Constructor_WithMessage_SetsMessageAndEmptyMetadata() + { + // Act + var exception = new BehaviorExecutionException("custom message"); + + // Assert + exception.Message.Should().Be("custom message"); + exception.BehaviorType.Should().BeEmpty(); + exception.Stage.Should().BeEmpty(); + exception.InnerException.Should().BeNull(); + } + + [Fact] + public void Constructor_WithMessageAndInnerException_SetsBoth() + { + // Arrange + var innerException = new InvalidOperationException("inner"); + + // Act + var exception = new BehaviorExecutionException("custom message", innerException); + + // Assert + exception.Message.Should().Be("custom message"); + exception.InnerException.Should().BeSameAs(innerException); + exception.BehaviorType.Should().BeEmpty(); + exception.Stage.Should().BeEmpty(); + } + + [Fact] + public void Constructor_WithEmptyBehaviorType_ThrowsArgumentException() + { + // Arrange + var innerException = new InvalidOperationException(); + + // Act + Action act = () => _ = new BehaviorExecutionException(string.Empty, "stage", innerException); + + // Assert - empty must be rejected distinctly from null, so the message is never "behavior '' at stage" + act.Should().Throw().Which.Should().NotBeOfType(); + } + + [Fact] + public void Constructor_WithEmptyStage_ThrowsArgumentException() + { + // Arrange + var innerException = new InvalidOperationException(); + + // Act + Action act = () => _ = new BehaviorExecutionException("behavior", string.Empty, innerException); + + // Assert + act.Should().Throw().Which.Should().NotBeOfType(); + } + 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 index 50f699eac0d..b108538bb86 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorPipelineTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorPipelineTests.cs @@ -347,6 +347,273 @@ public void HasWorkflowBehaviors_WithoutBehaviors_ReturnsFalse() pipeline!.HasWorkflowBehaviors.Should().BeFalse(); } + [Fact] + public async Task WorkflowPipeline_WithNoBehaviors_ReturnsFastPathAsync() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + var pipeline = options.BuildPipeline(); + var executed = false; + + // Act + var result = await pipeline!.ExecuteWorkflowPipelineAsync( + CreateWorkflowContext(), + async ct => { executed = true; return await Task.FromResult(42); }, + CancellationToken.None); + + // Assert + executed.Should().BeTrue(); + result.Should().Be(42); + } + + [Fact] + public async Task WorkflowPipeline_WithNoBehaviors_FinalHandlerExceptionNotWrappedAsync() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + var pipeline = options.BuildPipeline(); + + // Act + Func act = async () => await pipeline!.ExecuteWorkflowPipelineAsync( + CreateWorkflowContext(), + ct => throw new InvalidOperationException("Core handler error"), + CancellationToken.None); + + // Assert - without behaviors the raw exception propagates unwrapped + await act.Should().ThrowAsync().WithMessage("Core handler error"); + } + + [Fact] + public async Task WorkflowPipeline_ReturnsFinalHandlerResultThroughBehaviorsAsync() + { + // Arrange - TResult must survive the trip through the behavior chain + var options = new WorkflowBehaviorOptions(); + options.AddWorkflowBehavior(new TestWorkflowBehavior(_ => { })); + options.AddWorkflowBehavior(new TestWorkflowBehavior(_ => { })); + var pipeline = options.BuildPipeline(); + + // Act + var result = await pipeline!.ExecuteWorkflowPipelineAsync( + CreateWorkflowContext(), + async ct => await Task.FromResult("handler-result"), + CancellationToken.None); + + // Assert + result.Should().Be("handler-result"); + } + + [Fact] + public async Task WorkflowPipeline_BehaviorCanShortCircuit_SkipsRemainingPipelineAsync() + { + // Arrange + var innerRan = false; + var finalHandlerRan = false; + + var options = new WorkflowBehaviorOptions(); + options.AddWorkflowBehavior(new ShortCircuitingWorkflowBehavior("short-circuited")); + options.AddWorkflowBehavior(new TestWorkflowBehavior(_ => innerRan = true)); + var pipeline = options.BuildPipeline(); + + // Act + var result = await pipeline!.ExecuteWorkflowPipelineAsync( + CreateWorkflowContext(), + async ct => { finalHandlerRan = true; return await Task.FromResult("handler-result"); }, + CancellationToken.None); + + // Assert - neither the inner behavior nor the final handler runs + result.Should().Be("short-circuited"); + innerRan.Should().BeFalse(); + finalHandlerRan.Should().BeFalse(); + } + + [Fact] + public async Task WorkflowPipeline_VoidOverload_ExecutesBehaviorsAndFinalHandlerAsync() + { + // Arrange - the non-generic overload is what InProcessRunner uses for Starting/Ending + var executionOrder = new List(); + + var options = new WorkflowBehaviorOptions(); + options.AddWorkflowBehavior(new TestWorkflowBehavior(_ => executionOrder.Add("behavior1"))); + options.AddWorkflowBehavior(new TestWorkflowBehavior(_ => executionOrder.Add("behavior2"))); + var pipeline = options.BuildPipeline(); + + // Act + await pipeline!.ExecuteWorkflowPipelineAsync( + CreateWorkflowContext(), + ct => { executionOrder.Add("final"); return default; }, + CancellationToken.None); + + // Assert + executionOrder.Should().Equal("behavior1", "behavior2", "final"); + } + + [Fact] + public async Task WorkflowPipeline_VoidOverload_WithNoBehaviors_ExecutesFinalHandlerAsync() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + var pipeline = options.BuildPipeline(); + var executed = false; + + // Act + await pipeline!.ExecuteWorkflowPipelineAsync( + CreateWorkflowContext(), + ct => { executed = true; return default; }, + CancellationToken.None); + + // Assert + executed.Should().BeTrue(); + } + + [Fact] + public async Task ExecutorPipeline_BehaviorExecutionException_IsNotDoubleWrappedAsync() + { + // Arrange - an inner behavior's already-wrapped failure must not be re-wrapped by outer behaviors + var options = new WorkflowBehaviorOptions(); + options.AddExecutorBehavior(new TestExecutorBehavior(_ => { })); + options.AddExecutorBehavior(new ThrowingExecutorBehavior()); + var pipeline = options.BuildPipeline(); + + // Act + Func act = async () => await pipeline!.ExecuteExecutorPipelineAsync( + CreateExecutorContext(), + async ct => await Task.FromResult("result"), + CancellationToken.None); + + // Assert - exactly one layer of wrapping, with the original exception underneath + var wrapped = (await act.Should().ThrowAsync()).Which; + wrapped.BehaviorType.Should().Contain(nameof(ThrowingExecutorBehavior)); + wrapped.InnerException.Should().BeOfType(); + } + + [Fact] + public async Task WorkflowPipeline_BehaviorExecutionException_IsNotDoubleWrappedAsync() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + options.AddWorkflowBehavior(new TestWorkflowBehavior(_ => { })); + options.AddWorkflowBehavior(new ThrowingWorkflowBehavior()); + var pipeline = options.BuildPipeline(); + + // Act + Func act = async () => await pipeline!.ExecuteWorkflowPipelineAsync( + CreateWorkflowContext(), + async ct => await Task.FromResult(0), + CancellationToken.None); + + // Assert + var wrapped = (await act.Should().ThrowAsync()).Which; + wrapped.BehaviorType.Should().Contain(nameof(ThrowingWorkflowBehavior)); + wrapped.InnerException.Should().BeOfType(); + } + + [Fact] + public async Task ExecutorPipeline_CancellationTokenIsPropagatedToBehaviorsAndHandlerAsync() + { + // Arrange + using var cts = new CancellationTokenSource(); + CancellationToken observedByBehavior = default; + CancellationToken observedByHandler = default; + + var options = new WorkflowBehaviorOptions(); + options.AddExecutorBehavior(new TokenCapturingExecutorBehavior(ct => observedByBehavior = ct)); + var pipeline = options.BuildPipeline(); + + // Act + await pipeline!.ExecuteExecutorPipelineAsync( + CreateExecutorContext(), + async ct => { observedByHandler = ct; return await Task.FromResult("result"); }, + cts.Token); + + // Assert + observedByBehavior.Should().Be(cts.Token); + observedByHandler.Should().Be(cts.Token); + } + + [Fact] + public async Task WorkflowPipeline_CancellationTokenIsPropagatedToBehaviorsAndHandlerAsync() + { + // Arrange + using var cts = new CancellationTokenSource(); + CancellationToken observedByBehavior = default; + CancellationToken observedByHandler = default; + + var options = new WorkflowBehaviorOptions(); + options.AddWorkflowBehavior(new TokenCapturingWorkflowBehavior(ct => observedByBehavior = ct)); + var pipeline = options.BuildPipeline(); + + // Act + await pipeline!.ExecuteWorkflowPipelineAsync( + CreateWorkflowContext(), + async ct => { observedByHandler = ct; return await Task.FromResult(0); }, + cts.Token); + + // Assert + observedByBehavior.Should().Be(cts.Token); + observedByHandler.Should().Be(cts.Token); + } + + [Fact] + public async Task ExecutorPipeline_BehaviorTransformsResult_ReturnsTransformedValueAsync() + { + // Arrange - behaviors may rewrite the handler's result on the way out + var options = new WorkflowBehaviorOptions(); + options.AddExecutorBehavior(new ResultTransformingExecutorBehavior(r => $"outer({r})")); + options.AddExecutorBehavior(new ResultTransformingExecutorBehavior(r => $"inner({r})")); + var pipeline = options.BuildPipeline(); + + // Act + var result = await pipeline!.ExecuteExecutorPipelineAsync( + CreateExecutorContext(), + async ct => await Task.FromResult("core"), + CancellationToken.None); + + // Assert - the innermost behavior transforms first, the outermost last + result.Should().Be("outer(inner(core))"); + } + + [Fact] + public async Task ExecutorPipeline_MultipleBehaviors_NestInRegistrationOrderAsync() + { + // Arrange - the first registered behavior should be outermost: first in, last out + var log = new List(); + + var options = new WorkflowBehaviorOptions(); + options.AddExecutorBehavior(new NestingExecutorBehavior("A", log)); + options.AddExecutorBehavior(new NestingExecutorBehavior("B", log)); + var pipeline = options.BuildPipeline(); + + // Act + await pipeline!.ExecuteExecutorPipelineAsync( + CreateExecutorContext(), + async ct => { log.Add("handler"); return await Task.FromResult("result"); }, + CancellationToken.None); + + // Assert + log.Should().Equal("A:enter", "B:enter", "handler", "B:exit", "A:exit"); + } + + private static WorkflowBehaviorContext CreateWorkflowContext(WorkflowStage stage = WorkflowStage.Starting) => + new() + { + WorkflowName = "test-workflow", + RunId = Guid.NewGuid().ToString(), + StartExecutorId = "start", + Stage = stage + }; + + private static ExecutorBehaviorContext CreateExecutorContext() => + new() + { + ExecutorId = "test-executor", + ExecutorType = typeof(BehaviorPipelineTests), + Message = "test", + MessageType = typeof(string), + RunId = Guid.NewGuid().ToString(), + Stage = ExecutorStage.PreExecution, + WorkflowContext = NullWorkflowContext.Instance + }; + // Test helper behaviors private sealed class TestExecutorBehavior : IExecutorBehavior { @@ -427,6 +694,105 @@ public ValueTask HandleAsync( } } + private sealed class ShortCircuitingWorkflowBehavior : IWorkflowBehavior + { + private readonly object _result; + + public ShortCircuitingWorkflowBehavior(object result) + { + this._result = result; + } + + public ValueTask HandleAsync( + WorkflowBehaviorContext context, + WorkflowBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + // Short-circuit: don't call continuation + return new ValueTask((TResult)this._result); + } + } + + private sealed class TokenCapturingExecutorBehavior : IExecutorBehavior + { + private readonly Action _capture; + + public TokenCapturingExecutorBehavior(Action capture) + { + this._capture = capture; + } + + public async ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + this._capture(cancellationToken); + return await continuation(cancellationToken); + } + } + + private sealed class TokenCapturingWorkflowBehavior : IWorkflowBehavior + { + private readonly Action _capture; + + public TokenCapturingWorkflowBehavior(Action capture) + { + this._capture = capture; + } + + public async ValueTask HandleAsync( + WorkflowBehaviorContext context, + WorkflowBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + this._capture(cancellationToken); + return await continuation(cancellationToken); + } + } + + private sealed class ResultTransformingExecutorBehavior : IExecutorBehavior + { + private readonly Func _transform; + + public ResultTransformingExecutorBehavior(Func transform) + { + this._transform = transform; + } + + public async ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + var result = await continuation(cancellationToken); + return this._transform(result); + } + } + + private sealed class NestingExecutorBehavior : IExecutorBehavior + { + private readonly string _name; + private readonly List _log; + + public NestingExecutorBehavior(string name, List log) + { + this._name = name; + this._log = log; + } + + public async ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + this._log.Add($"{this._name}:enter"); + var result = await continuation(cancellationToken); + this._log.Add($"{this._name}:exit"); + return result; + } + } + private sealed class NullWorkflowContext : IWorkflowContext { public static readonly NullWorkflowContext Instance = new(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs index fa55ce09a53..e9bedf85e80 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs @@ -8,6 +8,8 @@ using System.Threading.Tasks; using FluentAssertions; using Microsoft.Agents.AI.Workflows.Behaviors; +using Microsoft.Agents.AI.Workflows.Execution; +using Microsoft.Agents.AI.Workflows.InProc; namespace Microsoft.Agents.AI.Workflows.UnitTests.Behaviors; @@ -296,10 +298,12 @@ public async Task Workflow_EndingBehaviorThrows_ExecutorsAreStillDisposedAsync() // Arrange var disposedExecutors = new List(); var faultyBehavior = new FaultyEndingWorkflowBehavior(); - var executor = new DisposableExecutor("disposable-executor", disposedExecutors); + var asyncExecutor = new DisposableExecutor("async-disposable", disposedExecutors); + var syncExecutor = new SyncDisposableExecutor("sync-disposable", disposedExecutors); - var workflow = new WorkflowBuilder(executor) + var workflow = new WorkflowBuilder(asyncExecutor) .WithBehaviors(options => options.AddWorkflowBehavior(faultyBehavior)) + .AddEdge(asyncExecutor, syncExecutor) .Build(); var run = await InProcessExecution.RunAsync(workflow, "test-input"); @@ -311,7 +315,155 @@ public async Task Workflow_EndingBehaviorThrows_ExecutorsAreStillDisposedAsync() await disposeRun.Should().ThrowAsync(); // ...but teardown still ran to completion, so executors were disposed rather than leaked. - disposedExecutors.Should().Contain("disposable-executor"); + disposedExecutors.Should().Contain("async-disposable"); + disposedExecutors.Should().Contain("sync-disposable"); + } + + [Fact] + public async Task Workflow_ExecutorBehaviorContext_IsFullyPopulatedAsync() + { + // Arrange + ExecutorBehaviorContext? captured = null; + var behavior = new CapturingExecutorBehavior(ctx => captured ??= ctx); + + var executor = new SimpleExecutor("the-executor"); + var workflow = new WorkflowBuilder(executor) + .WithBehaviors(options => options.AddExecutorBehavior(behavior)) + .Build(); + + // Act + await using var run = await InProcessExecution.RunAsync(workflow, "test-input"); + + // Assert - every context field the public API advertises is populated + captured.Should().NotBeNull(); + captured!.ExecutorId.Should().Be("the-executor"); + captured.ExecutorType.Should().Be(); + captured.Message.Should().Be("test-input"); + captured.MessageType.Should().Be(); + captured.Stage.Should().Be(ExecutorStage.PreExecution); + captured.WorkflowContext.Should().NotBeNull(); + captured.RunId.Should().NotBeNullOrEmpty(); + captured.Properties.Should().NotBeNull(); + } + + [Fact] + public async Task Workflow_WorkflowBehaviorContext_IsFullyPopulatedAsync() + { + // Arrange + WorkflowBehaviorContext? captured = null; + var behavior = new CapturingWorkflowBehavior(ctx => captured ??= ctx); + + var executor = new SimpleExecutor("start-executor"); + var workflow = new WorkflowBuilder(executor) + .WithName("my-workflow") + .WithDescription("my-description") + .WithBehaviors(options => options.AddWorkflowBehavior(behavior)) + .Build(); + + // Act + await using var run = await InProcessExecution.RunAsync(workflow, "test-input"); + + // Assert + captured.Should().NotBeNull(); + captured!.WorkflowName.Should().Be("my-workflow"); + captured.WorkflowDescription.Should().Be("my-description"); + captured.StartExecutorId.Should().Be("start-executor"); + captured.Stage.Should().Be(WorkflowStage.Starting); + captured.RunId.Should().NotBeNullOrEmpty(); + captured.Properties.Should().NotBeNull(); + } + + [Fact] + public async Task Workflow_WorkflowBehaviorProperties_AreIsolatedBetweenStagesAsync() + { + // Arrange - Starting and Ending are separate pipeline passes with separate contexts + bool? endingSawStartingValue = null; + var behavior = new StagePropertyBehavior(saw => endingSawStartingValue = saw); + + var executor = new SimpleExecutor("executor"); + var workflow = new WorkflowBuilder(executor) + .WithBehaviors(options => options.AddWorkflowBehavior(behavior)) + .Build(); + + // Act - disposal triggers the Ending stage + await using (await InProcessExecution.RunAsync(workflow, "test-input")) + { + } + + // Assert - values written during Starting do not leak into Ending + endingSawStartingValue.Should().BeFalse(); + } + + [Fact] + public async Task Workflow_ExecutorBehaviorProperties_AreIsolatedBetweenInvocationsAsync() + { + // Arrange - each executor invocation gets a fresh property bag + var sawExistingValue = new List(); + var behavior = new PerInvocationPropertyBehavior(sawExistingValue); + + var executor1 = new SimpleExecutor("executor1"); + var executor2 = new SimpleExecutor("executor2"); + 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 - two invocations, neither of which observed the other's value + sawExistingValue.Should().HaveCountGreaterThanOrEqualTo(2); + sawExistingValue.Should().AllSatisfy(saw => saw.Should().BeFalse()); + } + + [Fact] + public async Task Workflow_EndingBehaviors_RunInRegistrationOrderAsync() + { + // Arrange - Ending is a separate pass, not an unwinding of Starting + var executionLog = new List(); + var executor = new SimpleExecutor("executor"); + + var workflow = new WorkflowBuilder(executor) + .WithBehaviors(options => + { + options.AddWorkflowBehavior(new NamedWorkflowBehavior("first", executionLog)); + options.AddWorkflowBehavior(new NamedWorkflowBehavior("second", executionLog)); + }) + .Build(); + + // Act + await using (await InProcessExecution.RunAsync(workflow, "test-input")) + { + } + + // Assert - both stages are entered in registration order + executionLog.Should().Equal( + "first:Starting", + "second:Starting", + "first:Ending", + "second:Ending"); + } + + [Fact] + public void RunnerContext_SetRunEndingCallback_CalledTwice_Throws() + { + // Arrange - the runner registers exactly one Ending callback; a second must not silently overwrite it + var workflow = new WorkflowBuilder(new SimpleExecutor("executor")).Build(); + var context = new InProcessRunnerContext( + workflow, + sessionId: "test-session", + checkpointingEnabled: false, + outgoingEvents: new ConcurrentEventSink(), + stepTracer: null); + + context.SetRunEndingCallback(_ => default); + + // Act + Action act = () => context.SetRunEndingCallback(_ => default); + + // Assert + act.Should().Throw() + .WithMessage("*already been registered*"); } // Test Executors @@ -368,6 +520,25 @@ public ValueTask DisposeAsync() } } + private sealed class SyncDisposableExecutor : Executor, IDisposable + { + private readonly List _disposed; + + public SyncDisposableExecutor(string id, List disposed) : base(id) + { + this._disposed = disposed; + } + + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => + protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(async (message, context, ct) => + { + await context.SendMessageAsync(message, ct); + return message; + })); + + public void Dispose() => this._disposed.Add(this.Id); + } + private sealed class DelayExecutor : Executor { private readonly TimeSpan _delay; @@ -446,6 +617,74 @@ public EnrichingExecutorBehavior(string key, object value) } } + private sealed class StagePropertyBehavior : IWorkflowBehavior + { + private readonly Action _reportEndingSawValue; + + public StagePropertyBehavior(Action reportEndingSawValue) + { + this._reportEndingSawValue = reportEndingSawValue; + } + + public async ValueTask HandleAsync( + WorkflowBehaviorContext context, + WorkflowBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + if (context.Stage == WorkflowStage.Starting) + { + context.Properties["from-starting"] = "value"; + } + else + { + this._reportEndingSawValue(context.Properties.ContainsKey("from-starting")); + } + + return await continuation(cancellationToken); + } + } + + private sealed class PerInvocationPropertyBehavior : IExecutorBehavior + { + private readonly List _sawExistingValue; + + public PerInvocationPropertyBehavior(List sawExistingValue) + { + this._sawExistingValue = sawExistingValue; + } + + public async ValueTask HandleAsync( + ExecutorBehaviorContext context, + ExecutorBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + this._sawExistingValue.Add(context.Properties.ContainsKey("marker")); + context.Properties["marker"] = "set"; + return await continuation(cancellationToken); + } + } + + private sealed class NamedWorkflowBehavior : IWorkflowBehavior + { + private readonly string _name; + private readonly List _log; + + public NamedWorkflowBehavior(string name, List log) + { + this._name = name; + this._log = log; + } + + public async ValueTask HandleAsync( + WorkflowBehaviorContext context, + WorkflowBehaviorContinuation continuation, + CancellationToken cancellationToken) + { + this._log.Add($"{this._name}:{context.Stage}"); + return await continuation(cancellationToken); + } + } + private sealed class FaultyEndingWorkflowBehavior : IWorkflowBehavior { public async ValueTask HandleAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs index d6dcb035c02..2460d3de96e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs @@ -178,6 +178,87 @@ public void AddWorkflowBehavior_NullBehavior_ThrowsArgumentNullException() act.Should().Throw(); } + [Fact] + public void AddExecutorBehavior_GenericOverload_RegistersBehavior() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + + // Act + options.AddExecutorBehavior(); + var pipeline = options.BuildPipeline(); + + // Assert + pipeline.Should().NotBeNull(); + pipeline!.HasExecutorBehaviors.Should().BeTrue(); + } + + [Fact] + public void AddWorkflowBehavior_GenericOverload_RegistersBehavior() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + + // Act + options.AddWorkflowBehavior(); + var pipeline = options.BuildPipeline(); + + // Assert + pipeline.Should().NotBeNull(); + pipeline!.HasWorkflowBehaviors.Should().BeTrue(); + } + + [Fact] + public void AddBehavior_ReturnsSameOptionsInstance_ForChaining() + { + // Arrange + var options = new WorkflowBehaviorOptions(); + + // Act - every registration overload is documented as chainable + var chained = options + .AddExecutorBehavior(new TestExecutorBehavior()) + .AddWorkflowBehavior(new TestWorkflowBehavior()) + .AddExecutorBehavior() + .AddWorkflowBehavior(); + + // Assert + chained.Should().BeSameAs(options); + var pipeline = options.BuildPipeline(); + pipeline!.HasExecutorBehaviors.Should().BeTrue(); + pipeline.HasWorkflowBehaviors.Should().BeTrue(); + } + + [Fact] + public void WorkflowBuilder_WithBehaviors_NullConfigure_ThrowsArgumentNullException() + { + // Arrange + var builder = new WorkflowBuilder(new SimpleExecutor("test")); + + // Act + Action act = () => builder.WithBehaviors(null!); + + // Assert - a null configure callback must not be silently ignored + act.Should().Throw(); + } + + [Fact] + public void WorkflowBuilder_WithBehaviors_CalledTwice_AccumulatesBehaviors() + { + // Arrange + var executor = new SimpleExecutor("test"); + + // Act - successive calls share one options instance rather than replacing it + var workflow = new WorkflowBuilder(executor) + .WithBehaviors(options => options.AddExecutorBehavior(new TestExecutorBehavior())) + .WithBehaviors(options => options.AddWorkflowBehavior(new TestWorkflowBehavior())) + .Build(); + + // Assert + workflow.BehaviorPipeline.Should().NotBeNull(); + workflow.BehaviorPipeline!.HasExecutorBehaviors.Should().BeTrue(); + workflow.BehaviorPipeline.HasWorkflowBehaviors.Should().BeTrue(); + } + // Test helper classes private sealed class TestExecutorBehavior : IExecutorBehavior {