Skip to content

Python: [Bug]: Functional workflow ResponseStream leaks ContextVar tokens across tasks when abandoned mid-iteration (break before exhaustion) #7787

Closed

Description

What happened?

FUNCTIONAL_WORKFLOWS' run(stream=True) returns a ResponseStream[WorkflowEvent, WorkflowRunResult] backed by an async generator (FunctionalWorkflowDefinition._run_stream_async-style generator in _functional.py) that holds two kinds of context open across a yield:

  • with create_workflow_span(...) as span: (OpenTelemetry) wraps the entire generator body.
  • Many individual with _framework_event_origin(): yield <event> pairs (_events.py, used from _functional.py and _executor.py) each set a ContextVar and yield while it's still set.

If a consumer stops iterating before the generator is exhausted — e.g. async for event in stream: break after seeing the first request_info event, a completely normal "peek then decide" pattern — the generator is only closed later, on GC. Python's async-generator finalization throws GeneratorExit into the suspended frame from whatever task/Context happens to be running garbage collection, which is not necessarily the task that was iterating the stream. Every ContextVar.reset(token) the generator was about to run (both _framework_event_origin's own and OpenTelemetry's use_span/start_as_current_span) then raises, because the token was created in a different Context.

This reproduces with no cross-task handoff at all — a single anyio.run(main) coroutine, one task, no ASGI, no asyncio.create_task. The trigger is only "stop iterating early", not "iterate on a different task", so I believe it's a distinct defect from #6866 / #7767 (both of which are about Agent.run(..., stream=True) + observability.py's inner_response_telemetry_captured_fields, triggered by a different consuming task fully exhausting the stream). This report is about the functional-workflows ResponseStream over WorkflowEvents (_functional.py / _events.py / _executor.py), triggered by early abandonment in the same task.

Expected behavior

Abandoning a ResponseStream before it's exhausted (break, a caller-side timeout, an exception in the consumer, etc.) should not raise anything, on any task. The most common ask-then-answer HITL pattern is exactly this shape:

async for event in stream:
    if event.type == "request_info":
        break
# ... decide what to answer, resume later with run(responses=...)

Steps to reproduce

  1. pip install agent-framework-core==1.14.0 anyio
  2. Run the script below.
  3. Observe Task exception was never retrieved / RuntimeError: async generator ignored GeneratorExit, plus (if opentelemetry-api is installed, which is a transitive dependency) Failed to detach context / ValueError: ... was created in a different Context from both OpenTelemetry's use_span and MAF's own _framework_event_origin (_events.py:55).

Code Sample

import gc
import anyio
from agent_framework import step, workflow


@step
async def increment(state: dict) -> dict:
    return {"n": state["n"] + 1}


@workflow(name="increment-pipeline")
async def pipeline(state: dict) -> dict:
    state = await increment(state)
    return await increment(state)


async def main() -> None:
    stream = pipeline.build().run(stream=True, message=({"n": 0},))

    async for _event in stream:
        break  # a normal "peek at the first event, decide" pattern

    del stream
    gc.collect()
    await anyio.sleep(0.1)
    print("done")


anyio.run(main)

Error Messages / Stack Traces

Task exception was never retrieved
future: <Task finished name='Task-2' coro=<<async_generator_athrow without __name__>()> exception=RuntimeError('async generator ignored GeneratorExit')>
RuntimeError: async generator ignored GeneratorExit

Failed to detach context
Traceback (most recent call last):
  File ".../opentelemetry/trace/__init__.py", line 608, in use_span
    yield span
  File ".../opentelemetry/trace/__init__.py", line 508, in start_as_current_span
    yield span
  File ".../opentelemetry/trace/__init__.py", line 443, in start_as_current_span
    yield span
GeneratorExit

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File ".../opentelemetry/context/__init__.py", line 135, in detach
    _RUNTIME_CONTEXT.detach(token)
  File ".../opentelemetry/context/contextvars_context.py", line 42, in detach
    self._current_context.reset(token)
ValueError: <Token var=<ContextVar name='current_context' default={} ...>> was created in a different Context

Exception ignored while closing generator <generator object _framework_event_origin at ...>:
Traceback (most recent call last):
  File ".../agent_framework/_workflows/_events.py", line 55, in _framework_event_origin
    _event_origin_context.reset(token)
ValueError: <Token var=<ContextVar name='workflow_event_origin' default=<WorkflowEventSource.EXECUTOR: 'EXECUTOR'> ...>> was created in a different Context

Package Versions

agent-framework-core: 1.14.0
anyio: 4.14.2
opentelemetry-api: 1.44.0 (transitive)

Python Version

Reproduces on both Python 3.12.3 and 3.14.3.

Additional Context

Likely root cause, for reference:

  • agent_framework/_workflows/_events.py:48-55 — _framework_event_origin() is a @contextmanager that does token = _event_origin_context.set(...); yield; finally: _event_origin_context.reset(token).
  • agent_framework/_workflows/_functional.py (around lines 1036-1123 in 1.14.0) — the workflow-run generator wraps its entire body in with create_workflow_span(...) as span: and additionally does with _framework_event_origin(): yield WorkflowEvent... at each of the started/status/output/failed event points. The same with _framework_event_origin(): yield ... pattern also appears in agent_framework/_workflows/_executor.py (around lines 303-314).
  • Any of these with ...: yield sites can be the one still open when the generator is abandoned; which one depends on how far the consumer got before stopping.

This looks related to #6866 (closed, likely-fixed) and #7767 (open) — same underlying class of bug (a ContextVar token reset assumes it's running in the Context that created it) — but a different subsystem (_functional.py/_events.py functional-workflow event streaming, not observability.py's agent-response telemetry) and a different trigger (early abandonment in the same task vs. full exhaustion in a different task). The fixes referenced there (moving ContextVar creation into the awaited coroutine) may not by themselves cover this path, since here the problem is a context manager left open across a yield inside a generator that can be GC'd from anywhere, not a token created before a task boundary.

Package Versions

agent-framework-core: 1.14.0

Python Version

3.12

Activity

  1. added
    pythonUsage: [Issues, PRs], Target: Python
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Aug 20, 2026
  2. added
    reproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow
    on Aug 20, 2026
  3. github-actions commented on Aug 20, 2026

    @github-actions
    Contributor

    🤖 Automated triage reproduction notes (agent-authored — trust but verify)

    Agent analysis

    The bug reproduces in python/packages/core/agent_framework/_workflows/_functional.py::FunctionalWorkflow._run_core, where the workflow span and _framework_event_origin() remain open across event yields. It triggers when a caller breaks from a stream=True iteration before exhaustion and the stream is later garbage-colected. Minimal repro: run a functional workflow as a stream, break after its first event, delete the stream, force GC, and allow async-generator finalization to run.

    • Failing test: python/packages/core/tests/workflow/test_functional_workflow.py::TestStreaming::test_abandoned_stream_finalizes_without_event_loop_error
    • Files examined: python/AGENTS.md, python/packages/core/AGENTS.md, python/.github/skills/python-testing/SKILL.md, python/packages/core/agent_framework/_workflows/_functional.py, python/packages/core/agent_framework/_workflows/_events.py, python/packages/core/agent_framework/_workflows/_executor.py, python/packages/core/tests/workflow/test_functional_workflow.py, python/packages/core/pyproject.toml
    • Tests run: TestStreaming::test_abandoned_stream_finalizes_without_event_loop_error
    • Reported version: 1.14.0
    • Current version: 1.14.0
  4. added
    observabilityUsage: [Issues, PRs], Target: observability related features
    workflowsUsage: [Issues, PRs], Target: Workflows
    and removed
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Aug 20, 2026
  5. Shivani767 commented on Aug 20, 2026

    @Shivani767
    Contributor

    I'll take this one.

    The functional-workflow streaming generator currently keeps both the OpenTelemetry run span (create_workflow_span / start_as_current_span) and _framework_event_origin() open across yields. Abandoning the ResponseStream then lets GC inject GeneratorExit from a different Context, so the ContextVar tokens fail to reset.

    Plan:

    • Create framework lifecycle events inside _framework_event_origin(), then yield them after the manager exits (matching graph Workflow).
    • Start the run span unattached and activate it only around non-yielding work (_execute).
    • Add a regression test for break-then-GC of a functional stream=True run.

    Happy to adjust if you'd rather see the same unattached-span treatment applied to graph Workflow._run_core in this PR as well.

  6. snovik75 commented on Aug 21, 2026

    @snovik75
    Author

    Thank you

  7. Shivani767 commented on Aug 23, 2026

    @Shivani767
    Contributor

    You're welcome, Sergej Novik (@snovik75).

    The fix is in #7798. It addresses the abandoned-stream ContextVar / OpenTelemetry leak for functional workflows, including:

    • Unattached run spans with scoped activation (so tokens are not held across yields)
    • Framework events built before yielding
    • Run-guard reset on generator finalization so a follow-up HITL run works after GC
    • Regression tests for loop exceptions, OTel detach errors, and nested-span parenting

    Waiting on maintainer CI approval / merge for that PR. Happy to follow up if anything else comes up on this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

observabilityUsage: [Issues, PRs], Target: observability related featurespythonUsage: [Issues, PRs], Target: PythonreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflowworkflowsUsage: [Issues, PRs], Target: Workflows

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions