Skip to content

.NET: feat: add pipeline behavior extension points for workflow and executor execution - #3982

Open
gijswalraven wants to merge 28 commits into
microsoft:mainfrom
gijswalraven:feat/issue_3960_Workflow_Pipeline_Behavior
Open

.NET: feat: add pipeline behavior extension points for workflow and executor execution#3982
gijswalraven wants to merge 28 commits into
microsoft:mainfrom
gijswalraven:feat/issue_3960_Workflow_Pipeline_Behavior

Conversation

@gijswalraven

Copy link
Copy Markdown

Summary

  • Introduces IWorkflowBehavior and IExecutorBehavior interfaces for injecting custom logic before/after workflow start/end and executor step start/end
  • Implements a chain-of-responsibility BehaviorPipeline (internal) with zero-overhead fast path when no behaviors are registered
  • Wires behaviors into Workflow, WorkflowBuilder, Executor, and InProcessRunner via an opt-in fluent API (WithBehaviors)
  • Adds BehaviorExecutionException for wrapping behavior failures with stage/type context

Test plan

  • Unit tests for BehaviorExecutionException constructors and properties
  • Unit tests for BehaviorPipeline execution order and error handling
  • Unit tests for WorkflowBehaviorOptions registration (instance and factory)
  • Integration tests for full pipeline: logging, validation, short-circuiting, and context enrichment scenarios
  • Build passes with 0 errors and 0 warnings

References

Closes #3960

🤖 Generated with Claude Code

@markwallace-microsoft markwallace-microsoft added documentation Usage: [Issues, PRs], Target: documentation in the code base and learn docs .NET Usage: [Issues, PRs], Target: .Net workflows Usage: [Issues, PRs], Target: Workflows labels Feb 17, 2026
@github-actions github-actions Bot changed the title feat: add pipeline behavior extension points for workflow and executor execution .NET: feat: add pipeline behavior extension points for workflow and executor execution Feb 17, 2026
@gijswalraven

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree company="delaware Consulting B.V."

@gijswalraven
gijswalraven force-pushed the feat/issue_3960_Workflow_Pipeline_Behavior branch from 8a61c4c to a9828d3 Compare March 19, 2026 21:21
@gijswalraven

Copy link
Copy Markdown
Author

@markwallace-microsoft Can we make any progress on this?

@gijswalraven
gijswalraven force-pushed the feat/issue_3960_Workflow_Pipeline_Behavior branch from a9828d3 to 86a73f1 Compare April 7, 2026 10:01
Gijs Walraven and others added 21 commits April 7, 2026 12:06
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>
Gijs Walraven and others added 3 commits April 7, 2026 12:07
…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>
@gijswalraven
gijswalraven force-pushed the feat/issue_3960_Workflow_Pipeline_Behavior branch from 86a73f1 to f9f4f2a Compare April 7, 2026 10:07
@gijswalraven

Copy link
Copy Markdown
Author

@markwallace-microsoft What is needed to get this feature moving? This branch has been open for a while.
The related issue #3960 is also still open, and has others requesting the same feature.

Gijs Walraven and others added 2 commits August 2, 2026 18:07
- 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>
Copilot AI review requested due to automatic review settings August 2, 2026 16:11
@gijswalraven

Copy link
Copy Markdown
Author

Branch refreshed against current main (was ~870 commits behind; merge was conflict-free).

Current state:

  • Builds cleanMicrosoft.Agents.AI.Workflows and its unit tests both compile with 0 warnings / 0 errors across all target frameworks (net472 / net10.0).
  • Tests green — full Microsoft.Agents.AI.Workflows.UnitTests suite: 788/788 passing.
  • No public API baseline files exist for this project, so nothing further is needed there.

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 main still has no equivalent extension point (WorkflowBuilder exposes only WithName / WithDescription / WithOutputFrom).

One note that may explain part of the stall: the .NET CI has never actually run on this PR — only add_label and license/cla have executed, so there has never been a green build for a reviewer to look at. dotnet-build-and-test.yml does trigger on pull_request against main, so this looks like the fork-PR workflow-approval gate.

@markwallace-microsoft happy to adjust naming (Behavior vs Middleware), scope, or the DI story per the open questions in #3960 — just need a steer on direction.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 BehaviorPipeline and wires it into WorkflowBuilder, InProcessRunner, and Executor with an opt-in WithBehaviors(...) API.
  • Adds BehaviorExecutionException plus 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 (2 then 1), but ExecuteWorkflowPipelineAsync executes behaviors in registration order each time it is invoked (and Ending is 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 the ExecutorStage enum currently only contains PreExecution (post-execution logic is done after awaiting continuation). The README should describe the API as implemented (or the code should add and populate a PostExecution stage).
- `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 ExecutorStage as PreExecution, PostExecution, but ExecutorStage currently only defines PreExecution. 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 async but contains no await, 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 async but contains no await, 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()

Comment on lines +34 to +38
│ ├─> ExecutorBehavior 1 (PreExecution)
│ ├─> ExecutorBehavior 2 (PreExecution)
│ ├─> Actual Handler Execution
│ ├─> ExecutorBehavior 2 (PostExecution)
│ └─> ExecutorBehavior 1 (PostExecution)
Comment on lines +174 to +193
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);
}
Comment on lines +199 to 219
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()
Comment on lines +77 to +80
/// <summary>
/// Gets optional custom properties that can be used to pass additional context.
/// </summary>
public IReadOnlyDictionary<string, object>? Properties { get; init; }
Comment on lines +90 to +93
/// <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>
@gijswalraven

Copy link
Copy Markdown
Author

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

  1. Ending-behavior exceptions broke teardown. This turned out to be worse than the review noted. In InProcessRunnerContext.EndRunAsync, the run-ending callback was awaited before the executor-disposal loop with no guard, so a throwing WorkflowStage.Ending behavior did not merely escape IAsyncDisposable.DisposeAsync() — it also skipped executor disposal and workflow ownership release entirely, leaking those for the remainder of the process. The failure is now captured and rethrown after teardown completes, so it is still surfaced to the caller but no longer costs resources.

  2. Properties was dead API. It was declared IReadOnlyDictionary<string, object>? and hard-coded to null at all three construction sites, so the cross-behavior enrichment described in the docs was impossible. It is now a framework-initialized mutable IDictionary<string, object>, with lifetime (per executor invocation; per workflow stage) and thread-safety documented on both context types. Good catch — worth noting the PR description claimed enrichment tests that did not exist; there is now one.

  3. README documented ExecutorStage.PostExecution, which 7e2d959 deliberately removed. Corrected in all four places.

  4. README's Ending diagram showed behaviors unwinding in reverse. Starting and Ending are two independent pipeline passes, both entered in registration order — only executor behaviors nest around the handler. Diagram and surrounding prose corrected.

  5. Three async tests with no await converted to synchronous, matching the existing sync tests in that file.

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 Starting behavior exceptions surface as WorkflowErrorEvent rather than propagating out of BeginStreamAsync, consistent with superstep errors. I have deliberately not changed this, because it is a semantics decision rather than a defect: executor-behavior failures already become WorkflowErrorEvent, so there is a real inconsistency argument, but silently converting a startup failure into an event is also arguably worse than failing loudly at the call site. Happy to implement either — which do you prefer?

This also still ties back to the open API questions in #3960 (naming Behavior vs Middleware, DI integration). A steer on those would unblock the rest.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Usage: [Issues, PRs], Target: documentation in the code base and learn docs .NET Usage: [Issues, PRs], Target: .Net workflows Usage: [Issues, PRs], Target: Workflows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET Workflows: Feature Request: Pipeline Behavior Extension Points for Cross-Cutting Concerns

3 participants