Repository navigation
Description
Observed Behavior
When a successful AgentLoopMiddleware streaming iteration is followed by an exception or early close, the loop still invokes turn-scoped after_run providers (after_run_once_per_turn = True). The callback's SessionContext contains the final response from the previous iteration.
The streaming loop invokes the deferred providers unconditionally in its generator's finally block. The non-streaming path invokes them after try/finally, so a propagating exception skips the callback.
Source baseline:
Expected Behavior
Streaming and non-streaming loop runs should agree on whether a failed turn invokes deferred providers. Normal agent run finalization also skips after_run when an exception propagates.
The deferred callback should run after an intentional stop (should_continue, max_iterations, pending approval or handled MiddlewareTermination), and should be skipped when an iteration fails or the stream is abandoned.
Impact
Turn-scoped providers receive a stale response and can execute side effects after an incomplete streaming loop. With a configured after_strategy, CompactionProvider can run stored-history compaction through this unexpected callback. Its compaction strategy operates on session history rather than directly consuming the response.
The counting regressions establish the incorrect callback and streaming/non-streaming asymmetry. Provider-specific state consequences depend on the configured after_run behavior.
Steps to Reproduce
- Register a provider with
after_run_once_per_turn = Truethat countsafter_runcalls. - Run an agent with
AgentLoopMiddleware(always_continue, max_iterations=5). - Use a client that succeeds on iteration 1 and raises on iteration 2.
- Consume the streaming response and observe the expected
RuntimeError. - Observe one deferred callback, compared with zero for the equivalent non-streaming run.
For early close, continue pulling until the second client iteration has started, then close the stream. This ensures a previous completed response exists, without relying on the number of transport updates.
Proposed Fix
Gate deferred provider finalization on a flag set only after the streaming loop ends cleanly. Convert its three intentional-stop returns to breaks. Keep inner-stream closure and iteration-token cleanup unchanged.
Draft implementation and deterministic regressions are in #9818. On current main (03341b15), substituting the unfixed streaming method produces two expected regression failures (iteration exception and early close); the non-streaming control passes. With the fix, all three pass, and 1,201 related tests pass with the current middleware termination changes.
Metadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
CorgiBoyG commentedon Oct 29, 2026
I'd like to work on this. I have a minimal fix (gate the streaming loop's turn-scoped after-providers on a clean exit) plus a deterministic red-on-main / green-on-fix regression covering the streaming exception, streaming early-close, and the non-streaming control. I'll open a PR shortly.
github-actions commentedon Oct 29, 2026
🤖 Automated triage reproduction notes (agent-authored — trust but verify)
Agent analysis
The bug is in
python/packages/core/agent_framework/_harness/_loop.py::_process_streaming, where lines 723-725 invoke turn-scoped after-providers unconditionally during generator cleanup. It occurs when a completed iteration is followed by an exception or when the outer stream is closed after that iteration, leavingholder["final"]populated with the stale response. Minimal repro: configure anafter_run_once_per_turncounting provider, runAgentLoopMiddleware(always_continue)with a client that succeeds once then raises, consume the streaming response, and assert the provider was not called.python/packages/core/tests/core/test_harness_loop.py1.23.0CorgiBoyG commentedon Oct 29, 2026
Opened draft PR #9818 with the scoped fix and the red-on-main / green-on-fix regression. Draft pending maintainer confirmation of the direction.
Python: fire loop turn-scoped after_run only on clean streaming exit