Skip to content

fix(voice): wake StreamedAudioResult when the transcription producer is cancelled #4805

Description

@Hughhhhcoder

Describe the bug

VoicePipeline can leave StreamedAudioResult.stream() waiting forever when a streamed transcription session's producer exits with asyncio.CancelledError.

This is reachable through the public STTModel / StreamedTranscriptionSession interfaces. The issue is specifically about producer-side cancellation; consumer cancellation and ordinary Exception cleanup failures are separate paths already covered by earlier voice lifecycle changes.

Reproduction

Run from the repository root with the voice dependencies installed:

import asyncio

from agents.voice import (
    StreamedAudioInput,
    StreamedTranscriptionSession,
    VoicePipeline,
    VoicePipelineConfig,
)
from agents.voice.testing import ScriptedSTTModel, ScriptedTTSModel, ScriptedVoiceWorkflow


class CancellingSession(StreamedTranscriptionSession):
    async def transcribe_turns(self):
        raise asyncio.CancelledError("provider-cancelled")
        yield "unreachable"

    async def close(self):
        return None


class CancellingSTT(ScriptedSTTModel):
    def __init__(self):
        super().__init__(model_name="cancelling-stt")
        self.session = CancellingSession()

    async def create_session(
        self, input, settings, trace_include_sensitive_data, trace_include_sensitive_audio_data
    ):
        del input, settings, trace_include_sensitive_data, trace_include_sensitive_audio_data
        return self.session


async def main():
    pipeline = VoicePipeline(
        workflow=ScriptedVoiceWorkflow(),
        stt_model=CancellingSTT(),
        tts_model=ScriptedTTSModel(model_name="unused-tts"),
        config=VoicePipelineConfig(tracing_disabled=True),
    )
    result = await pipeline.run(StreamedAudioInput())
    producer = result.text_generation_task
    await asyncio.sleep(0.05)
    print("producer:", producer.done(), producer.cancelled())

    stream = result.stream()
    pending = asyncio.create_task(stream.__anext__())
    try:
        await asyncio.wait_for(asyncio.shield(pending), timeout=0.1)
    except asyncio.TimeoutError:
        print("stream: still waiting after 100ms")
    finally:
        pending.cancel()
        await asyncio.gather(pending, return_exceptions=True)
        await stream.aclose()


asyncio.run(main())

Observed on current main:

producer: True True
stream: still waiting after 100ms

The producer is already cancelled, but the public stream has no terminal event and remains blocked on its queue.

Expected behavior

A producer-side cancellation must not strand consumers indefinitely. stream() should promptly surface or otherwise terminate for the cancellation, while preserving the existing rule that cancellation initiated by the consumer propagates as asyncio.CancelledError. The exact public representation of producer cancellation can be chosen by maintainers; the important invariant is that the stream cannot wait forever after its producer has ended.

Actual behavior

In VoicePipeline._run_multi_turn, process_turns catches Exception, so asyncio.CancelledError bypasses the error-event path. The finally block closes the transcription session, then the producer task ends cancelled without calling _add_error() or _done(). StreamedAudioResult.stream() waits on _queue.get() and only checks producer exceptions after a terminal event; cancelled tasks are intentionally skipped by _check_errors(). No event is therefore available to release the consumer.

Related work / scope

This report is intentionally limited to that remaining producer-cancellation path and does not propose a public API change yet.

Verification

  • Repository: openai/openai-agents-python
  • Current branch: main
  • Command: PYTHONPATH=src .venv/bin/python <reproduction script>
  • Result: producer task is cancelled and stream().__anext__() remains pending after 100 ms.
  • No live API call or credentials are required.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions