Files
2026-07-13 13:39:38 +08:00

1899 lines
77 KiB
Python

from __future__ import annotations
import asyncio
import copy
import time
from collections.abc import AsyncIterable, AsyncIterator, Callable, Sequence
from contextlib import AbstractContextManager, asynccontextmanager, nullcontext
from contextvars import Token
from dataclasses import dataclass
from types import TracebackType
from typing import (
TYPE_CHECKING,
Any,
Generic,
Literal,
Protocol,
TypeVar,
overload,
runtime_checkable,
)
from google.protobuf.json_format import ParseDict
from google.protobuf.struct_pb2 import Struct
from opentelemetry import context as otel_context, trace
from typing_extensions import TypedDict
from livekit import rtc
from livekit.protocol.agent_pb import agent_session as agent_pb
from .. import cli, inference, llm, stt, tts, utils, vad
from .._exceptions import APIError
from ..job import get_job_context
from ..llm import AgentHandoff, ChatContext, MetricsReport
from ..llm.chat_context import Instructions
from ..log import logger
from ..metrics import AgentSessionUsage, ModelUsageCollector
from ..telemetry import trace_types, tracer
from ..types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectOptions,
NotGivenOr,
)
from ..utils.deprecation import deprecate_params
from ..utils.misc import is_given
from . import io, room_io
from ._utils import _set_participant_attributes
from .agent import Agent, AgentTask
from .agent_activity import AgentActivity, _ReusableResources
from .amd import AMD
from .events import (
AgentEvent,
AgentState,
AgentStateChangedEvent,
CloseEvent,
CloseReason,
ConversationItemAddedEvent,
EventTypes,
UserInputTranscribedEvent,
UserState,
UserStateChangedEvent,
)
from .ivr import IVRActivity
from .keyterm_detection import KeytermDetector, KeytermsOptions, _resolve_keyterms_options
from .recorder_io import RecorderIO
from .remote_session import RoomSessionTransport, SessionHost, SessionTransport
from .run_result import RunOutputOptions, RunResult
from .speech_handle import InputDetails, SpeechHandle
from .tool_executor import ToolHandlingOptions, _resolve_async_tool_options
from .turn import (
EndpointingOptions,
InterruptionOptions,
PreemptiveGenerationOptions,
TurnDetectionMode,
TurnHandlingOptions,
_migrate_turn_handling,
_resolve_endpointing,
_resolve_interruption,
_resolve_preemptive_generation,
_resolve_user_turn_limit,
)
if TYPE_CHECKING:
from ..cli.tcp_console import TcpAudioInput, TcpAudioOutput
from ..inference import LLMModels, STTModels, TTSModels
from ..llm import mcp
from .presets import Preset
from .transcription.text_transforms import TextTransforms
class RecordingOptions(TypedDict, total=False):
"""Granular control over which recording features are active.
All keys default to ``True`` when not specified, so ``{"logs": False}``
means "record everything except logs."
Can be passed directly to :pymethod:`AgentSession.start(record=...)`:
* ``record=True`` → all on (backward compatible)
* ``record=False`` → all off (backward compatible)
* ``record={"audio": True, "traces": False}`` → granular
"""
audio: bool
"""Record session audio. Defaults to ``True``."""
traces: bool
"""Export OpenTelemetry trace spans. Defaults to ``True``."""
logs: bool
"""Export OpenTelemetry logs. Defaults to ``True``."""
transcript: bool
"""Upload the conversation transcript (chat history). Defaults to ``True``."""
_RECORDING_ALL_ON: RecordingOptions = {
"audio": True,
"traces": True,
"logs": True,
"transcript": True,
}
_RECORDING_ALL_OFF: RecordingOptions = {
"audio": False,
"traces": False,
"logs": False,
"transcript": False,
}
def _resolve_recording_options(record: bool | RecordingOptions) -> RecordingOptions:
if isinstance(record, bool):
defaults = _RECORDING_ALL_ON if record else _RECORDING_ALL_OFF
return RecordingOptions(**defaults)
return RecordingOptions(**{**_RECORDING_ALL_ON, **record})
@dataclass
class SessionConnectOptions:
stt_conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
llm_conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
tts_conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
max_unrecoverable_errors: int = 3
"""Maximum number of consecutive unrecoverable errors from llm or tts."""
class ExpressiveOptions(TypedDict, total=False):
"""Configuration for the expressive pipeline (framework-internal, not publicly exposed).
Controls how TTS markup instructions are injected into the LLM when expressive is
enabled. All keys are optional; common shapes:
- ``{"preset": Preset.CASUAL}`` — a domain preset, resolved to the active
TTS provider's tuned tags (see ``voice.presets``).
- ``{"preset": ..., "tts_instructions_append": "..."}`` — a preset plus your own
rules appended after it resolves.
- ``{"tts_instructions_template": "..."}`` — a fully custom prompt.
Any explicit template overrides the corresponding part of the resolved preset; unset
parts fall back to the resolved preset (or the provider-agnostic default).
"""
preset: Preset
tts_instructions_template: Instructions | str
tts_instructions_append: str
DEFAULT_EXPRESSIVE_OPTIONS: ExpressiveOptions = ExpressiveOptions(
tts_instructions_template=Instructions(
"You can control how you speak using the following formatting tags. "
"Use them when appropriate to make your speech more expressive and natural:\n\n"
"{tts.markup.llm_instructions}"
),
)
@dataclass
class AgentSessionOptions:
turn_handling: TurnHandlingOptions
keyterms_options: KeytermsOptions
endpointing_overrides: EndpointingOptions
"""sparse endpointing keys the user provided explicitly"""
max_tool_steps: int
user_away_timeout: float | None
min_consecutive_speech_delay: float
use_tts_aligned_transcript: bool | None
tts_text_transforms: Sequence[TextTransforms] | None
ivr_detection: bool
aec_warmup_duration: float | None
session_close_transcript_timeout: float
@property
def endpointing(self) -> EndpointingOptions:
return self.turn_handling["endpointing"]
@property
def interruption(self) -> InterruptionOptions:
return self.turn_handling["interruption"]
@property
def preemptive_generation(self) -> PreemptiveGenerationOptions:
return self.turn_handling["preemptive_generation"]
Userdata_T = TypeVar("Userdata_T")
Run_T = TypeVar("Run_T")
# _RunContextVar = contextvars.ContextVar[RunResult]("agents_run_state")
@runtime_checkable
class _VideoSampler(Protocol):
def __call__(self, frame: rtc.VideoFrame, session: AgentSession) -> bool: ...
# TODO(theomonnom): Should this be moved to another file?
class VoiceActivityVideoSampler:
def __init__(self, *, speaking_fps: float = 1.0, silent_fps: float = 0.3):
self.speaking_fps = speaking_fps
self.silent_fps = silent_fps
self._last_sampled_time: float | None = None
def __call__(self, frame: rtc.VideoFrame, session: AgentSession) -> bool:
now = time.time()
is_speaking = session.user_state == "speaking"
target_fps = self.speaking_fps if is_speaking else self.silent_fps
if target_fps == 0:
return False
min_frame_interval = 1.0 / target_fps
if self._last_sampled_time is None:
self._last_sampled_time = now
return True
if (now - self._last_sampled_time) >= min_frame_interval:
self._last_sampled_time = now
return True
return False
DEFAULT_TTS_TEXT_TRANSFORMS: list[TextTransforms] = ["filter_markdown", "filter_emoji"]
class AgentSession(rtc.EventEmitter[EventTypes], Generic[Userdata_T]):
@deprecate_params(
{
"min_endpointing_delay": "Use turn_handling=TurnHandlingOptions(...) instead",
"max_endpointing_delay": "Use turn_handling=TurnHandlingOptions(...) instead",
"false_interruption_timeout": "Use turn_handling=TurnHandlingOptions(...) instead",
"resume_false_interruption": "Use turn_handling=TurnHandlingOptions(...) instead",
"allow_interruptions": "Use turn_handling=TurnHandlingOptions(...) instead",
"discard_audio_if_uninterruptible": "Use turn_handling=TurnHandlingOptions(...) instead",
"min_interruption_duration": "Use turn_handling=TurnHandlingOptions(...) instead",
"preemptive_generation": "Use turn_handling=TurnHandlingOptions(...) instead",
"min_interruption_words": "Use turn_handling=TurnHandlingOptions(...) instead",
"turn_detection": "Use turn_handling=TurnHandlingOptions(...) instead",
"agent_false_interruption_timeout": "Use turn_handling=TurnHandlingOptions(...) instead",
},
target_version="v2.0",
)
def __init__(
self,
*,
stt: NotGivenOr[stt.STT | STTModels | str] = NOT_GIVEN,
vad: NotGivenOr[vad.VAD | None] = NOT_GIVEN,
llm: NotGivenOr[llm.LLM | llm.RealtimeModel | LLMModels | str] = NOT_GIVEN,
tts: NotGivenOr[tts.TTS | TTSModels | str] = NOT_GIVEN,
turn_handling: NotGivenOr[TurnHandlingOptions] = NOT_GIVEN,
keyterms_options: NotGivenOr[KeytermsOptions] = NOT_GIVEN,
# Tool settings
tools: NotGivenOr[list[llm.Tool | llm.Toolset]] = NOT_GIVEN,
tool_handling: NotGivenOr[ToolHandlingOptions] = NOT_GIVEN,
max_tool_steps: int = 3,
# TTS settings
use_tts_aligned_transcript: NotGivenOr[bool] = NOT_GIVEN,
tts_text_transforms: NotGivenOr[Sequence[TextTransforms] | None] = NOT_GIVEN,
min_consecutive_speech_delay: float = 0.0,
# Misc settings
userdata: NotGivenOr[Userdata_T] = NOT_GIVEN,
video_sampler: NotGivenOr[_VideoSampler | None] = NOT_GIVEN,
aec_warmup_duration: float | None = 3.0,
ivr_detection: bool = False,
user_away_timeout: float | None = 15.0,
session_close_transcript_timeout: float = 2.0,
# Runtime settings
conn_options: NotGivenOr[SessionConnectOptions] = NOT_GIVEN,
loop: asyncio.AbstractEventLoop | None = None,
# deprecated
preemptive_generation: NotGivenOr[bool] = NOT_GIVEN,
min_endpointing_delay: NotGivenOr[float] = NOT_GIVEN,
max_endpointing_delay: NotGivenOr[float] = NOT_GIVEN,
false_interruption_timeout: NotGivenOr[float | None] = NOT_GIVEN,
turn_detection: NotGivenOr[TurnDetectionMode] = NOT_GIVEN,
discard_audio_if_uninterruptible: NotGivenOr[bool] = NOT_GIVEN,
min_interruption_duration: NotGivenOr[float] = NOT_GIVEN,
min_interruption_words: NotGivenOr[int] = NOT_GIVEN,
allow_interruptions: NotGivenOr[bool] = NOT_GIVEN,
resume_false_interruption: NotGivenOr[bool] = NOT_GIVEN,
agent_false_interruption_timeout: NotGivenOr[float | None] = NOT_GIVEN,
mcp_servers: NotGivenOr[list[mcp.MCPServer]] = NOT_GIVEN,
) -> None:
"""`AgentSession` is the LiveKit Agents runtime that glues together
media streams, speech/LLM components, and tool orchestration into a
single real-time voice agent.
It links audio, video, and text I/O with STT, VAD, TTS, and the LLM;
handles turn detection, endpointing, interruptions, and multi-step
tool calls; and exposes everything through event callbacks so you can
focus on writing function tools and simple hand-offs rather than
low-level streaming logic.
Args:
stt (stt.STT | str, optional): Speech-to-text backend.
vad (vad.VAD, optional): Voice-activity detector. Defaults to the
bundled silero VAD (``inference.VAD(model="silero")``) when
omitted. Pass ``vad=None`` to opt out, or pass an explicit
instance to customise options.
llm (llm.LLM | llm.RealtimeModel | str, optional): LLM or RealtimeModel
tts (tts.TTS | str, optional): Text-to-speech engine.
tools (list[llm.FunctionTool | llm.RawFunctionTool], optional): List of
tools shared by every agent in the agent session.
tool_handling (ToolHandlingOptions, optional): Tool handling configuration.
``tool_handling["async_options"]`` holds prompt templates for ``ctx.update()`` /
duplicate-handling / coalesced replies. Unspecified keys keep their defaults;
can be overridden per-``Agent`` or per-``AsyncToolset``.
mcp_servers (list[mcp.MCPServer], optional): List of MCP servers
providing external tools for the agent to use.
userdata (Userdata_T, optional): Arbitrary per-session user data.
turn_handling (TurnHandlingOptions, optional): Configuration for turn handling.
keyterms_options (KeytermsOptions, optional): Keyterm biasing for the STT. Holds
static ``keyterms`` plus ``keyterm_detection`` (LLM extraction). Applies to STTs
that accept a term list; on others it warns and is ignored.
max_endpointing_delay (float): Maximum time-in-seconds the agent
will wait before terminating the turn. Default ``3.0`` s.
max_tool_steps (int): Maximum consecutive tool calls per LLM turn.
Default ``3``.
video_sampler (_VideoSampler, optional): Uses
:class:`VoiceActivityVideoSampler` when *NOT_GIVEN*; that sampler
captures video at ~1 fps while the user is speaking and ~0.3 fps
when silent by default.
min_consecutive_speech_delay (float, optional): The minimum delay between
consecutive speech. Default ``0.0`` s.
use_tts_aligned_transcript (bool, optional): Whether to use TTS-aligned
transcript as the input of the ``transcription_node``. Only applies
if ``TTS.capabilities.aligned_transcript`` is ``True`` or ``streaming``
is ``False``. When NOT_GIVEN, it's disabled.
tts_text_transforms (Sequence[TextTransforms], optional): The transforms to apply
to the tts input text, available built-in transforms: ``"filter_markdown"``, ``"filter_emoji"``.
Set to ``None`` to disable. When NOT_GIVEN, all filters will be applied.
ivr_detection (bool): Whether to detect if the agent is interacting with an IVR system.
Default ``False``.
conn_options (SessionConnectOptions, optional): Connection options for
stt, llm, and tts.
loop (asyncio.AbstractEventLoop, optional): Event loop to bind the
session to. Falls back to :pyfunc:`asyncio.get_event_loop()`.
user_away_timeout (float, optional): If set, set the user state as
"away" after this amount of time after user and agent are silent.
Defaults to ``15.0`` s, set to ``None`` to disable.
aec_warmup_duration (float, optional): The duration in seconds that the agent
will ignore user's audio interruptions after the agent starts speaking.
This is useful to prevent the agent from being interrupted by echo before AEC is ready.
Set to ``None`` to disable. Default ``3.0`` s.
session_close_transcript_timeout (float, optional): Seconds to wait for the
final STT transcript when closing the session (after audio is detached).
Default ``2.0`` s (independent of ``commit_user_turn``'s ``transcript_timeout``).
preemptive_generation (NotGivenOr[bool | PreemptiveGenerationOptions]): Deprecated, use turn_handling=TurnHandlingOptions(...) instead.
min_endpointing_delay (NotGivenOr[float]): Deprecated, use turn_handling=TurnHandlingOptions(...) instead.
max_endpointing_delay (NotGivenOr[float]): Deprecated, use turn_handling=TurnHandlingOptions(...) instead.
false_interruption_timeout (NotGivenOr[float | None]): Deprecated, use turn_handling=TurnHandlingOptions(...) instead.
turn_detection (NotGivenOr[TurnDetectionMode]): Deprecated, use turn_handling=TurnHandlingOptions(...) instead.
discard_audio_if_uninterruptible (NotGivenOr[bool]): Deprecated, use turn_handling=TurnHandlingOptions(...) instead.
min_interruption_duration (NotGivenOr[float]): Deprecated, use turn_handling=TurnHandlingOptions(...) instead.
min_interruption_words (NotGivenOr[int]): Deprecated, use turn_handling=TurnHandlingOptions(...) instead.
allow_interruptions (NotGivenOr[bool]): Deprecated, use turn_handling=TurnHandlingOptions(...) instead.
resume_false_interruption (NotGivenOr[bool]): Deprecated, use turn_handling=TurnHandlingOptions(...) instead.
agent_false_interruption_timeout (NotGivenOr[float | None]): Deprecated, use turn_handling=TurnHandlingOptions(...) instead.
"""
super().__init__()
self._loop = loop or asyncio.get_event_loop()
self._video_sampler = (
video_sampler
if is_given(video_sampler)
else VoiceActivityVideoSampler(speaking_fps=1.0, silent_fps=0.3)
)
turn_handling = (
_migrate_turn_handling(
min_endpointing_delay=min_endpointing_delay,
max_endpointing_delay=max_endpointing_delay,
false_interruption_timeout=false_interruption_timeout,
turn_detection=turn_detection,
discard_audio_if_uninterruptible=discard_audio_if_uninterruptible,
min_interruption_duration=min_interruption_duration,
min_interruption_words=min_interruption_words,
allow_interruptions=allow_interruptions,
resume_false_interruption=resume_false_interruption,
agent_false_interruption_timeout=agent_false_interruption_timeout,
preemptive_generation=preemptive_generation,
)
if not is_given(turn_handling)
else turn_handling
)
raw_turn_detection: TurnDetectionMode | None = turn_handling.get(
"turn_detection", inference.TurnDetector()
)
endpointing_overrides = turn_handling.get("endpointing") or EndpointingOptions()
endpointing = _resolve_endpointing(endpointing_overrides, turn_detection=raw_turn_detection)
interruption = _resolve_interruption(turn_handling.get("interruption"))
preemptive_gen = _resolve_preemptive_generation(turn_handling.get("preemptive_generation"))
user_turn_limit = _resolve_user_turn_limit(turn_handling.get("user_turn_limit"))
# This is the "global" chat_context, it holds the entire conversation history
self._chat_ctx = ChatContext.empty()
self._opts = AgentSessionOptions(
turn_handling=TurnHandlingOptions(
endpointing=endpointing,
interruption=interruption,
turn_detection=raw_turn_detection,
preemptive_generation=preemptive_gen,
user_turn_limit=user_turn_limit,
),
keyterms_options=_resolve_keyterms_options(keyterms_options or None),
endpointing_overrides=endpointing_overrides,
max_tool_steps=max_tool_steps,
user_away_timeout=user_away_timeout,
min_consecutive_speech_delay=min_consecutive_speech_delay,
tts_text_transforms=(
tts_text_transforms
if is_given(tts_text_transforms)
else DEFAULT_TTS_TEXT_TRANSFORMS
),
ivr_detection=ivr_detection,
use_tts_aligned_transcript=(
use_tts_aligned_transcript if is_given(use_tts_aligned_transcript) else None
),
aec_warmup_duration=aec_warmup_duration,
session_close_transcript_timeout=session_close_transcript_timeout,
)
# expressive mode is not publicly exposed; the pipeline stays disabled
self._expressive: bool | ExpressiveOptions = False
self._conn_options = conn_options or SessionConnectOptions()
self._started = False
if isinstance(stt, str):
stt = inference.STT.from_model_string(stt)
if isinstance(llm, str):
llm = inference.LLM.from_model_string(llm)
if isinstance(tts, str):
tts = inference.TTS.from_model_string(tts)
self._stt = stt or None
self._using_default_vad = not is_given(vad)
if not is_given(vad):
vad = inference.VAD(model="silero")
self._vad = vad or None
self._llm = llm or None
self._tts = tts or None
self._keyterm_detector = KeytermDetector(
static_keyterms=self._opts.keyterms_options["keyterms"],
options=self._opts.keyterms_options["keyterm_detection"],
)
self._turn_detection = raw_turn_detection
self._interruption_detection = interruption.get("mode", NOT_GIVEN)
self._mcp_servers = mcp_servers or None
if self._mcp_servers:
logger.warning(
"passing MCP servers to AgentSession or Agent is deprecated "
"and will be removed in a future version. Use `MCPToolset` instead."
)
self._tools = tools if is_given(tools) else []
self._async_tool_options = _resolve_async_tool_options(
tool_handling.get("async_options") if is_given(tool_handling) else None
)
# unrecoverable error counts, reset after agent speaking
self._llm_error_counts = 0
self._tts_error_counts = 0
# aec warmup: disable interruptions while AEC warms up
self._aec_warmup_remaining = aec_warmup_duration or 0.0
self._aec_warmup_timer: asyncio.TimerHandle | None = None
# configurable IO
self._input = io.AgentInput(
self._on_video_input_changed,
self._on_audio_input_changed,
audio_enabled_cb=self._on_audio_enabled_changed,
)
self._output = io.AgentOutput(
self._on_video_output_changed,
self._on_audio_output_changed,
self._on_text_output_changed,
)
self._forward_audio_atask: asyncio.Task[None] | None = None
self._forward_video_atask: asyncio.Task[None] | None = None
self._update_activity_atask: asyncio.Task[None] | None = None
self._activity_lock = asyncio.Lock()
self._lock = asyncio.Lock()
# used to keep a reference to the room io
self._room_io: room_io.RoomIO | None = None
self._recorder_io: RecorderIO | None = None
self._session_transport: SessionTransport | None = None
self._session_transport_audio_input: TcpAudioInput | None = None
self._session_transport_audio_output: TcpAudioOutput | None = None
self._session_host: SessionHost | None = None
self._agent: Agent | None = None
self._activity: AgentActivity | None = None
self._next_activity: AgentActivity | None = None
self._user_state: UserState = "listening"
self._agent_state: AgentState = "initializing"
self._user_away_timer: asyncio.TimerHandle | None = None
self._userdata: Userdata_T | None = userdata if is_given(userdata) else None
self._closing_task: asyncio.Task[None] | None = None
self._closing: bool = False
self._job_context_cb_registered: bool = False
# count of active `claim_user_turn` scopes. while > 0, `wait_for_idle`
# is held open and `user_state` is pinned to "speaking"
self._user_turn_claims: int = 0
self._user_turn_released: asyncio.Event = asyncio.Event()
self._user_turn_released.set()
# count of active `_wait_for_idle_and_hold` scopes; while > 0, non-holder
# `wait_for_idle` callers block until release. holder bypasses via contextvar.
self._idle_holds: int = 0
self._idle_released: asyncio.Event = asyncio.Event()
self._idle_released.set()
self._global_run_state: RunResult | None = None
# TODO(theomonnom): need a better way to expose early assistant metrics
self._early_assistant_metrics: MetricsReport | None = None
# trace
self._user_speaking_span: trace.Span | None = None
self._agent_speaking_span: trace.Span | None = None
self._session_span: trace.Span | None = None
self._root_span_context: otel_context.Context | None = None
self._session_ctx_token: Token[otel_context.Context] | None = None
self._recorded_events: list[AgentEvent] = []
self._recording_options: RecordingOptions = _RECORDING_ALL_OFF.copy()
self._started_at: float | None = None
self._usage_collector = ModelUsageCollector()
# ivr and AMD
self._ivr_activity: IVRActivity | None = None
self._amd: AMD | None = None
@property
def amd(self) -> AMD | None:
"""The Answering Machine Detection (AMD) instance, or ``None`` if AMD is disabled."""
return self._amd
def on(self, event: EventTypes, callback: Callable | None = None) -> Callable:
if event == "metrics_collected" and callback is not None:
logger.warning(
"metrics_collected is deprecated. "
"Use session_usage_updated for usage tracking "
"and ChatMessage.metrics for per-turn latency."
)
return super().on(event, callback)
def emit(self, event: EventTypes, arg: AgentEvent) -> None:
self._recorded_events.append(arg)
super().emit(event, arg)
@property
def userdata(self) -> Userdata_T:
if self._userdata is None:
raise ValueError("AgentSession userdata is not set")
return self._userdata
@userdata.setter
def userdata(self, value: Userdata_T) -> None:
self._userdata = value
@property
def turn_detection(self) -> TurnDetectionMode | None:
return self._turn_detection
@property
def mcp_servers(self) -> list[mcp.MCPServer] | None:
return self._mcp_servers
@property
def input(self) -> io.AgentInput:
return self._input
@property
def output(self) -> io.AgentOutput:
return self._output
@property
def options(self) -> AgentSessionOptions:
return self._opts
@property
def conn_options(self) -> SessionConnectOptions:
return self._conn_options
@property
def history(self) -> llm.ChatContext:
return self._chat_ctx
@property
def keyterms(self) -> list[str]:
"""The effective keyterms (user-defined + auto-detected) currently applied to the STT."""
return self._keyterm_detector.keyterms
@property
def current_speech(self) -> SpeechHandle | None:
return self._activity.current_speech if self._activity is not None else None
@property
def user_state(self) -> UserState:
return self._user_state
@property
def agent_state(self) -> AgentState:
return self._agent_state
@property
def current_agent(self) -> Agent:
if self._agent is None:
raise RuntimeError("VoiceAgent isn't running")
return self._agent
@property
def tools(self) -> list[llm.Tool | llm.Toolset]:
return self._tools
@property
def usage(self) -> AgentSessionUsage:
"""Returns usage summaries for this session, one per model/provider combination."""
return AgentSessionUsage(model_usage=self._usage_collector.flatten())
def run(
self,
*,
user_input: str,
input_modality: Literal["text", "audio"] = "text",
output_type: type[Run_T] | None = None,
output_options: NotGivenOr[RunOutputOptions | None] = NOT_GIVEN,
) -> RunResult[Run_T]:
if self._global_run_state is not None and not self._global_run_state.done():
raise RuntimeError("nested runs are not supported")
run_state = RunResult(
user_input=user_input,
output_type=output_type,
output_options=output_options,
session=self,
)
self._global_run_state = run_state
self.generate_reply(user_input=user_input, input_modality=input_modality)
return run_state
@overload
async def start(
self,
agent: Agent,
*,
capture_run: Literal[True],
room: NotGivenOr[rtc.Room] = NOT_GIVEN,
room_options: NotGivenOr[room_io.RoomOptions] = NOT_GIVEN,
record: bool | RecordingOptions = True,
# deprecated
room_input_options: NotGivenOr[room_io.RoomInputOptions] = NOT_GIVEN,
room_output_options: NotGivenOr[room_io.RoomOutputOptions] = NOT_GIVEN,
) -> RunResult: ...
@overload
async def start(
self,
agent: Agent,
*,
capture_run: Literal[False] = False,
room: NotGivenOr[rtc.Room] = NOT_GIVEN,
room_options: NotGivenOr[room_io.RoomOptions] = NOT_GIVEN,
record: bool | RecordingOptions = True,
# deprecated
room_input_options: NotGivenOr[room_io.RoomInputOptions] = NOT_GIVEN,
room_output_options: NotGivenOr[room_io.RoomOutputOptions] = NOT_GIVEN,
) -> None: ...
async def start(
self,
agent: Agent,
*,
capture_run: bool = False,
room: NotGivenOr[rtc.Room] = NOT_GIVEN,
room_options: NotGivenOr[room_io.RoomOptions] = NOT_GIVEN,
record: NotGivenOr[bool | RecordingOptions] = NOT_GIVEN,
# deprecated
room_input_options: NotGivenOr[room_io.RoomInputOptions] = NOT_GIVEN,
room_output_options: NotGivenOr[room_io.RoomOutputOptions] = NOT_GIVEN,
) -> RunResult | None:
"""Start the voice agent.
Create a default RoomIO if the input or output audio is not already set.
If the console flag is provided, start a ChatCLI.
Args:
capture_run: Whether to return a RunResult and capture the run result during session start.
room: The room to use for input and output
room_input_options: Options for the room input
room_output_options: Options for the room output
record: Whether to record the audio, transcripts, traces, or logs
"""
async with self._lock:
if self._started:
return None
self._started_at = time.time()
# configure observability first
record_is_given = is_given(record)
job_ctx = get_job_context(required=False)
if not is_given(record):
# defer to server-side setting for recording
record = job_ctx.job.enable_recording if job_ctx else False
self._recording_options = _resolve_recording_options(record) # type: ignore[arg-type]
if self._text_only:
self._recording_options["audio"] = False
is_primary = True
if job_ctx:
# set the primary session
if job_ctx._primary_agent_session is None or job_ctx._primary_agent_session is self:
job_ctx._primary_agent_session = self
else:
is_primary = False
if any(self._recording_options.values()):
if record_is_given:
raise RuntimeError(
"Only one `AgentSession` can be the primary at a time. "
"If you want to ignore primary designation, "
"use session.start(record=False)."
)
else:
# auto-disable recording for non-primary sessions when record is not given
self._recording_options = _resolve_recording_options(False)
job_ctx.init_recording(self._recording_options)
# Under a text simulation the simulated user interacts over text
# streams only: disable audio I/O here, and STT/TTS/VAD via
# AgentActivity (both consult _text_only).
if self._text_only:
logger.info("text simulation: disabling STT/TTS/VAD and audio I/O")
self._session_span = current_span = tracer.start_span("agent_session")
# we detach here to avoid context issues since tokens need to be detached
# in the same context as it was created
if self._session_ctx_token is not None:
otel_context.detach(self._session_ctx_token)
self._session_ctx_token = None
ctx = trace.set_span_in_context(current_span)
self._session_ctx_token = otel_context.attach(ctx)
self._recorded_events = []
self._usage_collector = ModelUsageCollector()
self._room_io = None
self._recorder_io = None
self._session_host = None
self._closing = False
self._root_span_context = otel_context.get_current()
current_span = trace.get_current_span()
current_span.set_attribute(trace_types.ATTR_AGENT_LABEL, agent.label)
self._agent = agent
self._update_agent_state("initializing")
tasks: list[asyncio.Task[None]] = []
c = cli.AgentsConsole.get_instance()
if c.enabled and not c.io_acquired:
if self.input.audio is not None or self.output.audio is not None:
logger.warning(
"agent started with the console subcommand, but input.audio/output.audio "
"is already set, overriding..."
)
c.acquire_io(loop=self._loop, session=self)
if c._tcp_transport is not None:
self._session_host = SessionHost(
c._tcp_transport,
audio_input=c._tcp_audio_input,
audio_output=c._tcp_audio_output,
)
self._session_host.register_session(self)
elif is_given(room) and not self._room_io:
room_options = room_io.RoomOptions._ensure_options(
room_options,
room_input_options=room_input_options,
room_output_options=room_output_options,
)
room_options = copy.copy(room_options) # shadow copy is enough
if self._text_only:
room_options.audio_input = False
room_options.audio_output = False
if self.input.audio is not None:
if room_options.audio_input:
logger.warning(
"RoomIO audio input is enabled but input.audio is already set, ignoring.." # noqa: E501
)
room_options.audio_input = False
if self.output.audio is not None:
if room_options.audio_output:
logger.warning(
"RoomIO audio output is enabled but output.audio is already set, ignoring.." # noqa: E501
)
room_options.audio_output = False
if self.output.transcription is not None:
if room_options.text_output:
logger.warning(
"RoomIO transcription output is enabled but output.transcription is already set, ignoring.." # noqa: E501
)
room_options.text_output = False
self._room_io = room_io.RoomIO(room=room, agent_session=self, options=room_options)
await self._room_io.start()
if is_primary:
# only the primary session can have a session host
transport = RoomSessionTransport(room)
self._session_host = SessionHost(transport)
self._session_host.register_session(self)
text_input_opts = room_options.get_text_input_options()
if text_input_opts:
self._room_io.register_text_input(text_input_opts.text_input_cb)
if job_ctx:
# these aren't relevant during eval mode, as they require job context and/or room_io
if self.input.audio and self.output.audio:
if self._recording_options["audio"] or (c.enabled and c.record):
self._recorder_io = RecorderIO(agent_session=self)
self.input.audio = self._recorder_io.record_input(self.input.audio)
self.output.audio = self._recorder_io.record_output(self.output.audio)
if (c.enabled and c.record) or not c.enabled:
task = asyncio.create_task(
self._recorder_io.start(
output_path=job_ctx.session_directory / "audio.ogg"
)
)
tasks.append(task)
if self.options.ivr_detection:
tasks.append(
asyncio.create_task(self._start_ivr_detection(), name="_ivr_activity_start")
)
current_span.set_attribute(trace_types.ATTR_ROOM_NAME, job_ctx.room.name)
current_span.set_attribute(trace_types.ATTR_JOB_ID, job_ctx.job.id)
current_span.set_attribute(trace_types.ATTR_AGENT_NAME, job_ctx.job.agent_name)
if self._room_io:
# automatically connect to the room when room io is used
tasks.append(asyncio.create_task(job_ctx.connect(), name="_job_ctx_connect"))
# session can be restarted, register the callbacks only once
if not self._job_context_cb_registered:
job_ctx.add_shutdown_callback(
lambda: self._aclose_impl(reason=CloseReason.JOB_SHUTDOWN)
)
self._job_context_cb_registered = True
run_state: RunResult | None = None
if capture_run:
if self._global_run_state is not None and not self._global_run_state.done():
raise RuntimeError("nested runs are not supported")
run_state = RunResult(output_type=None)
self._global_run_state = run_state
# it is ok to await it directly, there is no previous task to drain
tasks.append(
asyncio.create_task(self._update_activity(self._agent, wait_on_enter=False))
)
try:
await asyncio.gather(*tasks)
finally:
await utils.aio.cancel_and_wait(*tasks)
if self._session_host is not None:
await self._session_host.start()
# important: no await should be done after this!
if self.input.audio is not None:
self._forward_audio_atask = asyncio.create_task(
self._forward_audio_task(), name="_forward_audio_task"
)
if self.input.video is not None:
self._forward_video_atask = asyncio.create_task(
self._forward_video_task(), name="_forward_video_task"
)
self._started = True
self._update_agent_state("listening")
if self._room_io and self._room_io.subscribed_fut:
def on_room_io_subscribed(_: asyncio.Future[None]) -> None:
if self._user_state == "listening" and self._agent_state == "listening":
self._set_user_away_timer()
self._room_io.subscribed_fut.add_done_callback(on_room_io_subscribed)
# log used IO
def _collect_source(
inp: io.AudioInput | io.VideoInput | None,
) -> list[io.AudioInput | io.VideoInput]:
return [] if inp is None else [inp] + _collect_source(inp.source)
def _collect_chain(
out: io.TextOutput | io.VideoOutput | io.AudioOutput | None,
) -> list[io.VideoOutput | io.AudioOutput | io.TextOutput]:
return [] if out is None else [out] + _collect_chain(out.next_in_chain)
audio_input = _collect_source(self.input.audio)[::-1]
video_input = _collect_source(self.input.video)[::-1]
audio_output = _collect_chain(self.output.audio)
video_output = _collect_chain(self.output.video)
transcript_output = _collect_chain(self.output.transcription)
logger.debug(
"using audio io: %s -> `AgentSession` -> %s",
" -> ".join([f"`{out.label}`" for out in audio_input]) or "(none)",
" -> ".join([f"`{out.label}`" for out in audio_output]) or "(none)",
)
if (
self._opts.interruption["resume_false_interruption"]
and self.output.audio
and not self.output.audio.can_pause
):
logger.warning(
"resume_false_interruption is enabled but audio output does not support pause, it will be ignored",
extra={"audio_output": self.output.audio.label},
)
logger.debug(
"using transcript io: `AgentSession` -> %s",
" -> ".join([f"`{out.label}`" for out in transcript_output]) or "(none)",
)
if video_input or video_output:
logger.debug(
"using video io: %s > `AgentSession` > %s",
" -> ".join([f"`{out.label}`" for out in video_input]) or "(none)",
" -> ".join([f"`{out.label}`" for out in video_output]) or "(none)",
)
if run_state:
await run_state
return run_state
async def drain(self) -> None:
if self._activity is None:
raise RuntimeError("AgentSession isn't running")
await self._activity.drain()
@property
def room_io(self) -> room_io.RoomIO:
if not self._room_io:
raise RuntimeError(
"Cannot access room_io: the AgentSession was not started with a room."
)
return self._room_io
def _close_soon(
self,
*,
reason: CloseReason,
drain: bool = False,
error: (llm.LLMError | stt.STTError | tts.TTSError | llm.RealtimeModelError | None) = None,
) -> None:
if self._closing_task:
return
self._closing_task = asyncio.create_task(
self._aclose_impl(error=error, drain=drain, reason=reason)
)
def shutdown(self, *, drain: bool = True) -> None:
self._close_soon(error=None, drain=drain, reason=CloseReason.USER_INITIATED)
@utils.log_exceptions(logger=logger)
async def _aclose_impl(
self,
*,
reason: CloseReason,
drain: bool = False,
error: (
llm.LLMError
| stt.STTError
| tts.TTSError
| llm.RealtimeModelError
| inference.InterruptionDetectionError
| None
) = None,
) -> None:
if self._root_span_context:
# make `activity.drain` and `on_exit` under the root span
otel_context.attach(self._root_span_context)
async with self._lock:
if not self._started:
return
self._closing = True
self._cancel_user_away_timer()
self._on_aec_warmup_expired() # always clear aec warmup when closing the session
if self._amd is not None:
await self._amd.aclose()
self._amd = None
activity = self._activity
while activity and isinstance(agent_task := activity.agent, AgentTask):
# notify AgentTask to complete and wait it to resume the parent agent
agent_task.cancel()
await agent_task._wait_for_inactive()
if old_agent := agent_task._old_agent:
activity = old_agent._activity
else:
break
if activity is not None:
if not drain:
try:
# force interrupt speeches when closing the session
await activity.interrupt(force=True)
except RuntimeError:
# uninterruptible speech
pass
await activity.drain()
# wait any uninterruptible speech to finish
if activity.current_speech:
await activity.current_speech
# detach the inputs and outputs
self.input.audio = None
self.input.video = None
self.output.audio = None
self.output.transcription = None
if (
reason != CloseReason.ERROR
and (audio_recognition := activity._audio_recognition) is not None
):
# wait for the user transcript to be committed
audio_recognition._commit_user_turn(
audio_detached=True,
transcript_timeout=self._opts.session_close_transcript_timeout,
)
await activity.aclose()
self._activity = None
if self._agent_speaking_span:
self._agent_speaking_span.end()
self._agent_speaking_span = None
if self._user_speaking_span:
self._user_speaking_span.end()
self._user_speaking_span = None
if self._forward_audio_atask is not None:
await utils.aio.cancel_and_wait(self._forward_audio_atask)
if self._forward_video_atask is not None:
await utils.aio.cancel_and_wait(self._forward_video_atask)
if self._recorder_io:
await self._recorder_io.aclose()
if self._ivr_activity is not None:
await self._ivr_activity.aclose()
toolsets = [tool for tool in self._tools if isinstance(tool, llm.Toolset)]
if toolsets:
await asyncio.gather(
*(toolset.aclose() for toolset in toolsets),
return_exceptions=True,
)
if self._session_span:
self._session_span.end()
self._session_span = None
self._started = False
self.emit("close", CloseEvent(error=error, reason=reason))
self._cancel_user_away_timer()
self._user_state = "listening"
self._agent_state = "initializing"
self._llm_error_counts = 0
self._tts_error_counts = 0
self._root_span_context = None
if self._global_run_state and not self._global_run_state.done():
self._global_run_state._done_fut.set_exception(
RuntimeError(f"session closed: {error}" if error else "session closed")
)
if self._session_host:
await self._session_host.aclose()
self._session_host = None
# close room io after close event is emitted
if self._room_io:
await self._room_io.aclose()
self._room_io = None
logger.debug("session closed", extra={"reason": reason.value, "error": error})
async def aclose(self) -> None:
await self._aclose_impl(reason=CloseReason.USER_INITIATED)
def update_options(
self,
*,
endpointing_opts: NotGivenOr[EndpointingOptions] = NOT_GIVEN,
turn_detection: NotGivenOr[TurnDetectionMode | None] = NOT_GIVEN,
keyterms: NotGivenOr[list[str]] = NOT_GIVEN,
# deprecated
min_endpointing_delay: NotGivenOr[float] = NOT_GIVEN,
max_endpointing_delay: NotGivenOr[float] = NOT_GIVEN,
) -> None:
"""
Update the options for the agent session.
Args:
endpointing_opts (NotGivenOr[EndpointingOptions], optional): Endpointing options.
turn_detection (NotGivenOr[TurnDetectionMode | None], optional): Strategy for deciding
when the user has finished speaking. ``None`` reverts to automatic selection.
keyterms (NotGivenOr[list[str]], optional): Replace the user-defined keyterms applied
to the STT. Auto-detected keyterms are left untouched.
min_endpointing_delay: Deprecated, use ``endpointing_opts`` instead.
max_endpointing_delay: Deprecated, use ``endpointing_opts`` instead.
"""
if is_given(keyterms):
self._keyterm_detector.set_static_keyterms(keyterms)
if is_given(min_endpointing_delay) or is_given(max_endpointing_delay):
logger.warning(
"min_endpointing_delay and max_endpointing_delay are deprecated, "
"use endpointing_opts instead"
)
endpointing_opts = EndpointingOptions()
if is_given(min_endpointing_delay):
endpointing_opts["min_delay"] = min_endpointing_delay
if is_given(max_endpointing_delay):
endpointing_opts["max_delay"] = max_endpointing_delay
if is_given(endpointing_opts):
if (mode := endpointing_opts.get("mode")) is not None:
self._opts.endpointing["mode"] = mode
self._opts.endpointing_overrides["mode"] = mode
if (min_delay := endpointing_opts.get("min_delay")) is not None:
self._opts.endpointing["min_delay"] = min_delay
self._opts.endpointing_overrides["min_delay"] = min_delay
if (max_delay := endpointing_opts.get("max_delay")) is not None:
self._opts.endpointing["max_delay"] = max_delay
self._opts.endpointing_overrides["max_delay"] = max_delay
if (alpha := endpointing_opts.get("alpha")) is not None:
self._opts.endpointing["alpha"] = alpha
self._opts.endpointing_overrides["alpha"] = alpha
if is_given(turn_detection):
self._turn_detection = turn_detection
if self._activity is not None:
self._activity.update_options(
endpointing_opts=(
self._opts.endpointing if is_given(endpointing_opts) else NOT_GIVEN
),
turn_detection=turn_detection,
)
async def _start_ivr_detection(self, transcript: str | None = None) -> None:
"""Start IVR detection on this session.
This method injects the DTMF tool and enables loop/silence detection,
allowing the agent to navigate IVR phone trees. Safe to call after AMD resolves.
Args:
transcript (str | None, optional): The transcript to start IVR detection with.
"""
if self._ivr_activity is not None:
logger.warning("IVR detection already started, skipping")
return
self._ivr_activity = IVRActivity(self)
self._tools.extend(self._ivr_activity.tools)
await self._ivr_activity.start()
if transcript is not None:
logger.debug(
"IVR detection started with transcript",
extra={"transcript": transcript},
)
self._ivr_activity._on_user_input_transcribed(
UserInputTranscribedEvent(transcript=transcript, is_final=True)
)
def say(
self,
text: str | AsyncIterable[str],
*,
audio: NotGivenOr[AsyncIterable[rtc.AudioFrame]] = NOT_GIVEN,
allow_interruptions: NotGivenOr[bool] = NOT_GIVEN,
add_to_chat_ctx: bool = True,
) -> SpeechHandle:
if self._activity is None:
raise RuntimeError("AgentSession isn't running")
run_state = self._global_run_state
activity = self._next_activity if self._activity.scheduling_paused else self._activity
if activity is None:
raise RuntimeError("AgentSession is closing, cannot use say()")
# attach to the session span if called outside of the AgentSession
use_span: AbstractContextManager[trace.Span | None] = nullcontext()
if trace.get_current_span() is trace.INVALID_SPAN and self._session_span is not None:
use_span = trace.use_span(self._session_span, end_on_exit=False)
with use_span:
handle = activity.say(
text,
audio=audio,
allow_interruptions=allow_interruptions,
add_to_chat_ctx=add_to_chat_ctx,
)
if run_state:
run_state._watch_handle(handle)
return handle
def generate_reply(
self,
*,
user_input: NotGivenOr[str | llm.ChatMessage] = NOT_GIVEN,
instructions: NotGivenOr[str | Instructions] = NOT_GIVEN,
tool_choice: NotGivenOr[llm.ToolChoice] = NOT_GIVEN,
tools: NotGivenOr[list[str]] = NOT_GIVEN,
allow_interruptions: NotGivenOr[bool] = NOT_GIVEN,
chat_ctx: NotGivenOr[ChatContext] = NOT_GIVEN,
input_modality: Literal["text", "audio"] = "text",
) -> SpeechHandle:
"""Generate a reply for the agent to speak to the user.
Args:
user_input (NotGivenOr[str | llm.ChatMessage], optional): The user's input that may influence the reply,
such as answering a question.
instructions (NotGivenOr[str], optional): Additional instructions for generating the reply.
tool_choice (NotGivenOr[llm.ToolChoice], optional): Specifies the external tool to use when
generating the reply. If generate_reply is invoked within a function_tool, defaults to "none".
tools (NotGivenOr[list[str]], optional): List of tool IDs to make available for this response.
When set, only the specified tools can be used. Tool IDs must match registered tools on the
agent. For function tools, the ID is the function name (accessible via ``my_tool.id``).
For toolsets, the ID is the one provided at construction (accessible via ``my_toolset.id``).
allow_interruptions (NotGivenOr[bool], optional): Indicates whether the user can interrupt this speech.
chat_ctx (NotGivenOr[ChatContext], optional): The chat context to use for generating the reply.
Defaults to the chat context of the current agent if not provided.
input_modality (Literal["text", "audio"], optional): The input mode to use for generating the reply.
Returns:
SpeechHandle: A handle to the generated reply.
""" # noqa: E501
if self._activity is None:
raise RuntimeError("AgentSession isn't running")
user_message = (
llm.ChatMessage(role="user", content=[user_input])
if isinstance(user_input, str)
else user_input
)
run_state = self._global_run_state
activity = self._next_activity if self._activity.scheduling_paused else self._activity
if activity is None:
raise RuntimeError("AgentSession is closing, cannot use generate_reply()")
# attach to the session span if called outside of the AgentSession
use_span: AbstractContextManager[trace.Span | None] = nullcontext()
if trace.get_current_span() is trace.INVALID_SPAN and self._session_span is not None:
use_span = trace.use_span(self._session_span, end_on_exit=False)
with use_span:
handle = activity._generate_reply(
user_message=user_message if user_message else None,
instructions=instructions,
tool_choice=tool_choice,
tools=tools,
allow_interruptions=allow_interruptions,
chat_ctx=chat_ctx,
input_details=InputDetails(modality=input_modality),
)
if run_state:
run_state._watch_handle(handle)
return handle
def interrupt(self, *, force: bool = False) -> asyncio.Future[None]:
"""Interrupt the current speech generation.
Returns:
An asyncio.Future that completes when the interruption is fully processed
and chat context has been updated.
"""
if self._activity is None:
raise RuntimeError("AgentSession isn't running")
return self._activity.interrupt(force=force)
@asynccontextmanager
async def _claim_user_turn(self) -> AsyncIterator[None]:
"""Declare a programmatic user-driven turn.
Pins ``user_state`` to ``"speaking"`` and holds ``wait_for_idle``
open until release. On release, ``user_state`` is re-derived from the
audio path. Reentrant and session-scoped (survives handoff).
Use in custom ``text_input_cb`` or any flow that drives a user turn
across awaits.
"""
first = self._user_turn_claims == 0
self._user_turn_claims += 1
if first:
self._user_turn_released.clear()
self._update_user_state("speaking", last_speaking_time=time.time())
try:
yield
finally:
self._user_turn_claims -= 1
if self._user_turn_claims == 0:
self._user_turn_released.set()
activity = self._activity
speaking = activity is not None and not activity._user_silence_event.is_set()
self._update_user_state("speaking" if speaking else "listening")
def clear_user_turn(self) -> None:
# clear the transcription or input audio buffer of the user turn
if self._activity is None:
raise RuntimeError("AgentSession isn't running")
self._activity.clear_user_turn()
def commit_user_turn(
self,
*,
transcript_timeout: float = 2.0,
stt_flush_duration: float = 2.0,
skip_reply: bool = False,
) -> asyncio.Future[str]:
"""Commit the user turn and generate a reply.
Returns a future that resolves with the user's audio transcript once STT
is complete and end-of-turn detection has been triggered.
Args:
transcript_timeout (float, optional): The timeout for the final transcript
to be received after committing the user turn.
Default ``2.0`` s. Increase this value if the STT is slow to respond.
stt_flush_duration (float, optional): The duration of the silence to be appended to the STT
to flush the buffer and generate the final transcript.
Default ``2.0`` s.
skip_reply (bool, optional): Whether to skip the reply generation after committing the user turn.
Returns:
asyncio.Future[str]: A future that resolves with the audio transcript.
Raises:
RuntimeError: If the AgentSession isn't running.
"""
if self._activity is None:
raise RuntimeError("AgentSession isn't running")
return self._activity.commit_user_turn(
transcript_timeout=transcript_timeout,
stt_flush_duration=stt_flush_duration,
skip_reply=skip_reply,
)
def update_agent(self, agent: Agent) -> None:
self._agent = agent
if self._started:
# immediately block the old activity from accepting new user turns
# during the transition window (before drain() formally pauses scheduling)
if self._activity is not None:
self._activity._new_turns_blocked = True
self._update_activity_atask = task = asyncio.create_task(
self._update_activity_task(self._update_activity_atask, self._agent),
name="_update_activity_task",
)
run_state = self._global_run_state
if run_state:
# don't mark the RunResult as done, if there is currently an agent transition happening. # noqa: E501
# (used to make sure we're correctly adding the AgentHandoffResult before completion) # noqa: E501
run_state._watch_handle(task)
async def wait_for_idle(self) -> AgentActivity:
"""Wait until the current activity is idle and return it. Re-targets on handoff.
Raises ``ActivityClosedError`` if the session is closing,
or ``RuntimeError`` if no activity has been started.
"""
from .agent_activity import ActivityClosedError
while True:
if self._closing_task is not None:
raise ActivityClosedError("session is closing")
activity = self._activity
if activity is None:
raise RuntimeError("AgentSession has no active AgentActivity")
try:
await activity.wait_for_idle()
return activity
except ActivityClosedError:
# handoff in flight — re-target to whatever's current now
if self._activity is activity:
raise
continue
@asynccontextmanager
async def _wait_for_idle_and_hold(self) -> AsyncIterator[AgentActivity]:
"""Wait for idle, then block other ``wait_for_idle`` callers until exit."""
from .agent_activity import _IdleHoldContextVar
activity = await self.wait_for_idle()
self._idle_holds += 1
self._idle_released.clear()
token = _IdleHoldContextVar.set(True)
try:
yield activity
finally:
_IdleHoldContextVar.reset(token)
self._idle_holds -= 1
if self._idle_holds == 0:
self._idle_released.set()
async def _update_activity(
self,
agent: Agent,
*,
previous_activity: Literal["close", "pause"] = "close",
new_activity: Literal["start", "resume"] = "start",
blocked_tasks: list[asyncio.Task] | None = None,
wait_on_enter: bool = True,
) -> None:
async with self._activity_lock:
if self._closing and new_activity == "start":
# checked again after the drain below: closing may start while it's in flight
logger.warning(
f"session is closing, skipping start activity of agent {agent.id}",
)
return
# _update_activity is called directly sometimes, update for redundancy
self._agent = agent
if new_activity == "start":
previous_agent = self._activity.agent if self._activity else None
if agent._activity is not None and (
# allow updating the same agent that is running
agent is not previous_agent or previous_activity != "close"
):
raise RuntimeError("cannot start agent: an activity is already running")
self._next_activity = AgentActivity(agent, self)
elif new_activity == "resume":
if agent._activity is None:
raise RuntimeError("cannot resume agent: no existing active activity to resume")
self._next_activity = agent._activity
if self._root_span_context is not None:
# restore the root span context so on_exit, on_enter, and future turns
# are direct children of the root span, not nested under a tool call.
otel_context.attach(self._root_span_context)
reuse_resources: _ReusableResources | None = None
try:
previous_activity_v = self._activity
if (activity := self._activity) is not None:
if previous_activity == "close":
reuse_resources = await activity.drain(new_activity=self._next_activity)
await activity.aclose()
elif previous_activity == "pause":
reuse_resources = await activity.pause(
blocked_tasks=blocked_tasks or [],
new_activity=self._next_activity,
)
if self._closing and new_activity == "start":
# disallow starting a new activity when the session is closing
logger.warning(
f"session is closing, skipping {new_activity} activity of {self._next_activity.agent.id}",
)
if reuse_resources is not None:
await reuse_resources.cleanup()
reuse_resources = None
self._next_activity = None
self._activity = None
return
self._activity = self._next_activity
self._next_activity = None
run_state = self._global_run_state
handoff_item = AgentHandoff(
old_agent_id=(previous_activity_v.agent.id if previous_activity_v else None),
new_agent_id=self._activity.agent.id,
)
if run_state:
run_state._agent_handoff(
item=handoff_item,
old_agent=(previous_activity_v.agent if previous_activity_v else None),
new_agent=self._activity.agent,
)
self._chat_ctx.insert(handoff_item)
self.emit(
"conversation_item_added",
ConversationItemAddedEvent(item=handoff_item),
)
if new_activity == "start":
await self._activity.start(reuse_resources=reuse_resources)
elif new_activity == "resume":
await self._activity.resume(reuse_resources=reuse_resources)
except BaseException:
if reuse_resources is not None:
await reuse_resources.cleanup()
raise
# move it outside the lock to allow calling _update_activity in on_enter of a new agent
if wait_on_enter:
assert self._activity._on_enter_task is not None
await asyncio.shield(self._activity._on_enter_task)
@utils.log_exceptions(logger=logger)
async def _update_activity_task(
self, old_task: asyncio.Task[None] | None, agent: Agent
) -> None:
if old_task is not None:
await old_task
await self._update_activity(agent)
def _emit_debug_message(self, payload: dict[str, Any]) -> None:
""":meta private: internal — emit a debug/trace payload to the debugger/recorder."""
st = Struct()
ParseDict(payload, st)
# super().emit bypasses AgentSession.emit's narrowed AgentEvent type;
# debug messages ride the proto, not the Pydantic event union.
super().emit("debug_message", agent_pb.DebugMessage(payload=st))
def _on_error(
self, error: llm.LLMError | stt.STTError | tts.TTSError | llm.RealtimeModelError
) -> None:
if self._closing_task or error.recoverable:
return
if error.type == "llm_error":
self._llm_error_counts += 1
if self._llm_error_counts <= self.conn_options.max_unrecoverable_errors:
return
elif error.type == "tts_error":
self._tts_error_counts += 1
if self._tts_error_counts <= self.conn_options.max_unrecoverable_errors:
return
if isinstance(error.error, APIError):
logger.error(f"AgentSession is closing due to unrecoverable error: {error.error}")
else:
logger.error(
"AgentSession is closing due to unrecoverable error",
exc_info=error.error,
)
def on_close_done(_: asyncio.Task[None]) -> None:
self._closing_task = None
self._closing_task = asyncio.create_task(
self._aclose_impl(error=error, reason=CloseReason.ERROR)
)
self._closing_task.add_done_callback(on_close_done)
@utils.log_exceptions(logger=logger)
async def _forward_audio_task(self) -> None:
audio_input = self.input.audio
if audio_input is None:
return
async for frame in audio_input:
if self._activity is not None:
self._activity.push_audio(frame)
@utils.log_exceptions(logger=logger)
async def _forward_video_task(self) -> None:
video_input = self.input.video
if video_input is None:
return
async for frame in video_input:
if self._activity is not None:
if self._video_sampler is not None and not self._video_sampler(frame, self):
continue # ignore this frame
self._activity.push_video(frame)
def _set_user_away_timer(self) -> None:
self._cancel_user_away_timer()
if self._opts.user_away_timeout is None:
return
if (
(room_io := self._room_io)
and room_io.subscribed_fut
and not room_io.subscribed_fut.done()
):
# skip the timer before user join the room
return
self._user_away_timer = self._loop.call_later(
self._opts.user_away_timeout, self._update_user_state, "away"
)
def _cancel_user_away_timer(self) -> None:
if self._user_away_timer is not None:
self._user_away_timer.cancel()
self._user_away_timer = None
def _on_aec_warmup_expired(self) -> None:
if self._aec_warmup_remaining > 0 and not self._closing:
logger.debug("aec warmup expired, re-enabling interruptions")
self._aec_warmup_remaining = 0.0
if self._aec_warmup_timer is not None:
self._aec_warmup_timer.cancel()
self._aec_warmup_timer = None
def _update_agent_state(
self,
state: AgentState,
*,
otel_context: otel_context.Context | None = None,
start_time: float | None = None,
) -> None:
if self._agent_state == state:
return
start_time_ns = int(start_time * 1_000_000_000) if start_time else None
if state == "speaking":
self._llm_error_counts = 0
self._tts_error_counts = 0
if self._agent_speaking_span is None:
self._agent_speaking_span = tracer.start_span(
"agent_speaking", context=otel_context, start_time=start_time_ns
)
if self._room_io:
_set_participant_attributes(
self._agent_speaking_span, self._room_io.room.local_participant
)
# self._agent_speaking_span.set_attribute(trace_types.ATTR_START_TIME, time.time())
elif self._agent_speaking_span is not None:
# self._agent_speaking_span.set_attribute(trace_types.ATTR_END_TIME, time.time())
self._agent_speaking_span.end()
self._agent_speaking_span = None
# aec warmup: start a one-shot wall-clock timer on the first speaking turn
if (
state == "speaking"
and self._aec_warmup_remaining > 0
and self._aec_warmup_timer is None
and self._output.audio_enabled
and self._output.audio is not None
):
self._aec_warmup_timer = self._loop.call_later(
self._aec_warmup_remaining, self._on_aec_warmup_expired
)
logger.debug(
"aec warmup active, disabling interruptions for %.2fs",
self._aec_warmup_remaining,
)
if state == "listening" and self._user_state == "listening":
self._set_user_away_timer()
else:
self._cancel_user_away_timer()
old_state = self._agent_state
self._agent_state = state
self.emit(
"agent_state_changed",
AgentStateChangedEvent(old_state=old_state, new_state=state),
)
def _update_user_state(
self, state: UserState, *, last_speaking_time: float | None = None
) -> None:
# pinned to "speaking" while a `claim_user_turn` is active; voice
# transitions are recoverable from `_user_silence_event` on release
if self._user_turn_claims > 0 and state != "speaking":
return
if self._user_state == state:
return
last_speaking_time_ns = (
int(last_speaking_time * 1_000_000_000) if last_speaking_time else None
)
if state == "speaking" and self._user_speaking_span is None:
self._user_speaking_span = tracer.start_span(
"user_speaking", start_time=last_speaking_time_ns
)
if self._room_io and self._room_io.linked_participant:
_set_participant_attributes(
self._user_speaking_span, self._room_io.linked_participant
)
# self._user_speaking_span.set_attribute(trace_types.ATTR_START_TIME, time.time())
elif self._user_speaking_span is not None:
# end_time = last_speaking_time or time.time()
# self._user_speaking_span.set_attribute(trace_types.ATTR_END_TIME, end_time)
self._user_speaking_span.end(end_time=last_speaking_time_ns)
self._user_speaking_span = None
if state == "listening" and self._agent_state == "listening":
self._set_user_away_timer()
else:
self._cancel_user_away_timer()
old_state = self._user_state
self._user_state = state
self.emit(
"user_state_changed",
UserStateChangedEvent(
old_state=old_state,
new_state=state,
created_at=last_speaking_time or time.time(),
),
)
def _on_audio_enabled_changed(self, enabled: bool) -> None:
"""End user speaking state when audio is disabled by default."""
if not enabled and self._user_state == "speaking":
if self._activity is not None:
self._activity.on_end_of_speech(None)
else:
self._update_user_state("listening")
def _user_input_transcribed(self, ev: UserInputTranscribedEvent) -> None:
if self.user_state == "away" and ev.is_final:
# reset user state from away to listening in case VAD has a miss detection
self._update_user_state("listening")
self.emit("user_input_transcribed", ev)
def _conversation_item_added(self, message: llm.ChatMessage) -> None:
self._chat_ctx.insert(message)
if text := message.raw_text_content:
logger.debug(
"conversation_item_added",
extra={"role": message.role, "text": text},
)
self.emit("conversation_item_added", ConversationItemAddedEvent(item=message))
def _tool_items_added(self, items: Sequence[llm.FunctionCall | llm.FunctionCallOutput]) -> None:
self._chat_ctx.insert(items)
def _config_update_added(self, item: llm.AgentConfigUpdate) -> None:
self._chat_ctx.insert(item)
# move them to the end to avoid shadowing the same named modules for mypy
@property
def _text_only(self) -> bool:
"""True when running under a text simulation: the session uses no audio
I/O and no audio models (STT/TTS/VAD)."""
from ..job import get_job_context
job_ctx = get_job_context(required=False)
if job_ctx is None or (sim_ctx := job_ctx.simulation_context()) is None:
return False
from ..simulation import SimulationMode
return sim_ctx.simulation_mode == SimulationMode.SIMULATION_MODE_TEXT
@property
def stt(self) -> stt.STT | None:
return self._stt
@property
def llm(self) -> llm.LLM | llm.RealtimeModel | None:
return self._llm
@property
def tts(self) -> tts.TTS | None:
return self._tts
@property
def vad(self) -> vad.VAD | None:
return self._vad
@property
def interruption_detection(self) -> NotGivenOr[Literal["adaptive", "vad"]]:
return self._interruption_detection
# -- User changed input/output streams/sinks --
def _on_video_input_changed(self) -> None:
if not self._started:
return
if self._forward_video_atask is not None:
self._forward_video_atask.cancel()
self._forward_video_atask = asyncio.create_task(
self._forward_video_task(), name="_forward_video_task"
)
def _on_audio_input_changed(self) -> None:
if not self._started:
return
if self._forward_audio_atask is not None:
self._forward_audio_atask.cancel()
self._forward_audio_atask = asyncio.create_task(
self._forward_audio_task(), name="_forward_audio_task"
)
def _on_video_output_changed(self) -> None:
pass
def _on_audio_output_changed(self) -> None:
if (
self._started
and self._opts.interruption["resume_false_interruption"]
and (audio_output := self.output.audio)
and not audio_output.can_pause
):
logger.warning(
"resume_false_interruption is enabled, but the audio output does not support pause, ignored",
extra={"audio_output": audio_output.label},
)
def _on_text_output_changed(self) -> None:
pass
# ---
async def __aenter__(self) -> AgentSession:
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
await self.aclose()