Module scenario.voice.adapter

VoiceAgentAdapter — base class for voice-capable agents.

Extends AgentAdapter (text-based) with audio send/receive primitives and a capability matrix. Concrete subclasses live under scenario.voice.adapters (PipecatAgentAdapter, LiveKitAgentAdapter, etc.).

The scenario executor calls connect() automatically at scenario start and disconnect() at end — users do not manage lifecycle.

The default call() implementation records the audio it sends and receives into the executor's VoiceRecording so result.audio is populated without each adapter needing its own bookkeeping.

Expand source code
"""
VoiceAgentAdapter — base class for voice-capable agents.

Extends AgentAdapter (text-based) with audio send/receive primitives and a
capability matrix. Concrete subclasses live under
``scenario.voice.adapters`` (PipecatAgentAdapter, LiveKitAgentAdapter, etc.).

The scenario executor calls ``connect()`` automatically at scenario start and
``disconnect()`` at end — users do not manage lifecycle.

The default ``call()`` implementation records the audio it sends and receives
into the executor's ``VoiceRecording`` so ``result.audio`` is populated without
each adapter needing its own bookkeeping.
"""

from __future__ import annotations

import asyncio
import contextlib
import dataclasses
import logging
import time
from abc import abstractmethod
from typing import Any, Callable, ClassVar, Iterator, List, Optional

from opentelemetry import context as otel_context
from opentelemetry.context import Context

logger = logging.getLogger("scenario.voice")

from ..agent_adapter import AgentAdapter
from ..types import AgentInput, AgentReturnTypes, AgentRole
from .audio_chunk import AudioChunk
from .capabilities import AdapterCapabilities
from .messages import create_audio_message, extract_audio
from .stt import transcribe
from .recording import AudioSegment, VoiceEvent
from ._telemetry import voice_span


_FIRST_CHUNK_PHASE = "first-chunk"
"""Phase marker for the first-chunk recv timeout (used in FirstChunkTimeoutError)."""


class AgentStreamEndedError(Exception):
    """An adapter's recv_audio raises this when the agent's audio transport has
    TERMINATED — a background read-loop crash or a clean close by the peer — so
    no further audio can arrive on this connection.

    WHY distinct from asyncio.TimeoutError: a timeout is TRANSIENT (the agent may
    still be mid-think; audio could still arrive), a stream-ended is TERMINAL (the
    connection is done). _drain_agent_response treats them differently: on the
    FIRST chunk it propagates this unchanged (it already names the real cause —
    this is the #498 diagnostic fix), on TAIL chunks it ends the turn normally
    (the peer closed after the agent finished speaking). Subclasses (e.g.
    PipecatRecvError) carry a transport-specific message and chain the underlying
    cause via __cause__.
    """


class FirstChunkTimeoutError(asyncio.TimeoutError):
    """Raised when the agent fails to send its first audio chunk within ``response_timeout``.

    WHY this subclass exists: operators could not distinguish a first-chunk hang
    (agent never spoke — wrong endpoint, VAD never fired, response_timeout too
    short) from a tail-silence cutoff (agent finished speaking normally).  The
    bare ``asyncio.TimeoutError`` that escaped previously had an empty ``str()``
    and no structured attributes, so log aggregators and re-raise chains had no
    signal.  This class embeds the phase marker (``_FIRST_CHUNK_PHASE``) in its
    message, a machine-readable ``.timeout`` attribute, and chains the original
    transport error via ``__cause__``.
    """

    def __init__(self, *, timeout: float) -> None:
        self.timeout = timeout
        self.phase = _FIRST_CHUNK_PHASE
        super().__init__(
            f"agent did not send its first audio chunk within {timeout}s "
            f"(phase={_FIRST_CHUNK_PHASE})"
        )


class VoiceAgentAdapter(AgentAdapter):
    """
    Abstract base for voice agents that exchange audio with the agent under test.

    Subclasses implement ``connect``, ``disconnect``, ``send_audio``, and
    ``recv_audio``. The default ``call`` implementation threads audio extracted
    from the last incoming message through the transport and wraps the response
    back into an assistant message.

    Attributes:
        capabilities: Declaration of what the adapter can and cannot do. Each
            concrete subclass must set this as a class attribute.
        response_timeout: Seconds to wait for agent audio after sending user
            audio. Defaults to 60 seconds.

            60 seconds covers a typical real-world STT → LLM → TTS round-trip
            including backoff/retry inside each provider, tool calls, and RAG
            lookups. If you see TimeoutError flakes against a fast LLM-only
            chain, you can lower this; if your agent does heavy processing
            (MCP roundtrips, multi-step tool chains), consider raising it.

            Override per-adapter at construction time::

                adapter = MyVoiceAdapter()
                adapter.response_timeout = 90.0  # slow tool-call chain
    """

    role: ClassVar[AgentRole] = AgentRole.AGENT
    capabilities: ClassVar[AdapterCapabilities] = AdapterCapabilities()
    response_timeout: float = 60.0  # 60s: STT + LLM + TTS budget (see docstring)
    # Tail silence: once the first agent chunk arrives, keep draining recv_audio
    # until no chunk shows up within this many seconds — that's how we detect the
    # agent finished talking. Without this, demos record only the first ~100ms.
    response_tail_silence: float = 0.6
    # Hard cap on a single agent turn's audio. Prevents runaway loops if a
    # transport never signals end-of-stream. 30s = a long sentence.
    response_max_duration: float = 30.0

    # Live OTel context of the CURRENT ``voice.turn``, published by ``call()``
    # for background-receive-loop adapters (Pipecat/Twilio) to parent their
    # detached-task recv spans under the turn (#774). ``None`` between turns.
    # Class-level default so it exists even for a subclass that skips
    # ``super().__init__()`` (mirrors the ``_agent_speaking`` safety-net).
    _voice_turn_context: Optional[Context] = None

    #: SET when the AGENT deliberately ended the call (e.g. an ElevenLabs hosted
    #: agent invoking the ``end_call`` system tool), as opposed to the transport
    #: dropping. A scripted turn that arrives after this concludes the
    #: conversation instead of failing the run — the agent behaved as designed.
    #: Assertions and judges can read it to reason about WHO ended the call.
    #:
    #: Class-level default for the same reason as ``_voice_turn_context``: a
    #: subclass that skips ``super().__init__()`` would otherwise raise
    #: AttributeError from the ``call()`` gate instead of the intended
    #: TransportNotConnectedError. Matches the TS field default (``agentHungUp``).
    agent_hung_up: bool = False

    def __init__(self) -> None:
        # Per-instance event used by the interruption path to wait until
        # the agent is actually speaking before firing an interrupt — so
        # we don't fire ``clear`` at a silent SUT. Subclasses that
        # override ``__init__`` must call ``super().__init__()``.
        self._agent_speaking = asyncio.Event()

    @property
    def _agent_speaking_event(self) -> asyncio.Event:
        """Event set when the agent emits its first chunk of the current turn."""
        # Safety net for subclasses that pre-date this base ``__init__``
        # contract and didn't call ``super().__init__()``. They get a
        # one-shot lazy event so the interruption path doesn't crash.
        # We emit a single warning per subclass — silent fallback masks
        # bugs, but a warning per call would spam the timing-critical
        # interruption path. New adapters must call super().__init__().
        ev = getattr(self, "_agent_speaking", None)
        if ev is None:
            cls = type(self)
            if not getattr(cls, "_agent_speaking_lazy_warned", False):
                logger.warning(
                    "%s.__init__() did not call super().__init__(); "
                    "lazily initialising _agent_speaking event. "
                    "Add super().__init__() to silence this warning.",
                    cls.__name__,
                )
                # setattr() form: pyright won't infer this dynamic class attr
                # otherwise (reportAttributeAccessIssue). Functionally identical
                # to cls._agent_speaking_lazy_warned = True.
                setattr(cls, "_agent_speaking_lazy_warned", True)
            ev = asyncio.Event()
            self._agent_speaking = ev
        return ev

    def is_connected(self) -> bool:
        """Whether the transport is open and ready to exchange audio.

        Base default is ``True``: adapters without a persistent socket (or
        that manage liveness elsewhere) are always considered ready, so the
        pre-turn guard in :meth:`call` never blocks them. Transports with a
        real socket override this — e.g. :class:`ElevenLabsAgentAdapter`
        returns ``self._ws is not None and not self._ws.closed`` (parity with
        the TS ``isConnected()`` override, ``adapters/elevenlabs.ts:531-534``).
        """
        return True

    @abstractmethod
    async def connect(self) -> None:
        """Open the transport and prepare to exchange audio."""

    @abstractmethod
    async def disconnect(self) -> None:
        """Close the transport and release resources."""

    @abstractmethod
    async def send_audio(self, chunk: AudioChunk) -> None:
        """Transmit an AudioChunk to the agent under test."""

    @abstractmethod
    async def recv_audio(self, timeout: float) -> AudioChunk:
        """Receive the next AudioChunk from the agent."""

    async def __aenter__(self):
        # Default async context manager: subclasses don't need to
        # reimplement this — they get connect/disconnect sandwiching
        # for free. Override only if a transport needs extra setup
        # ordering around connect.
        await self.connect()
        return self

    async def __aexit__(self, *exc_info: Any) -> None:
        await self.disconnect()

    async def interrupt(self) -> None:
        """Send a first-class interrupt signal to the agent under test.

        Adapters that advertise ``capabilities.interruption=True`` override
        this to send the transport-native interrupt (e.g., Twilio ``clear``,
        OpenAI Realtime ``response.cancel``). The agent stops generating
        audio immediately — much more deterministic than racing VAD against
        a wall-clock sleep.

        The default raises ``UnsupportedCapabilityError``. Callers
        (``scenario.interrupt()``) check ``capabilities.interruption`` and
        fall back to timing-based barge-in (sending audio while the agent
        is speaking) when this returns False.
        """
        from .capabilities import UnsupportedCapabilityError

        raise UnsupportedCapabilityError(
            type(self).__name__,
            "interruption",
            hint=(
                "This adapter has no native interrupt signal. Use the "
                "timing-based barge-in pattern instead: "
                "agent(wait=False) + sleep(N) + user(content), where the "
                "user audio overlaps with the agent's TTS and the SUT's "
                "VAD detects it."
            ),
        )

    @contextlib.contextmanager
    def _voice_turn_context_scope(self) -> Iterator[None]:
        """Publish the live ``voice.turn`` OTel context for background-loop adapters.

        Pipecat/Twilio run their real receive in a background task/callback whose
        OTel context was frozen at ``connect()`` (a now-closed span). They read
        :attr:`_voice_turn_context` to parent those detached recv spans under the
        CURRENT ``voice.turn`` (#774 — the reusable pattern Twilio PR5 inherits).
        Entered INSIDE the ``voice.turn`` span so ``get_current()`` captures it;
        cleared on exit so a late background frame BETWEEN turns finds ``None`` and
        skips its span rather than parenting under a closed turn.
        """
        self._voice_turn_context = otel_context.get_current()
        try:
            yield
        finally:
            self._voice_turn_context = None

    async def call(self, input: AgentInput) -> AgentReturnTypes:
        """
        Default implementation: extract audio from the latest user message,
        send it, drain the agent's full response (multiple recv_audio chunks
        until tail silence), record once, return as one assistant audio message.

        Why drain instead of taking one chunk: TTS and realtime APIs stream
        their response in many small chunks. A single recv_audio() returns the
        first one only — the recorder would log ~100ms of agent audio per turn
        and the judge would receive a truncated response. Draining until
        tail-silence (no new chunk for ``response_tail_silence`` seconds) gives
        the natural "agent finished talking" signal that works across
        adapters without each one needing to know its transport's done event.

        Subclasses may override this for specialised flows but will usually
        inherit it.
        """
        # Uniform pre-turn connected-state gate (mirror TS
        # ``adapter.runtime.ts:249-254``): a call() issued before the
        # executor's connect() — or after a dropped transport — fails once with
        # a clear error naming the adapter, rather than a transport-specific
        # null-deref or a silent hang. Checked ONCE, BEFORE send_audio/
        # recv_audio. It does NOT suppress ``FirstChunkTimeoutError``: a
        # connected adapter whose first chunk never arrives still surfaces that
        # timeout from the drain below.
        #
        # We raise ``TransportNotConnectedError`` (a subclass of
        # ``PendingTransportError``, so the TS-parity ``except
        # PendingTransportError`` gate still catches it) whose message is
        # actionable for a real, implemented adapter — "call connect()/reconnect"
        # — rather than the base "implement your transport" guidance meant for
        # unshipped stubs.
        if not self.is_connected():
            if self.agent_hung_up:
                # The AGENT ended the call on purpose (issue #839) — hosted
                # agents routinely invoke a hangup tool right after their
                # farewell, which closes the transport. Any scripted turn left
                # in the script has nobody to talk to, but the agent did
                # exactly what it was designed to do, so concluding here and
                # letting the script fall through to the judge is the correct
                # outcome. Failing the run would punish correct behaviour.
                # Returning no messages leaves the transcript ending on the
                # agent's farewell, which is what the judge should assess.
                logger.info(
                    "%s: agent ended the call; concluding the conversation "
                    "instead of failing the remaining scripted turn(s)",
                    type(self).__name__,
                )
                return []
            from .adapters._stub import TransportNotConnectedError

            raise TransportNotConnectedError(type(self).__name__)
        # One ``voice.turn`` span per call(), nesting under the executor's
        # existing ``{cls}.call`` agent span (ambient OTel context — no parent
        # passed). The transport spans below (send/receive) nest under it.
        turn_index = getattr(
            getattr(input, "scenario_state", None), "current_turn", None
        )
        with voice_span(
            "voice.turn",
            {
                "voice.adapter.class": type(self).__name__,
                "voice.turn.index": turn_index,
            },
        ) as _turn_span, self._voice_turn_context_scope():
            _turn_started = time.monotonic()
            # Clear the speaking-event for this turn — set in _drain on first chunk.
            self._agent_speaking_event.clear()
            recorder = _AdapterRecorder(input)
            incoming = (
                extract_audio(input.new_messages[-1]) if input.new_messages else None
            )
            if incoming is not None:
                # BEFORE the user's audio goes out (and before the user segment
                # is written, so an agent segment is still last on the cursor):
                # sweep up any agent audio an early turn close stranded in
                # flight, so it lands on the turn that produced it instead of
                # bleeding out as the next turn's opening audio (#749).
                await reconcile_prior_agent_audio(
                    self, recorder._executor, recorder._offset()
                )
                # The sweep is cleanup of the PREVIOUS turn, so re-stamp the
                # start: leaving it would bill this turn for time spent draining
                # the last one and inflate the reported turn latency.
                _turn_started = time.monotonic()
                # Wrap send_audio so user.start = "we began transmitting" and
                # user.end = "we finished transmitting" — both real flow points.
                recorder.mark_user_start()
                with voice_span(
                    "voice.audio.send", {"voice.audio.bytes": len(incoming.data)}
                ):
                    await self.send_audio(incoming)
                recorder.record_user(incoming)
            # Drain. Recorder grabs agent.start at first chunk via
            # mark_agent_start, so agent.start is "first chunk on the wire,"
            # not "now minus merged.duration."
            merged = await self._drain_agent_response(
                on_first_chunk=recorder.mark_agent_start
            )
            # Mark agent.end BEFORE the STT round-trip below — the agent stopped
            # speaking when drain settled, not after transcription returned.
            recorder.mark_agent_end()
            _turn_span.set_attribute(
                "voice.turn.latency_ms",
                round((time.monotonic() - _turn_started) * 1000),
            )
            if incoming is not None and incoming.data:
                _turn_span.set_attribute(
                    "voice.turn.user_audio_bytes", len(incoming.data)
                )
            if merged.data:
                _turn_span.set_attribute(
                    "voice.turn.agent_audio_bytes", len(merged.data)
                )
            merged = await self._ensure_transcript(merged)
            recorder.record_agent(merged)
            return create_audio_message(merged, role="assistant")

    async def _ensure_transcript(self, merged: AudioChunk) -> AudioChunk:
        """Best-effort runtime STT for adapters whose transport carries no text.

        The assistant message built from this chunk feeds the conversation
        history that the user simulator (a text-only LLM) reads. Without a
        text part the simulator sees the ``[audio message]`` placeholder for
        every agent turn and replies blind — both sides talk past each other
        while the judge, which transcribes the recording post-hoc, still
        renders a perfectly readable transcript. Transcribing here closes
        that gap; it also fills the recording segment's transcript, so the
        judge fallback (``transcribe_segments(only_missing=True)``) skips
        these turns.

        Adapters that already ship transcripts on their chunks (realtime
        APIs) return them merged — ``merged.transcript`` is set and this is
        a no-op. STT failures are logged and the audio-only chunk is
        returned unchanged, same contract as ``transcribe_segments``.
        """
        if not merged.data or merged.transcript:
            return merged
        try:
            # ``voice.stt.transcribe`` — one span per per-turn STT provider call,
            # nesting under this turn's ``voice.turn`` span (#776). Python runs
            # STT per-turn (here); the TS mirror is a per-RUN back-fill batch
            # (``voice.stt.backfill`` → ``voice.stt.transcribe``). Shared
            # attributes across BOTH languages: ``voice.stt.scope`` /
            # ``.speaker`` / ``.audio_bytes`` / ``.transcript_chars``.
            # ``voice.adapter.class`` is Python-ONLY here: the per-turn STT always
            # transcribes THIS adapter's own agent output, whereas the TS per-run
            # back-fill is adapter-agnostic over recorded segments. Disambiguate
            # cross-language by ``voice.stt.scope`` (turn vs run).
            with voice_span(
                "voice.stt.transcribe",
                {
                    "voice.adapter.class": type(self).__name__,
                    "voice.stt.scope": "turn",
                    "voice.stt.speaker": "agent",
                    "voice.stt.audio_bytes": len(merged.data),
                },
            ) as stt_span:
                try:
                    text = await transcribe(merged)
                except Exception as exc:
                    # Sanitize BEFORE the span records it: provider SDK errors
                    # (OpenAI/ElevenLabs) can embed the raw response body — and a
                    # key fragment on a 401 — in the message, which
                    # ``voice_span``'s ``record_exception`` would EXPORT to
                    # telemetry. Log the detail locally at DEBUG (non-exported);
                    # raise a minimal, provider-agnostic error (``from None`` so
                    # the chained original is not formatted into the recorded
                    # traceback) so the span still marks ERROR without leaking.
                    # Mirrors ``ElevenLabsSTTProvider`` in ``voice/stt.py``.
                    logger.debug(
                        "voice: STT provider error detail", exc_info=True
                    )
                    raise RuntimeError(
                        f"STT provider failed: {type(exc).__name__}"
                    ) from None
                if text:
                    stt_span.set_attribute("voice.stt.transcript_chars", len(text))
        except Exception:
            logger.warning(
                "voice: agent-turn STT failed; the user simulator will see "
                "'[audio message]' instead of this turn's words",
            )
            logger.debug("voice: agent-turn STT failure detail", exc_info=True)
            return merged
        if not text:
            return merged
        return dataclasses.replace(merged, transcript=text)

    async def _drain_agent_response(
        self, on_first_chunk: Optional[Callable[[], None]] = None
    ) -> AudioChunk:
        """Loop ``recv_audio`` until tail silence or max duration; merge result.

        ``on_first_chunk`` is invoked synchronously the moment the first
        non-empty audio chunk arrives — used by the recorder to capture
        agent.start at a real flow point rather than back-computing from
        the merged-chunk duration.
        """
        # ``voice.audio.receive`` — THE user-story span: a receiveAudio timeout
        # surfaces as ERROR here, so the trace shows WHY a run failed.
        with voice_span("voice.audio.receive") as _recv_span:
            _recv_started = time.monotonic()
            try:
                first = await self.recv_audio(timeout=self.response_timeout)
            except asyncio.TimeoutError as err:
                _recv_span.set_attribute(
                    "voice.audio.terminated_reason", "first_chunk_timeout"
                )
                raise FirstChunkTimeoutError(timeout=self.response_timeout) from err
            # An AgentStreamEndedError is intentionally NOT caught here: it is not a
            # TimeoutError, so it propagates past this handler unchanged. That
            # preserves the real terminal cause (recv-loop crash or clean peer close)
            # for the #498 diagnostic fix instead of masking it as a first-chunk
            # timeout. Do NOT add a bare ``except Exception`` here. It exits the
            # span as ERROR, which is exactly what the trace should show.
            _recv_span.set_attribute(
                "voice.audio.first_chunk_latency_ms",
                round((time.monotonic() - _recv_started) * 1000),
            )
            # First chunk arrived → agent is now speaking. Wakes anyone awaiting
            # _agent_speaking_event (the interruption path).
            if first.data and on_first_chunk is not None:
                on_first_chunk()
            self._agent_speaking_event.set()
            chunks: List[AudioChunk] = [first]
            accumulated = first.duration_seconds
            # Default reason: the loop condition fell through, i.e. the runaway
            # backstop (Python-only — TS terminates on a hard ceiling instead).
            terminated_reason = "max_duration"
            while accumulated < self.response_max_duration:
                try:
                    nxt = await self.recv_audio(timeout=self.response_tail_silence)
                except asyncio.TimeoutError:
                    terminated_reason = "tail_silence"
                    break
                except AgentStreamEndedError:
                    # The stream ended after the agent already spoke — a normal
                    # end-of-turn (the peer closed once it finished). Return the
                    # audio collected so far instead of surfacing the terminal cause.
                    terminated_reason = "stream_ended"
                    break
                if not nxt.data:
                    terminated_reason = "terminal_chunk"
                    break
                chunks.append(nxt)
                accumulated += nxt.duration_seconds
            merged = _merge_chunks(chunks)
            _recv_span.set_attribute("voice.audio.terminated_reason", terminated_reason)
            _recv_span.set_attribute("voice.audio.chunk_count", len(chunks))
            _recv_span.set_attribute(
                "voice.audio.bytes", len(merged.data) if merged.data else 0
            )
            return merged


class _AdapterRecorder:
    """Bridges a single call() turn's audio and timing into the executor state.

    Kept as a private helper so the default ``VoiceAgentAdapter.call`` stays
    short and each subclass can opt-out by overriding ``call()``.

    Timing model: every segment's start/end is captured at a real audio
    flow point — when transmission begins, when it ends, when the first
    chunk arrives. Nothing is back-computed from chunk byte length, so
    user and agent segments share a single timeline and do not overlap.
    """

    def __init__(self, input: AgentInput) -> None:
        # ``scenario_state`` is declared on AgentInput, but tests use lightweight
        # _FakeInput stubs that don't carry it. Guard so the recorder
        # degrades to a no-op (segments unwritten) instead of crashing the
        # call(), matching the established test-double seam.
        state = getattr(input, "scenario_state", None)
        executor = getattr(state, "_executor", None) if state is not None else None
        self._executor = executor
        self._user_start: Optional[float] = None
        self._user_end: Optional[float] = None
        self._agent_start: Optional[float] = None
        self._agent_end: Optional[float] = None

    def _offset(self) -> float:
        anchor = getattr(self._executor, "_voice_recording_started_at", None)
        if anchor is None:
            return 0.0
        return time.monotonic() - anchor

    def mark_user_start(self) -> None:
        """Capture the moment send_audio begins transmitting user audio."""
        self._user_start = self._offset()

    def record_user(self, chunk: AudioChunk) -> None:
        """Finalise the user segment after send_audio returns.

        Uses real flow timestamps: start = when transmission began,
        end = now (transmission complete). The chunk's intrinsic
        duration is metadata only, not used to compute timestamps.
        """
        if self._executor is None or not chunk.data:
            return
        end = self._offset()
        start = self._user_start if self._user_start is not None else end
        self._user_end = end
        write_user_segment(self._executor, chunk, start, end)

    def mark_agent_start(self) -> None:
        """Capture the moment the first agent chunk arrives.

        Called by ``_drain_agent_response`` synchronously when its first
        non-empty chunk lands, so the agent segment's start reflects when
        audio actually started flowing back from the AUT — not when drain
        eventually returns.
        """
        self._agent_start = self._offset()

    def mark_agent_end(self) -> None:
        """Capture the moment drain settles — the agent has stopped speaking.

        Called before any post-drain processing (runtime STT) so the agent
        segment's end and the agent_stop_speaking event reflect when audio
        stopped flowing, not when transcription returned.
        """
        self._agent_end = self._offset()

    def record_agent(self, chunk: AudioChunk) -> None:
        """Finalise the agent segment after drain completes.

        start = when first chunk arrived (captured by mark_agent_start).
        end = when drain settled (captured by mark_agent_end).
        latency = agent.start - user.end. Real measurement; no clamp.
        """
        if self._executor is None or not chunk.data:
            return
        _fire_audio_chunk(self._executor, chunk)
        end = self._agent_end if self._agent_end is not None else self._offset()
        start = self._agent_start if self._agent_start is not None else end
        _append_segment(self._executor, "agent", start, end, chunk)
        latency = None
        if self._user_end is not None:
            latency = start - self._user_end
            # Negative latency means the agent began emitting audio before
            # the user audio finished transmitting — which the wire model
            # forbids on serial adapters. Treat as a measurement artefact
            # and skip the record so p50/p95 aren't poisoned.
            if latency >= 0:
                _record_latency(self._executor, latency)
            else:
                latency = None
        _append_event(
            self._executor,
            VoiceEvent(time=start, type="agent_start_speaking", latency=latency),
        )
        _append_event(self._executor, VoiceEvent(time=end, type="agent_stop_speaking"))


def _merge_chunks(chunks: List[AudioChunk]) -> AudioChunk:
    """Concatenate PCM bytes from drained agent chunks into one AudioChunk.

    Transcripts: each adapter populates ``chunk.transcript`` differently —
    some on the last chunk (after STT settles), some incrementally. Joining
    non-empty transcripts with a space preserves whatever the adapter shipped
    without forcing adapters to coordinate.
    """
    if len(chunks) == 1:
        return chunks[0]
    data = b"".join(c.data for c in chunks)
    parts = [c.transcript for c in chunks if c.transcript]
    transcript = " ".join(parts) if parts else None
    return AudioChunk(data=data, transcript=transcript)


def write_user_segment(executor, chunk: AudioChunk, start: float, end: float) -> None:
    """Append a finalised user segment + start/stop timeline events.

    Single path that both ``_AdapterRecorder.record_user`` (the default
    ``call()`` flow) and ``ScenarioExecutor._record_interrupt_user_segment``
    (the barge-in flow that bypasses the recorder) call into. Previously
    those two paths each open-coded the same four-step sequence
    (``_fire_audio_chunk`` + ``_append_segment`` + two ``_append_event``s),
    drifting apart as the timing model evolved.
    """
    if executor is None or not chunk.data:
        return
    _fire_audio_chunk(executor, chunk)
    _append_segment(executor, "user", start, end, chunk)
    _append_event(executor, VoiceEvent(time=start, type="user_start_speaking"))
    _append_event(executor, VoiceEvent(time=end, type="user_stop_speaking"))


async def reconcile_prior_agent_audio(
    adapter: Any, executor: Any, now: float
) -> None:
    """Sweep up agent audio stranded by an early turn close and give it back to
    the utterance that produced it (issue #749; TypeScript parity with #748).

    The drain ends a turn on ``response_tail_silence``. A delivery gap longer
    than that leaves the rest of the agent's utterance in flight, and the next
    drain shifts it out as the opening audio of the NEXT agent turn — so turn
    N+1 appears to answer question N. Run at the pre-user-``send_audio``
    boundary, where the agent cannot yet have begun its next reply, so anything
    still arriving is unambiguously the prior turn's tail.

    Attribution is position-gated, mirroring the TypeScript reconcile:

    - Cursor-safe (an AGENT segment is still last — the user segment for this
      turn has not been written yet): grow it. Extending its ``end_time`` and
      audio cannot overlap a later segment. The transcript is left ALONE: the
      turn was cut short in AUDIO only — the provider's own ``agent_response``
      text already covers the whole utterance — so appending the tail makes the
      two consistent. Clearing it would discard a correct transcript and, for an
      audio-capable judge (which never runs the STT back-fill), leave the
      segment with none at all.
    - Cursor-unsafe (no recording, the opening greeting with no prior agent
      segment, or a barge-in where a user segment is last): the audio is already
      off the wire, so the bleed is prevented either way; it is dropped with a
      warning rather than corrupting the append-only cursor.

    Adapters that expose no ``reconcile_pending_audio`` are untouched.

    ``adapter`` and ``executor`` are deliberately ``Any``: this helper is
    duck-typed on both sides — it feature-detects ``reconcile_pending_audio``
    rather than requiring :class:`VoiceAgentAdapter`, and reads the recording off
    the executor with ``getattr`` so the lightweight test doubles the recorder
    already tolerates work here too. Narrowing either to a concrete class would
    describe a contract this function does not actually enforce.
    """
    reconcile = getattr(adapter, "reconcile_pending_audio", None)
    if reconcile is None:
        return
    try:
        leftover = await reconcile()
    except Exception:  # noqa: BLE001 — opportunistic cleanup must never fail a turn
        logger.warning(
            "%s: turn-boundary reconcile raised; continuing.",
            type(adapter).__name__,
            exc_info=True,
        )
        return
    if leftover is None or not leftover.data:
        return

    recording = getattr(executor, "_voice_recording", None) if executor else None
    segments = getattr(recording, "segments", None) if recording is not None else None
    if segments and segments[-1].speaker == "agent":
        prior = segments[-1]
        prior.audio += leftover.data
        prior.end_time = max(prior.end_time, now)
        _fire_audio_chunk(executor, leftover)
        logger.warning(
            "%s: recovered %d bytes of agent audio stranded by an early turn "
            "close and attributed them to the preceding agent turn. Raise "
            "response_tail_silence if this recurs.",
            type(adapter).__name__,
            len(leftover.data),
        )
    else:
        logger.warning(
            "%s: discarded %d bytes of agent audio at a user-turn boundary — "
            "there is no preceding agent segment to attribute them to, and "
            "growing an out-of-order segment would corrupt the recording. If "
            "your script does not lead with agent(), this is the on-connect "
            "greeting arriving after the first user turn was queued; lead with "
            "agent() so the greeting is drained as its own turn.",
            type(adapter).__name__,
            len(leftover.data),
        )


def _append_segment(executor, speaker: str, start: float, end: float, chunk: AudioChunk) -> None:
    recording = getattr(executor, "_voice_recording", None)
    if recording is None:
        return
    recording.segments.append(
        AudioSegment(
            speaker=speaker,  # type: ignore[arg-type]
            start_time=start,
            end_time=end,
            audio=chunk.data,
            transcript=chunk.transcript,
        )
    )


def _append_event(executor, event: VoiceEvent) -> None:
    timeline = getattr(executor, "_voice_timeline", None)
    if timeline is None:
        return
    timeline.append(event)
    hook = getattr(executor, "_on_voice_event", None)
    if hook is not None:
        try:
            hook(event)
        except Exception:
            logger.warning(
                "on_voice_event callback raised; continuing scenario.",
                exc_info=True,
            )


def _fire_audio_chunk(executor, chunk: AudioChunk) -> None:
    hook = getattr(executor, "_on_audio_chunk", None)
    if hook is None:
        return
    try:
        hook(chunk)
    except Exception:
        logger.warning(
            "on_audio_chunk callback raised; continuing scenario.",
            exc_info=True,
        )


def _record_latency(executor, latency: float) -> None:
    metrics = getattr(executor, "_voice_latency", None)
    if metrics is None:
        return
    metrics.measurements.append(latency)
    if metrics.time_to_first_byte is None:
        metrics.time_to_first_byte = latency

Functions

async def reconcile_prior_agent_audio(adapter: Any, executor: Any, now: float) ‑> None

Sweep up agent audio stranded by an early turn close and give it back to the utterance that produced it (issue #749; TypeScript parity with #748).

The drain ends a turn on response_tail_silence. A delivery gap longer than that leaves the rest of the agent's utterance in flight, and the next drain shifts it out as the opening audio of the NEXT agent turn — so turn N+1 appears to answer question N. Run at the pre-user-send_audio boundary, where the agent cannot yet have begun its next reply, so anything still arriving is unambiguously the prior turn's tail.

Attribution is position-gated, mirroring the TypeScript reconcile:

  • Cursor-safe (an AGENT segment is still last — the user segment for this turn has not been written yet): grow it. Extending its end_time and audio cannot overlap a later segment. The transcript is left ALONE: the turn was cut short in AUDIO only — the provider's own agent_response text already covers the whole utterance — so appending the tail makes the two consistent. Clearing it would discard a correct transcript and, for an audio-capable judge (which never runs the STT back-fill), leave the segment with none at all.
  • Cursor-unsafe (no recording, the opening greeting with no prior agent segment, or a barge-in where a user segment is last): the audio is already off the wire, so the bleed is prevented either way; it is dropped with a warning rather than corrupting the append-only cursor.

Adapters that expose no reconcile_pending_audio are untouched.

adapter and executor are deliberately Any: this helper is duck-typed on both sides — it feature-detects reconcile_pending_audio rather than requiring :class:VoiceAgentAdapter, and reads the recording off the executor with getattr so the lightweight test doubles the recorder already tolerates work here too. Narrowing either to a concrete class would describe a contract this function does not actually enforce.

Expand source code
async def reconcile_prior_agent_audio(
    adapter: Any, executor: Any, now: float
) -> None:
    """Sweep up agent audio stranded by an early turn close and give it back to
    the utterance that produced it (issue #749; TypeScript parity with #748).

    The drain ends a turn on ``response_tail_silence``. A delivery gap longer
    than that leaves the rest of the agent's utterance in flight, and the next
    drain shifts it out as the opening audio of the NEXT agent turn — so turn
    N+1 appears to answer question N. Run at the pre-user-``send_audio``
    boundary, where the agent cannot yet have begun its next reply, so anything
    still arriving is unambiguously the prior turn's tail.

    Attribution is position-gated, mirroring the TypeScript reconcile:

    - Cursor-safe (an AGENT segment is still last — the user segment for this
      turn has not been written yet): grow it. Extending its ``end_time`` and
      audio cannot overlap a later segment. The transcript is left ALONE: the
      turn was cut short in AUDIO only — the provider's own ``agent_response``
      text already covers the whole utterance — so appending the tail makes the
      two consistent. Clearing it would discard a correct transcript and, for an
      audio-capable judge (which never runs the STT back-fill), leave the
      segment with none at all.
    - Cursor-unsafe (no recording, the opening greeting with no prior agent
      segment, or a barge-in where a user segment is last): the audio is already
      off the wire, so the bleed is prevented either way; it is dropped with a
      warning rather than corrupting the append-only cursor.

    Adapters that expose no ``reconcile_pending_audio`` are untouched.

    ``adapter`` and ``executor`` are deliberately ``Any``: this helper is
    duck-typed on both sides — it feature-detects ``reconcile_pending_audio``
    rather than requiring :class:`VoiceAgentAdapter`, and reads the recording off
    the executor with ``getattr`` so the lightweight test doubles the recorder
    already tolerates work here too. Narrowing either to a concrete class would
    describe a contract this function does not actually enforce.
    """
    reconcile = getattr(adapter, "reconcile_pending_audio", None)
    if reconcile is None:
        return
    try:
        leftover = await reconcile()
    except Exception:  # noqa: BLE001 — opportunistic cleanup must never fail a turn
        logger.warning(
            "%s: turn-boundary reconcile raised; continuing.",
            type(adapter).__name__,
            exc_info=True,
        )
        return
    if leftover is None or not leftover.data:
        return

    recording = getattr(executor, "_voice_recording", None) if executor else None
    segments = getattr(recording, "segments", None) if recording is not None else None
    if segments and segments[-1].speaker == "agent":
        prior = segments[-1]
        prior.audio += leftover.data
        prior.end_time = max(prior.end_time, now)
        _fire_audio_chunk(executor, leftover)
        logger.warning(
            "%s: recovered %d bytes of agent audio stranded by an early turn "
            "close and attributed them to the preceding agent turn. Raise "
            "response_tail_silence if this recurs.",
            type(adapter).__name__,
            len(leftover.data),
        )
    else:
        logger.warning(
            "%s: discarded %d bytes of agent audio at a user-turn boundary — "
            "there is no preceding agent segment to attribute them to, and "
            "growing an out-of-order segment would corrupt the recording. If "
            "your script does not lead with agent(), this is the on-connect "
            "greeting arriving after the first user turn was queued; lead with "
            "agent() so the greeting is drained as its own turn.",
            type(adapter).__name__,
            len(leftover.data),
        )
def write_user_segment(executor, chunk: AudioChunk, start: float, end: float) ‑> None

Append a finalised user segment + start/stop timeline events.

Single path that both _AdapterRecorder.record_user (the default call() flow) and ScenarioExecutor._record_interrupt_user_segment (the barge-in flow that bypasses the recorder) call into. Previously those two paths each open-coded the same four-step sequence (_fire_audio_chunk + _append_segment + two _append_events), drifting apart as the timing model evolved.

Expand source code
def write_user_segment(executor, chunk: AudioChunk, start: float, end: float) -> None:
    """Append a finalised user segment + start/stop timeline events.

    Single path that both ``_AdapterRecorder.record_user`` (the default
    ``call()`` flow) and ``ScenarioExecutor._record_interrupt_user_segment``
    (the barge-in flow that bypasses the recorder) call into. Previously
    those two paths each open-coded the same four-step sequence
    (``_fire_audio_chunk`` + ``_append_segment`` + two ``_append_event``s),
    drifting apart as the timing model evolved.
    """
    if executor is None or not chunk.data:
        return
    _fire_audio_chunk(executor, chunk)
    _append_segment(executor, "user", start, end, chunk)
    _append_event(executor, VoiceEvent(time=start, type="user_start_speaking"))
    _append_event(executor, VoiceEvent(time=end, type="user_stop_speaking"))

Classes

class AgentStreamEndedError (*args, **kwargs)

An adapter's recv_audio raises this when the agent's audio transport has TERMINATED — a background read-loop crash or a clean close by the peer — so no further audio can arrive on this connection.

WHY distinct from asyncio.TimeoutError: a timeout is TRANSIENT (the agent may still be mid-think; audio could still arrive), a stream-ended is TERMINAL (the connection is done). _drain_agent_response treats them differently: on the FIRST chunk it propagates this unchanged (it already names the real cause — this is the #498 diagnostic fix), on TAIL chunks it ends the turn normally (the peer closed after the agent finished speaking). Subclasses (e.g. PipecatRecvError) carry a transport-specific message and chain the underlying cause via cause.

Expand source code
class AgentStreamEndedError(Exception):
    """An adapter's recv_audio raises this when the agent's audio transport has
    TERMINATED — a background read-loop crash or a clean close by the peer — so
    no further audio can arrive on this connection.

    WHY distinct from asyncio.TimeoutError: a timeout is TRANSIENT (the agent may
    still be mid-think; audio could still arrive), a stream-ended is TERMINAL (the
    connection is done). _drain_agent_response treats them differently: on the
    FIRST chunk it propagates this unchanged (it already names the real cause —
    this is the #498 diagnostic fix), on TAIL chunks it ends the turn normally
    (the peer closed after the agent finished speaking). Subclasses (e.g.
    PipecatRecvError) carry a transport-specific message and chain the underlying
    cause via __cause__.
    """

Ancestors

  • builtins.Exception
  • builtins.BaseException

Subclasses

class FirstChunkTimeoutError (*, timeout: float)

Raised when the agent fails to send its first audio chunk within response_timeout.

WHY this subclass exists: operators could not distinguish a first-chunk hang (agent never spoke — wrong endpoint, VAD never fired, response_timeout too short) from a tail-silence cutoff (agent finished speaking normally). The bare asyncio.TimeoutError that escaped previously had an empty str() and no structured attributes, so log aggregators and re-raise chains had no signal. This class embeds the phase marker (_FIRST_CHUNK_PHASE) in its message, a machine-readable .timeout attribute, and chains the original transport error via __cause__.

Expand source code
class FirstChunkTimeoutError(asyncio.TimeoutError):
    """Raised when the agent fails to send its first audio chunk within ``response_timeout``.

    WHY this subclass exists: operators could not distinguish a first-chunk hang
    (agent never spoke — wrong endpoint, VAD never fired, response_timeout too
    short) from a tail-silence cutoff (agent finished speaking normally).  The
    bare ``asyncio.TimeoutError`` that escaped previously had an empty ``str()``
    and no structured attributes, so log aggregators and re-raise chains had no
    signal.  This class embeds the phase marker (``_FIRST_CHUNK_PHASE``) in its
    message, a machine-readable ``.timeout`` attribute, and chains the original
    transport error via ``__cause__``.
    """

    def __init__(self, *, timeout: float) -> None:
        self.timeout = timeout
        self.phase = _FIRST_CHUNK_PHASE
        super().__init__(
            f"agent did not send its first audio chunk within {timeout}s "
            f"(phase={_FIRST_CHUNK_PHASE})"
        )

Ancestors

  • builtins.TimeoutError
  • builtins.OSError
  • builtins.Exception
  • builtins.BaseException
class VoiceAgentAdapter

Abstract base for voice agents that exchange audio with the agent under test.

Subclasses implement connect, disconnect, send_audio, and recv_audio. The default call implementation threads audio extracted from the last incoming message through the transport and wraps the response back into an assistant message.

Attributes

capabilities
Declaration of what the adapter can and cannot do. Each concrete subclass must set this as a class attribute.
response_timeout

Seconds to wait for agent audio after sending user audio. Defaults to 60 seconds.

60 seconds covers a typical real-world STT → LLM → TTS round-trip including backoff/retry inside each provider, tool calls, and RAG lookups. If you see TimeoutError flakes against a fast LLM-only chain, you can lower this; if your agent does heavy processing (MCP roundtrips, multi-step tool chains), consider raising it.

Override per-adapter at construction time::

adapter = MyVoiceAdapter()
adapter.response_timeout = 90.0  # slow tool-call chain
Expand source code
class VoiceAgentAdapter(AgentAdapter):
    """
    Abstract base for voice agents that exchange audio with the agent under test.

    Subclasses implement ``connect``, ``disconnect``, ``send_audio``, and
    ``recv_audio``. The default ``call`` implementation threads audio extracted
    from the last incoming message through the transport and wraps the response
    back into an assistant message.

    Attributes:
        capabilities: Declaration of what the adapter can and cannot do. Each
            concrete subclass must set this as a class attribute.
        response_timeout: Seconds to wait for agent audio after sending user
            audio. Defaults to 60 seconds.

            60 seconds covers a typical real-world STT → LLM → TTS round-trip
            including backoff/retry inside each provider, tool calls, and RAG
            lookups. If you see TimeoutError flakes against a fast LLM-only
            chain, you can lower this; if your agent does heavy processing
            (MCP roundtrips, multi-step tool chains), consider raising it.

            Override per-adapter at construction time::

                adapter = MyVoiceAdapter()
                adapter.response_timeout = 90.0  # slow tool-call chain
    """

    role: ClassVar[AgentRole] = AgentRole.AGENT
    capabilities: ClassVar[AdapterCapabilities] = AdapterCapabilities()
    response_timeout: float = 60.0  # 60s: STT + LLM + TTS budget (see docstring)
    # Tail silence: once the first agent chunk arrives, keep draining recv_audio
    # until no chunk shows up within this many seconds — that's how we detect the
    # agent finished talking. Without this, demos record only the first ~100ms.
    response_tail_silence: float = 0.6
    # Hard cap on a single agent turn's audio. Prevents runaway loops if a
    # transport never signals end-of-stream. 30s = a long sentence.
    response_max_duration: float = 30.0

    # Live OTel context of the CURRENT ``voice.turn``, published by ``call()``
    # for background-receive-loop adapters (Pipecat/Twilio) to parent their
    # detached-task recv spans under the turn (#774). ``None`` between turns.
    # Class-level default so it exists even for a subclass that skips
    # ``super().__init__()`` (mirrors the ``_agent_speaking`` safety-net).
    _voice_turn_context: Optional[Context] = None

    #: SET when the AGENT deliberately ended the call (e.g. an ElevenLabs hosted
    #: agent invoking the ``end_call`` system tool), as opposed to the transport
    #: dropping. A scripted turn that arrives after this concludes the
    #: conversation instead of failing the run — the agent behaved as designed.
    #: Assertions and judges can read it to reason about WHO ended the call.
    #:
    #: Class-level default for the same reason as ``_voice_turn_context``: a
    #: subclass that skips ``super().__init__()`` would otherwise raise
    #: AttributeError from the ``call()`` gate instead of the intended
    #: TransportNotConnectedError. Matches the TS field default (``agentHungUp``).
    agent_hung_up: bool = False

    def __init__(self) -> None:
        # Per-instance event used by the interruption path to wait until
        # the agent is actually speaking before firing an interrupt — so
        # we don't fire ``clear`` at a silent SUT. Subclasses that
        # override ``__init__`` must call ``super().__init__()``.
        self._agent_speaking = asyncio.Event()

    @property
    def _agent_speaking_event(self) -> asyncio.Event:
        """Event set when the agent emits its first chunk of the current turn."""
        # Safety net for subclasses that pre-date this base ``__init__``
        # contract and didn't call ``super().__init__()``. They get a
        # one-shot lazy event so the interruption path doesn't crash.
        # We emit a single warning per subclass — silent fallback masks
        # bugs, but a warning per call would spam the timing-critical
        # interruption path. New adapters must call super().__init__().
        ev = getattr(self, "_agent_speaking", None)
        if ev is None:
            cls = type(self)
            if not getattr(cls, "_agent_speaking_lazy_warned", False):
                logger.warning(
                    "%s.__init__() did not call super().__init__(); "
                    "lazily initialising _agent_speaking event. "
                    "Add super().__init__() to silence this warning.",
                    cls.__name__,
                )
                # setattr() form: pyright won't infer this dynamic class attr
                # otherwise (reportAttributeAccessIssue). Functionally identical
                # to cls._agent_speaking_lazy_warned = True.
                setattr(cls, "_agent_speaking_lazy_warned", True)
            ev = asyncio.Event()
            self._agent_speaking = ev
        return ev

    def is_connected(self) -> bool:
        """Whether the transport is open and ready to exchange audio.

        Base default is ``True``: adapters without a persistent socket (or
        that manage liveness elsewhere) are always considered ready, so the
        pre-turn guard in :meth:`call` never blocks them. Transports with a
        real socket override this — e.g. :class:`ElevenLabsAgentAdapter`
        returns ``self._ws is not None and not self._ws.closed`` (parity with
        the TS ``isConnected()`` override, ``adapters/elevenlabs.ts:531-534``).
        """
        return True

    @abstractmethod
    async def connect(self) -> None:
        """Open the transport and prepare to exchange audio."""

    @abstractmethod
    async def disconnect(self) -> None:
        """Close the transport and release resources."""

    @abstractmethod
    async def send_audio(self, chunk: AudioChunk) -> None:
        """Transmit an AudioChunk to the agent under test."""

    @abstractmethod
    async def recv_audio(self, timeout: float) -> AudioChunk:
        """Receive the next AudioChunk from the agent."""

    async def __aenter__(self):
        # Default async context manager: subclasses don't need to
        # reimplement this — they get connect/disconnect sandwiching
        # for free. Override only if a transport needs extra setup
        # ordering around connect.
        await self.connect()
        return self

    async def __aexit__(self, *exc_info: Any) -> None:
        await self.disconnect()

    async def interrupt(self) -> None:
        """Send a first-class interrupt signal to the agent under test.

        Adapters that advertise ``capabilities.interruption=True`` override
        this to send the transport-native interrupt (e.g., Twilio ``clear``,
        OpenAI Realtime ``response.cancel``). The agent stops generating
        audio immediately — much more deterministic than racing VAD against
        a wall-clock sleep.

        The default raises ``UnsupportedCapabilityError``. Callers
        (``scenario.interrupt()``) check ``capabilities.interruption`` and
        fall back to timing-based barge-in (sending audio while the agent
        is speaking) when this returns False.
        """
        from .capabilities import UnsupportedCapabilityError

        raise UnsupportedCapabilityError(
            type(self).__name__,
            "interruption",
            hint=(
                "This adapter has no native interrupt signal. Use the "
                "timing-based barge-in pattern instead: "
                "agent(wait=False) + sleep(N) + user(content), where the "
                "user audio overlaps with the agent's TTS and the SUT's "
                "VAD detects it."
            ),
        )

    @contextlib.contextmanager
    def _voice_turn_context_scope(self) -> Iterator[None]:
        """Publish the live ``voice.turn`` OTel context for background-loop adapters.

        Pipecat/Twilio run their real receive in a background task/callback whose
        OTel context was frozen at ``connect()`` (a now-closed span). They read
        :attr:`_voice_turn_context` to parent those detached recv spans under the
        CURRENT ``voice.turn`` (#774 — the reusable pattern Twilio PR5 inherits).
        Entered INSIDE the ``voice.turn`` span so ``get_current()`` captures it;
        cleared on exit so a late background frame BETWEEN turns finds ``None`` and
        skips its span rather than parenting under a closed turn.
        """
        self._voice_turn_context = otel_context.get_current()
        try:
            yield
        finally:
            self._voice_turn_context = None

    async def call(self, input: AgentInput) -> AgentReturnTypes:
        """
        Default implementation: extract audio from the latest user message,
        send it, drain the agent's full response (multiple recv_audio chunks
        until tail silence), record once, return as one assistant audio message.

        Why drain instead of taking one chunk: TTS and realtime APIs stream
        their response in many small chunks. A single recv_audio() returns the
        first one only — the recorder would log ~100ms of agent audio per turn
        and the judge would receive a truncated response. Draining until
        tail-silence (no new chunk for ``response_tail_silence`` seconds) gives
        the natural "agent finished talking" signal that works across
        adapters without each one needing to know its transport's done event.

        Subclasses may override this for specialised flows but will usually
        inherit it.
        """
        # Uniform pre-turn connected-state gate (mirror TS
        # ``adapter.runtime.ts:249-254``): a call() issued before the
        # executor's connect() — or after a dropped transport — fails once with
        # a clear error naming the adapter, rather than a transport-specific
        # null-deref or a silent hang. Checked ONCE, BEFORE send_audio/
        # recv_audio. It does NOT suppress ``FirstChunkTimeoutError``: a
        # connected adapter whose first chunk never arrives still surfaces that
        # timeout from the drain below.
        #
        # We raise ``TransportNotConnectedError`` (a subclass of
        # ``PendingTransportError``, so the TS-parity ``except
        # PendingTransportError`` gate still catches it) whose message is
        # actionable for a real, implemented adapter — "call connect()/reconnect"
        # — rather than the base "implement your transport" guidance meant for
        # unshipped stubs.
        if not self.is_connected():
            if self.agent_hung_up:
                # The AGENT ended the call on purpose (issue #839) — hosted
                # agents routinely invoke a hangup tool right after their
                # farewell, which closes the transport. Any scripted turn left
                # in the script has nobody to talk to, but the agent did
                # exactly what it was designed to do, so concluding here and
                # letting the script fall through to the judge is the correct
                # outcome. Failing the run would punish correct behaviour.
                # Returning no messages leaves the transcript ending on the
                # agent's farewell, which is what the judge should assess.
                logger.info(
                    "%s: agent ended the call; concluding the conversation "
                    "instead of failing the remaining scripted turn(s)",
                    type(self).__name__,
                )
                return []
            from .adapters._stub import TransportNotConnectedError

            raise TransportNotConnectedError(type(self).__name__)
        # One ``voice.turn`` span per call(), nesting under the executor's
        # existing ``{cls}.call`` agent span (ambient OTel context — no parent
        # passed). The transport spans below (send/receive) nest under it.
        turn_index = getattr(
            getattr(input, "scenario_state", None), "current_turn", None
        )
        with voice_span(
            "voice.turn",
            {
                "voice.adapter.class": type(self).__name__,
                "voice.turn.index": turn_index,
            },
        ) as _turn_span, self._voice_turn_context_scope():
            _turn_started = time.monotonic()
            # Clear the speaking-event for this turn — set in _drain on first chunk.
            self._agent_speaking_event.clear()
            recorder = _AdapterRecorder(input)
            incoming = (
                extract_audio(input.new_messages[-1]) if input.new_messages else None
            )
            if incoming is not None:
                # BEFORE the user's audio goes out (and before the user segment
                # is written, so an agent segment is still last on the cursor):
                # sweep up any agent audio an early turn close stranded in
                # flight, so it lands on the turn that produced it instead of
                # bleeding out as the next turn's opening audio (#749).
                await reconcile_prior_agent_audio(
                    self, recorder._executor, recorder._offset()
                )
                # The sweep is cleanup of the PREVIOUS turn, so re-stamp the
                # start: leaving it would bill this turn for time spent draining
                # the last one and inflate the reported turn latency.
                _turn_started = time.monotonic()
                # Wrap send_audio so user.start = "we began transmitting" and
                # user.end = "we finished transmitting" — both real flow points.
                recorder.mark_user_start()
                with voice_span(
                    "voice.audio.send", {"voice.audio.bytes": len(incoming.data)}
                ):
                    await self.send_audio(incoming)
                recorder.record_user(incoming)
            # Drain. Recorder grabs agent.start at first chunk via
            # mark_agent_start, so agent.start is "first chunk on the wire,"
            # not "now minus merged.duration."
            merged = await self._drain_agent_response(
                on_first_chunk=recorder.mark_agent_start
            )
            # Mark agent.end BEFORE the STT round-trip below — the agent stopped
            # speaking when drain settled, not after transcription returned.
            recorder.mark_agent_end()
            _turn_span.set_attribute(
                "voice.turn.latency_ms",
                round((time.monotonic() - _turn_started) * 1000),
            )
            if incoming is not None and incoming.data:
                _turn_span.set_attribute(
                    "voice.turn.user_audio_bytes", len(incoming.data)
                )
            if merged.data:
                _turn_span.set_attribute(
                    "voice.turn.agent_audio_bytes", len(merged.data)
                )
            merged = await self._ensure_transcript(merged)
            recorder.record_agent(merged)
            return create_audio_message(merged, role="assistant")

    async def _ensure_transcript(self, merged: AudioChunk) -> AudioChunk:
        """Best-effort runtime STT for adapters whose transport carries no text.

        The assistant message built from this chunk feeds the conversation
        history that the user simulator (a text-only LLM) reads. Without a
        text part the simulator sees the ``[audio message]`` placeholder for
        every agent turn and replies blind — both sides talk past each other
        while the judge, which transcribes the recording post-hoc, still
        renders a perfectly readable transcript. Transcribing here closes
        that gap; it also fills the recording segment's transcript, so the
        judge fallback (``transcribe_segments(only_missing=True)``) skips
        these turns.

        Adapters that already ship transcripts on their chunks (realtime
        APIs) return them merged — ``merged.transcript`` is set and this is
        a no-op. STT failures are logged and the audio-only chunk is
        returned unchanged, same contract as ``transcribe_segments``.
        """
        if not merged.data or merged.transcript:
            return merged
        try:
            # ``voice.stt.transcribe`` — one span per per-turn STT provider call,
            # nesting under this turn's ``voice.turn`` span (#776). Python runs
            # STT per-turn (here); the TS mirror is a per-RUN back-fill batch
            # (``voice.stt.backfill`` → ``voice.stt.transcribe``). Shared
            # attributes across BOTH languages: ``voice.stt.scope`` /
            # ``.speaker`` / ``.audio_bytes`` / ``.transcript_chars``.
            # ``voice.adapter.class`` is Python-ONLY here: the per-turn STT always
            # transcribes THIS adapter's own agent output, whereas the TS per-run
            # back-fill is adapter-agnostic over recorded segments. Disambiguate
            # cross-language by ``voice.stt.scope`` (turn vs run).
            with voice_span(
                "voice.stt.transcribe",
                {
                    "voice.adapter.class": type(self).__name__,
                    "voice.stt.scope": "turn",
                    "voice.stt.speaker": "agent",
                    "voice.stt.audio_bytes": len(merged.data),
                },
            ) as stt_span:
                try:
                    text = await transcribe(merged)
                except Exception as exc:
                    # Sanitize BEFORE the span records it: provider SDK errors
                    # (OpenAI/ElevenLabs) can embed the raw response body — and a
                    # key fragment on a 401 — in the message, which
                    # ``voice_span``'s ``record_exception`` would EXPORT to
                    # telemetry. Log the detail locally at DEBUG (non-exported);
                    # raise a minimal, provider-agnostic error (``from None`` so
                    # the chained original is not formatted into the recorded
                    # traceback) so the span still marks ERROR without leaking.
                    # Mirrors ``ElevenLabsSTTProvider`` in ``voice/stt.py``.
                    logger.debug(
                        "voice: STT provider error detail", exc_info=True
                    )
                    raise RuntimeError(
                        f"STT provider failed: {type(exc).__name__}"
                    ) from None
                if text:
                    stt_span.set_attribute("voice.stt.transcript_chars", len(text))
        except Exception:
            logger.warning(
                "voice: agent-turn STT failed; the user simulator will see "
                "'[audio message]' instead of this turn's words",
            )
            logger.debug("voice: agent-turn STT failure detail", exc_info=True)
            return merged
        if not text:
            return merged
        return dataclasses.replace(merged, transcript=text)

    async def _drain_agent_response(
        self, on_first_chunk: Optional[Callable[[], None]] = None
    ) -> AudioChunk:
        """Loop ``recv_audio`` until tail silence or max duration; merge result.

        ``on_first_chunk`` is invoked synchronously the moment the first
        non-empty audio chunk arrives — used by the recorder to capture
        agent.start at a real flow point rather than back-computing from
        the merged-chunk duration.
        """
        # ``voice.audio.receive`` — THE user-story span: a receiveAudio timeout
        # surfaces as ERROR here, so the trace shows WHY a run failed.
        with voice_span("voice.audio.receive") as _recv_span:
            _recv_started = time.monotonic()
            try:
                first = await self.recv_audio(timeout=self.response_timeout)
            except asyncio.TimeoutError as err:
                _recv_span.set_attribute(
                    "voice.audio.terminated_reason", "first_chunk_timeout"
                )
                raise FirstChunkTimeoutError(timeout=self.response_timeout) from err
            # An AgentStreamEndedError is intentionally NOT caught here: it is not a
            # TimeoutError, so it propagates past this handler unchanged. That
            # preserves the real terminal cause (recv-loop crash or clean peer close)
            # for the #498 diagnostic fix instead of masking it as a first-chunk
            # timeout. Do NOT add a bare ``except Exception`` here. It exits the
            # span as ERROR, which is exactly what the trace should show.
            _recv_span.set_attribute(
                "voice.audio.first_chunk_latency_ms",
                round((time.monotonic() - _recv_started) * 1000),
            )
            # First chunk arrived → agent is now speaking. Wakes anyone awaiting
            # _agent_speaking_event (the interruption path).
            if first.data and on_first_chunk is not None:
                on_first_chunk()
            self._agent_speaking_event.set()
            chunks: List[AudioChunk] = [first]
            accumulated = first.duration_seconds
            # Default reason: the loop condition fell through, i.e. the runaway
            # backstop (Python-only — TS terminates on a hard ceiling instead).
            terminated_reason = "max_duration"
            while accumulated < self.response_max_duration:
                try:
                    nxt = await self.recv_audio(timeout=self.response_tail_silence)
                except asyncio.TimeoutError:
                    terminated_reason = "tail_silence"
                    break
                except AgentStreamEndedError:
                    # The stream ended after the agent already spoke — a normal
                    # end-of-turn (the peer closed once it finished). Return the
                    # audio collected so far instead of surfacing the terminal cause.
                    terminated_reason = "stream_ended"
                    break
                if not nxt.data:
                    terminated_reason = "terminal_chunk"
                    break
                chunks.append(nxt)
                accumulated += nxt.duration_seconds
            merged = _merge_chunks(chunks)
            _recv_span.set_attribute("voice.audio.terminated_reason", terminated_reason)
            _recv_span.set_attribute("voice.audio.chunk_count", len(chunks))
            _recv_span.set_attribute(
                "voice.audio.bytes", len(merged.data) if merged.data else 0
            )
            return merged

Ancestors

Subclasses

Class variables

var agent_hung_up : bool

Class-level default for the same reason as _voice_turn_context: a subclass that skips super().__init__() would otherwise raise AttributeError from the call() gate instead of the intended TransportNotConnectedError. Matches the TS field default (agentHungUp).

var capabilities : ClassVar[AdapterCapabilities]
var response_max_duration : float
var response_tail_silence : float
var response_timeout : float
var role : ClassVar[AgentRole]

Methods

async def call(self, input: AgentInput) ‑> str | openai.types.chat.chat_completion_developer_message_param.ChatCompletionDeveloperMessageParam | openai.types.chat.chat_completion_system_message_param.ChatCompletionSystemMessageParam | openai.types.chat.chat_completion_user_message_param.ChatCompletionUserMessageParam | openai.types.chat.chat_completion_assistant_message_param.ChatCompletionAssistantMessageParam | openai.types.chat.chat_completion_tool_message_param.ChatCompletionToolMessageParam | openai.types.chat.chat_completion_function_message_param.ChatCompletionFunctionMessageParam | List[openai.types.chat.chat_completion_developer_message_param.ChatCompletionDeveloperMessageParam | openai.types.chat.chat_completion_system_message_param.ChatCompletionSystemMessageParam | openai.types.chat.chat_completion_user_message_param.ChatCompletionUserMessageParam | openai.types.chat.chat_completion_assistant_message_param.ChatCompletionAssistantMessageParam | openai.types.chat.chat_completion_tool_message_param.ChatCompletionToolMessageParam | openai.types.chat.chat_completion_function_message_param.ChatCompletionFunctionMessageParam] | ScenarioResult

Default implementation: extract audio from the latest user message, send it, drain the agent's full response (multiple recv_audio chunks until tail silence), record once, return as one assistant audio message.

Why drain instead of taking one chunk: TTS and realtime APIs stream their response in many small chunks. A single recv_audio() returns the first one only — the recorder would log ~100ms of agent audio per turn and the judge would receive a truncated response. Draining until tail-silence (no new chunk for response_tail_silence seconds) gives the natural "agent finished talking" signal that works across adapters without each one needing to know its transport's done event.

Subclasses may override this for specialised flows but will usually inherit it.

Expand source code
async def call(self, input: AgentInput) -> AgentReturnTypes:
    """
    Default implementation: extract audio from the latest user message,
    send it, drain the agent's full response (multiple recv_audio chunks
    until tail silence), record once, return as one assistant audio message.

    Why drain instead of taking one chunk: TTS and realtime APIs stream
    their response in many small chunks. A single recv_audio() returns the
    first one only — the recorder would log ~100ms of agent audio per turn
    and the judge would receive a truncated response. Draining until
    tail-silence (no new chunk for ``response_tail_silence`` seconds) gives
    the natural "agent finished talking" signal that works across
    adapters without each one needing to know its transport's done event.

    Subclasses may override this for specialised flows but will usually
    inherit it.
    """
    # Uniform pre-turn connected-state gate (mirror TS
    # ``adapter.runtime.ts:249-254``): a call() issued before the
    # executor's connect() — or after a dropped transport — fails once with
    # a clear error naming the adapter, rather than a transport-specific
    # null-deref or a silent hang. Checked ONCE, BEFORE send_audio/
    # recv_audio. It does NOT suppress ``FirstChunkTimeoutError``: a
    # connected adapter whose first chunk never arrives still surfaces that
    # timeout from the drain below.
    #
    # We raise ``TransportNotConnectedError`` (a subclass of
    # ``PendingTransportError``, so the TS-parity ``except
    # PendingTransportError`` gate still catches it) whose message is
    # actionable for a real, implemented adapter — "call connect()/reconnect"
    # — rather than the base "implement your transport" guidance meant for
    # unshipped stubs.
    if not self.is_connected():
        if self.agent_hung_up:
            # The AGENT ended the call on purpose (issue #839) — hosted
            # agents routinely invoke a hangup tool right after their
            # farewell, which closes the transport. Any scripted turn left
            # in the script has nobody to talk to, but the agent did
            # exactly what it was designed to do, so concluding here and
            # letting the script fall through to the judge is the correct
            # outcome. Failing the run would punish correct behaviour.
            # Returning no messages leaves the transcript ending on the
            # agent's farewell, which is what the judge should assess.
            logger.info(
                "%s: agent ended the call; concluding the conversation "
                "instead of failing the remaining scripted turn(s)",
                type(self).__name__,
            )
            return []
        from .adapters._stub import TransportNotConnectedError

        raise TransportNotConnectedError(type(self).__name__)
    # One ``voice.turn`` span per call(), nesting under the executor's
    # existing ``{cls}.call`` agent span (ambient OTel context — no parent
    # passed). The transport spans below (send/receive) nest under it.
    turn_index = getattr(
        getattr(input, "scenario_state", None), "current_turn", None
    )
    with voice_span(
        "voice.turn",
        {
            "voice.adapter.class": type(self).__name__,
            "voice.turn.index": turn_index,
        },
    ) as _turn_span, self._voice_turn_context_scope():
        _turn_started = time.monotonic()
        # Clear the speaking-event for this turn — set in _drain on first chunk.
        self._agent_speaking_event.clear()
        recorder = _AdapterRecorder(input)
        incoming = (
            extract_audio(input.new_messages[-1]) if input.new_messages else None
        )
        if incoming is not None:
            # BEFORE the user's audio goes out (and before the user segment
            # is written, so an agent segment is still last on the cursor):
            # sweep up any agent audio an early turn close stranded in
            # flight, so it lands on the turn that produced it instead of
            # bleeding out as the next turn's opening audio (#749).
            await reconcile_prior_agent_audio(
                self, recorder._executor, recorder._offset()
            )
            # The sweep is cleanup of the PREVIOUS turn, so re-stamp the
            # start: leaving it would bill this turn for time spent draining
            # the last one and inflate the reported turn latency.
            _turn_started = time.monotonic()
            # Wrap send_audio so user.start = "we began transmitting" and
            # user.end = "we finished transmitting" — both real flow points.
            recorder.mark_user_start()
            with voice_span(
                "voice.audio.send", {"voice.audio.bytes": len(incoming.data)}
            ):
                await self.send_audio(incoming)
            recorder.record_user(incoming)
        # Drain. Recorder grabs agent.start at first chunk via
        # mark_agent_start, so agent.start is "first chunk on the wire,"
        # not "now minus merged.duration."
        merged = await self._drain_agent_response(
            on_first_chunk=recorder.mark_agent_start
        )
        # Mark agent.end BEFORE the STT round-trip below — the agent stopped
        # speaking when drain settled, not after transcription returned.
        recorder.mark_agent_end()
        _turn_span.set_attribute(
            "voice.turn.latency_ms",
            round((time.monotonic() - _turn_started) * 1000),
        )
        if incoming is not None and incoming.data:
            _turn_span.set_attribute(
                "voice.turn.user_audio_bytes", len(incoming.data)
            )
        if merged.data:
            _turn_span.set_attribute(
                "voice.turn.agent_audio_bytes", len(merged.data)
            )
        merged = await self._ensure_transcript(merged)
        recorder.record_agent(merged)
        return create_audio_message(merged, role="assistant")
async def connect(self) ‑> None

Open the transport and prepare to exchange audio.

Expand source code
@abstractmethod
async def connect(self) -> None:
    """Open the transport and prepare to exchange audio."""
async def disconnect(self) ‑> None

Close the transport and release resources.

Expand source code
@abstractmethod
async def disconnect(self) -> None:
    """Close the transport and release resources."""
async def interrupt(self) ‑> None

Send a first-class interrupt signal to the agent under test.

Adapters that advertise capabilities.interruption=True override this to send the transport-native interrupt (e.g., Twilio clear, OpenAI Realtime response.cancel). The agent stops generating audio immediately — much more deterministic than racing VAD against a wall-clock sleep.

The default raises UnsupportedCapabilityError. Callers (interrupt()) check capabilities.interruption and fall back to timing-based barge-in (sending audio while the agent is speaking) when this returns False.

Expand source code
async def interrupt(self) -> None:
    """Send a first-class interrupt signal to the agent under test.

    Adapters that advertise ``capabilities.interruption=True`` override
    this to send the transport-native interrupt (e.g., Twilio ``clear``,
    OpenAI Realtime ``response.cancel``). The agent stops generating
    audio immediately — much more deterministic than racing VAD against
    a wall-clock sleep.

    The default raises ``UnsupportedCapabilityError``. Callers
    (``scenario.interrupt()``) check ``capabilities.interruption`` and
    fall back to timing-based barge-in (sending audio while the agent
    is speaking) when this returns False.
    """
    from .capabilities import UnsupportedCapabilityError

    raise UnsupportedCapabilityError(
        type(self).__name__,
        "interruption",
        hint=(
            "This adapter has no native interrupt signal. Use the "
            "timing-based barge-in pattern instead: "
            "agent(wait=False) + sleep(N) + user(content), where the "
            "user audio overlaps with the agent's TTS and the SUT's "
            "VAD detects it."
        ),
    )
def is_connected(self) ‑> bool

Whether the transport is open and ready to exchange audio.

Base default is True: adapters without a persistent socket (or that manage liveness elsewhere) are always considered ready, so the pre-turn guard in :meth:call never blocks them. Transports with a real socket override this — e.g. :class:ElevenLabsAgentAdapter returns self._ws is not None and not self._ws.closed (parity with the TS isConnected() override, adapters/elevenlabs.ts:531-534).

Expand source code
def is_connected(self) -> bool:
    """Whether the transport is open and ready to exchange audio.

    Base default is ``True``: adapters without a persistent socket (or
    that manage liveness elsewhere) are always considered ready, so the
    pre-turn guard in :meth:`call` never blocks them. Transports with a
    real socket override this — e.g. :class:`ElevenLabsAgentAdapter`
    returns ``self._ws is not None and not self._ws.closed`` (parity with
    the TS ``isConnected()`` override, ``adapters/elevenlabs.ts:531-534``).
    """
    return True
async def recv_audio(self, timeout: float) ‑> AudioChunk

Receive the next AudioChunk from the agent.

Expand source code
@abstractmethod
async def recv_audio(self, timeout: float) -> AudioChunk:
    """Receive the next AudioChunk from the agent."""
async def send_audio(self, chunk: AudioChunk) ‑> None

Transmit an AudioChunk to the agent under test.

Expand source code
@abstractmethod
async def send_audio(self, chunk: AudioChunk) -> None:
    """Transmit an AudioChunk to the agent under test."""