.NET: feat: add pipeline behavior extension points for workflow and executor execution - #3982
Conversation
|
@microsoft-github-policy-service agree company="delaware Consulting B.V." |
8a61c4c to
a9828d3
Compare
|
@markwallace-microsoft Can we make any progress on this? |
a9828d3 to
86a73f1
Compare
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…eardown 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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<int>(0) continuation pattern. Update both call sites in InProcessRunner to use ValueTask.CompletedTask. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…iors 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…lowBehavior.HandleAsync Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…ontext doc Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…l, RunId → SessionId Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
86a73f1 to
f9f4f2a
Compare
|
@markwallace-microsoft What is needed to get this feature moving? This branch has been open for a while. |
…low_Pipeline_Behavior
- Drop redundant explicit type argument on ExecuteWorkflowPipelineAsync - Add required braces around single-statement if in BeginStreamAsync Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Branch refreshed against current Current state:
On the "is this still wanted?" question — the demand has grown rather than faded since this was opened:
So there is independently-arrived-at demand from both the general cross-cutting-concerns angle and the security/validation angle, and One note that may explain part of the stall: the .NET CI has never actually run on this PR — only @markwallace-microsoft happy to adjust naming ( |
There was a problem hiding this comment.
Pull request overview
This PR adds a new pipeline-behavior extensibility model to the .NET Workflows implementation, enabling cross-cutting logic to run around workflow lifecycle and executor message processing via registered behaviors.
Changes:
- Introduces workflow/executor behavior abstractions (
IWorkflowBehavior,IExecutorBehavior) and supporting context types/options. - Implements an internal
BehaviorPipelineand wires it intoWorkflowBuilder,InProcessRunner, andExecutorwith an opt-inWithBehaviors(...)API. - Adds
BehaviorExecutionExceptionplus unit/integration tests and documentation for the behavior system.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs | Adds WithBehaviors(...) and attaches a built BehaviorPipeline to constructed workflows. |
| dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs | Stores the optional internal BehaviorPipeline on the workflow object. |
| dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj | Enables legacy injection for required-member related compiler features. |
| dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs | Adds a run-ending callback hook to trigger workflow ending behaviors. |
| dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs | Executes workflow start/end behaviors and passes the pipeline/runId into executor execution. |
| dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs | Executes executor behaviors (when configured) around core message handling. |
| dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/WorkflowBehaviorOptions.cs | Provides the registration surface for workflow/executor behaviors and pipeline construction. |
| dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/README.md | Adds developer documentation and examples for behaviors. |
| dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IWorkflowBehavior.cs | Defines workflow behavior interface, context, continuation delegate, and stage enum. |
| dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/IExecutorBehavior.cs | Defines executor behavior interface, context, continuation delegate, and stage enum. |
| dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorPipeline.cs | Implements behavior chaining and exception-wrapping behavior execution. |
| dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/BehaviorExecutionException.cs | Adds a dedicated exception type for behavior failures with stage/type context. |
| dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs | Tests registration and WorkflowBuilder.WithBehaviors(...) behavior pipeline setup. |
| dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorEndToEndTests.cs | End-to-end behavior tests across workflow/executor execution. |
| dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorPipelineTests.cs | Verifies pipeline ordering, short-circuiting, and exception wrapping. |
| dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/BehaviorExecutionExceptionTests.cs | Tests exception construction and property behavior. |
Suppressed comments (5)
dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/README.md:41
- The diagram shows workflow ending behaviors executing in reverse order (
2then1), butExecuteWorkflowPipelineAsyncexecutes behaviors in registration order each time it is invoked (andEndingis a separate invocation). Update the diagram to match the actual ordering to prevent incorrect assumptions about nesting/unwinding.
├─> WorkflowBehavior 2 (Ending)
└─> WorkflowBehavior 1 (Ending)
dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/README.md:68
- This section documents
ExecutorStage.PostExecution, but theExecutorStageenum currently only containsPreExecution(post-execution logic is done after awaitingcontinuation). The README should describe the API as implemented (or the code should add and populate aPostExecutionstage).
- `Stage` - Execution stage:
- `PreExecution` - Before the executor begins processing the message
- `PostExecution` - After the executor completes processing the message
dotnet/src/Microsoft.Agents.AI.Workflows/Behaviors/README.md:490
- The API reference lists
ExecutorStageasPreExecution, PostExecution, butExecutorStagecurrently only definesPreExecution. This should be corrected to avoid generating incorrect expectations for consumers.
- `ExecutorStage` - PreExecution, PostExecution
dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs:120
- This test is marked
asyncbut contains noawait, which commonly triggers CS1998 warnings and makes the test signature misleading. Consider making it synchronous (void) since all operations here are synchronous.
public async Task WorkflowBuilder_WithBehaviors_SupportsFluentAPIAsync()
dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Behaviors/WorkflowBehaviorOptionsTests.cs:142
- This test is marked
asyncbut contains noawait, which commonly triggers CS1998 warnings and makes the test signature misleading. Consider making it synchronous (void) since all operations here are synchronous.
public async Task WorkflowBuilder_WithoutBehaviors_HasNullPipelineAsync()
| │ ├─> ExecutorBehavior 1 (PreExecution) | ||
| │ ├─> ExecutorBehavior 2 (PreExecution) | ||
| │ ├─> Actual Handler Execution | ||
| │ ├─> ExecutorBehavior 2 (PostExecution) | ||
| │ └─> ExecutorBehavior 1 (PostExecution) |
| private async Task<AsyncRunHandle> 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) => default, | ||
| cancellationToken | ||
| ).ConfigureAwait(false); | ||
|
|
||
| return new AsyncRunHandle(this, this, mode); | ||
| } |
| 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) => default, | ||
| cancellationToken | ||
| ).ConfigureAwait(false); | ||
| } | ||
| } |
| } | ||
|
|
||
| [Fact] | ||
| public async Task WorkflowBuilder_WithBehaviors_ConfiguresBehaviorsAsync() |
| /// <summary> | ||
| /// Gets optional custom properties that can be used to pass additional context. | ||
| /// </summary> | ||
| public IReadOnlyDictionary<string, object>? Properties { get; init; } |
| /// <summary> | ||
| /// Gets optional custom properties that can be used to pass additional context. | ||
| /// </summary> | ||
| public IReadOnlyDictionary<string, object>? Properties { get; init; } |
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) <noreply@anthropic.com>
|
Thanks — worked through the Copilot review. All six distinct findings were valid; five are now fixed in 5b97da4, one I would like a maintainer steer on before changing. Fixed
Verification: builds clean across all TFMs (0 warnings, 0 errors); suite is 790/790 passing, up from 788 with the two new tests. I confirmed the disposal test is not vacuous by temporarily reverting the fix — it fails as expected, then passes once restored. Open question — deferred to a maintainer The remaining comment asks that This also still ties back to the open API questions in #3960 (naming |
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<T>/AddWorkflowBehavior<T> 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) <noreply@anthropic.com>
Summary
IWorkflowBehaviorandIExecutorBehaviorinterfaces for injecting custom logic before/after workflow start/end and executor step start/endBehaviorPipeline(internal) with zero-overhead fast path when no behaviors are registeredWorkflow,WorkflowBuilder,Executor, andInProcessRunnervia an opt-in fluent API (WithBehaviors)BehaviorExecutionExceptionfor wrapping behavior failures with stage/type contextTest plan
BehaviorExecutionExceptionconstructors and propertiesBehaviorPipelineexecution order and error handlingWorkflowBehaviorOptionsregistration (instance and factory)References
Closes #3960
🤖 Generated with Claude Code