feat: add middleware system for wrapping agent stages - #2681
Conversation
Port of strands-agents/sdk-typescript#1068 into the mono-repo. Adds a middleware system that wraps agent stages (model calls, tool execution, agent streaming) using async generator handlers. New public API: agent.addMiddleware(stage, handler). Three built-in stages ship with the SDK: InvokeModelStage, ExecuteToolStage, and AgentStreamStage. Third parties can define custom stages via createStage without modifying SDK internals. Hooks fire unconditionally around the middleware chain, so observability stays intact even when middleware short-circuits. Co-authored-by: Jack Yuan <jackypc@amazon.com>
- Yield InterruptEvent for AgentStreamStage middleware interrupts so stream consumers see them (matching tool/hook interrupt behavior) - Apply interrupt responses from AfterInvocationEvent.resume args in _streamWithResumeLoop so resumed iterations see answered interrupts - Add explanatory comments for the eslint-disable/self pattern (arrow functions can't be generators) and the inline AgentResult construction (middleware is stateless during execution) - Extract createMiddlewareInterrupt helper to consolidate repeated interrupt closure pattern
Changes middleware context interrupt() to return MiddlewareInterruptResult<T> (an object with a `response` field) instead of T directly. This allows future additions (e.g., cached data, metadata) without a breaking change to callers. The tool/hook Interruptible interface is unchanged — only middleware contexts (ExecuteToolContext, AgentStreamContext) use the new MiddlewareInterruptible interface.
- Mark messages and toolSpecs as `readonly` arrays in InvokeModelContext to enforce immutability at the type level (middleware should spread, not mutate) - Add TSDoc to MiddlewareInterruptResult.response noting the caller assertion
…HandlerOf/MiddlewareNextOf, add cleanup return from addMiddleware - Prefix public types with Middleware to avoid generic naming collisions - addMiddleware returns () => void cleanup function (matches addHook API) - Add MiddlewareRegistry.remove() for handler removal by reference - Simplify eslint-disable comment to clarify the lexical `this` constraint
…ectly
Remove InvokeModelResult, ExecuteToolResult, and AgentStreamResult wrapper
interfaces. Middleware now returns StreamAggregatedResult, ToolResultBlock,
and AgentResult directly. Eliminates unnecessary { result } wrapping/unwrapping.
Each stage now exposes .Input, .Around, and .Output phase tokens with fixed execution order (Input → Output → Around) regardless of registration order. Input/Output handlers use simplified plain-async signatures; Around is the full async generator (same as bare stage, backwards compatible). Also restores result wrapper types (InvokeModelResult, ExecuteToolResult, AgentStreamResult) for future extensibility of middleware return values.
Moves the stream/event type parameter to the last position so that stages without streaming (potential future use) don't require an awkward middle generic. New order: input first, output second, stream last.
…oken - Reword phase ordering comments to distinguish compose layering from execution order (input → around → output) - Handle MiddlewareAroundPhase token in addMiddleware implementation so InvokeModelStage.Around works at runtime, not just via type overloads
- Convert if/if/fallthrough to exhaustive switch statement - Throw descriptive error for unrecognized phase values - Clarify compose vs execution order in phase comments
- Update LocalAgent.addMiddleware TSDoc to cover all three phases - Reword Output phase TSDoc to use execution-order language - Add test verifying Output phase cleanup function works
|
API Bar Raising: This PR introduces a new public primitive ( The PR description is excellent and provides all the information an API reviewer needs (use cases, examples, signatures, exports). Please ensure the PR has the One API design question worth discussing with the reviewer: the execution order semantics of phases. The current model is "Input → Around → Output" which is intuitive for the simple case, but the comment in the code ("Compose layering: input → output → around") reveals that Output handlers are layered outside Around handlers. This means a retry plugin on |
|
Assessment: Comment Well-designed middleware system with thoughtful API ergonomics and comprehensive test coverage. The PR description is exemplary for API documentation. Review Categories
The async generator approach for middleware is elegant and well-suited for streaming use cases. The phase sub-token design (Input/Around/Output) is a nice solution for the middleware ordering problem. |
Port of strands-agents/sdk-typescript#1068 into the mono-repo.
Summary
Adds a middleware system that wraps agent stages using async generator handlers. Middleware controls flow (retry, cache, transform, short-circuit).
Public API:
agent.addMiddleware(stage, handler)— returns a cleanup function.Three built-in stages:
InvokeModelStage,ExecuteToolStage,AgentStreamStage.Motivation
Hooks let you observe operations and set flags, but they don't let you wrap them. If you want to do something both before and after a model call (timing it, adding a span, catching errors), hooks force you to manage state across two separate callbacks. With middleware you can wrap the entire invocation keeping state within your callback:
Beyond the before/after pattern, middleware also makes several other use cases much more natural to express: caching, input transformation, short-circuiting, and error handling. All of these are awkward or impossible with hooks alone.
Public API Changes
Three stages ship with the SDK:
InvokeModelStageExecuteToolStageAgentStreamStageagent.stream()outputHandlers are async generators and simple pass-through is
return yield* next(context). Manual iteration ofnext()allows real-time event filtering or injection while not callingnextat all short-circuits the operation.Plugin Examples
Middleware Interrupts
Middleware contexts expose
interrupt()for human-in-the-loop gating:Returns
MiddlewareInterruptResult<T>(wrapper) — allows non-breaking additions (cached data, metadata) as the interrupt system evolves.Phase Sub-Stages (Input / Wrap / Output)
Each stage exposes three phases with a fixed execution order in order to solve the middleware ordering problem without explicit priority numbers:
Input: Modifies the input of the phase as a pure function - take in the original input, return the modified inputOutput: Modifies the output of the phase as a pure function - take in the original output, return the modified outputWrap: Wraps the entire invocation of the stage - this is more akin to traditional middleware from express or other frameworksExecution order is fixed: all Input → all Output → all Wrap → terminal. A retry plugin on
.Wrapretries subsequent Wrap handlers and the terminal — Input/Output transforms are not re-executed. A response logger on.Outputnever needs to coordinate registration order with a system prompt injector on.Input.Key Decisions
InvokeModelResult, etc.): allows future extension of middleware return values, including returning values "up the chain"readonlyarrays onInvokeModelContext: enforce immutable-context pattern at the type levelKnown Limitations
AgentStreamStagemiddleware can observe a tool-level interrupt result (stopReason: 'interrupt') but cannot re-enter the stream with interrupt responses. Interrupt resolution runs instream()'s outer loop, outside middleware. To programmatically resume interrupts within a single invocation, useAfterInvocationEvent.resumein a hook. A future enhancement may add a resume mechanism toAgentStreamResultorAgentStreamContext.Checklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.