项目文件夹

文件
2026-07-13 13:39:38 +08:00

388 行
14 KiB
Python

from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Literal, Protocol, runtime_checkable
from typing_extensions import TypedDict
from livekit import rtc
from ..language import LanguageCode
from ..llm import ChatContext
from ..types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectOptions,
NotGivenOr,
)
from ..utils import is_given
@dataclass
class TurnDetectionEvent:
type: Literal["eot_prediction"]
end_of_turn_probability: float
last_speaking_time: float
detection_delay: float | None = None
"""Latest input audio creation time -> prediction receive time."""
inference_duration: float | None = None
"""Server-side model inference time."""
backchannel_probability: float | None = None
"""How appropriate it is for the agent to backchannel at this pause.
``None`` when the detector does not produce one (e.g. the local mini model)."""
class _TurnDetector(Protocol):
@property
def model(self) -> str:
return "unknown"
@property
def provider(self) -> str:
return "unknown"
# TODO: Move those two functions to EOU ctor (capabilities dataclass)
async def unlikely_threshold(self, language: LanguageCode | None) -> float | None: ...
async def supports_language(self, language: LanguageCode | None) -> bool: ...
async def predict_end_of_turn(
self, chat_ctx: ChatContext, *, timeout: float | None = None
) -> float: ...
@runtime_checkable
class _StreamingTurnDetectorStream(Protocol):
"""I/O stream for the streaming turn detector."""
@property
def model(self) -> str: ...
@property
def provider(self) -> str: ...
@property
def is_fallback(self) -> bool: ...
@property
def prediction_timeout(self) -> float: ...
async def unlikely_threshold(self, language: LanguageCode | None) -> float | None: ...
async def backchannel_threshold(self, language: LanguageCode | None) -> float | None: ...
async def supports_language(self, language: LanguageCode | None) -> bool: ...
def predict(self) -> asyncio.Future[TurnDetectionEvent]: ...
def cancel_inference(self, *, timed_out: bool = False) -> None: ...
def flush(self, reason: str | None = None) -> None: ...
def push_audio(self, frame: rtc.AudioFrame) -> None: ...
def end_input(self) -> None: ...
async def aclose(self) -> None: ...
@runtime_checkable
class _StreamingTurnDetector(Protocol):
"""Turn detector that processes streaming data."""
@property
def model(self) -> str: ...
@property
def provider(self) -> str: ...
def stream(
self,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> _StreamingTurnDetectorStream: ...
TurnDetectionMode = (
Literal["stt", "vad", "realtime_llm", "manual"] | _TurnDetector | _StreamingTurnDetector
)
"""
The mode of turn detection to use.
- "stt": use speech-to-text result to detect the end of the user's turn
- "vad": use VAD to detect the start and end of the user's turn
- "realtime_llm": use server-side turn detection provided by the realtime LLM
- "manual": manually manage the turn detection
- _TurnDetector: use the default mode with the provided turn detector
(default) If not provided, automatically choose the best mode based on
available models (realtime_llm -> vad -> stt -> manual)
If the needed model (VAD, STT, or RealtimeModel) is not provided, fallback to the default mode.
"""
class EndpointingOptions(TypedDict, total=False):
"""Configuration for endpointing.
All keys are optional. Missing keys inherit from the session default
(at the ``Agent`` level) or use the documented defaults
(at the ``AgentSession`` level).
"""
mode: Literal["fixed", "dynamic"]
"""Endpointing mode. ``"fixed"`` for fixed delay, ``"dynamic"`` for dynamic delay. Defaults to ``"fixed"``."""
min_delay: float
"""Minimum time (s) since last detected speech before declaring the
user's turn complete. Defaults to ``0.5``."""
max_delay: float
"""Maximum time (s) the agent waits before terminating the turn.
Defaults to ``3.0``."""
alpha: float
"""Exponential moving average coefficient for dynamic endpointing.
The higher the value, the more weight is given to the history.
Defaults to ``0.9``. Only applies when mode is ``dynamic``."""
_ENDPOINTING_DEFAULTS: EndpointingOptions = {
"mode": "fixed",
"min_delay": 0.5,
"max_delay": 3.0,
"alpha": 0.9,
}
_STREAMING_ENDPOINTING_DEFAULTS: EndpointingOptions = {
"mode": "fixed",
"min_delay": 0.3,
"max_delay": 2.5,
"alpha": 0.9,
}
class InterruptionOptions(TypedDict, total=False):
"""Configuration for interruption handling.
All keys are optional. Missing keys inherit from the session default
(at the ``Agent`` level) or use the documented defaults
(at the ``AgentSession`` level).
``mode`` absent means the session picks the best available strategy.
"""
enabled: bool
"""Whether interruptions are enabled. Defaults to ``True``."""
mode: Literal["adaptive", "vad"]
"""Interruption handling strategy. ``"adaptive"`` for ML-based
detection, ``"vad"`` for simple voice-activity detection.
Absent means auto-detect."""
discard_audio_if_uninterruptible: bool
"""Drop buffered audio while the agent speaks and cannot be
interrupted. Defaults to ``True``."""
min_duration: float
"""Minimum speech length (s) to register as an interruption.
Defaults to ``0.5``."""
min_words: int
"""Minimum word count to consider an interruption (STT only).
Defaults to ``0``."""
resume_false_interruption: bool
"""Resume the agent's speech after a false interruption.
Defaults to ``True``."""
false_interruption_timeout: float | None
"""Seconds of silence after an interruption before it is
classified as false. ``None`` disables. Defaults to ``2.0``."""
backchannel_boundary: float | tuple[float, float] | None
"""Seconds near the start/end of each agent turn during which overlapping
speech classified as a backchannel by the adaptive detector is suppressed
(events flagged as interruptions still pass through). Use a tuple to apply
different values for start and end separately. ``None`` disables. Defaults
to ``(1.0, 1.0)``. End value accounts for STT transcript timestamp
inaccuracy."""
_INTERRUPTION_DEFAULTS: InterruptionOptions = {
"enabled": True,
"discard_audio_if_uninterruptible": True,
"min_duration": 0.5,
"min_words": 0,
"resume_false_interruption": True,
"false_interruption_timeout": 2.0,
"backchannel_boundary": (1.0, 1.0),
}
class PreemptiveGenerationOptions(TypedDict, total=False):
"""Configuration for preemptive generation."""
enabled: bool
"""Whether preemptive generation is enabled. Defaults to ``True``."""
preemptive_tts: bool
"""Whether to also run TTS preemptively before the turn is confirmed.
When ``False`` (default), only LLM runs preemptively; TTS starts once the
turn is confirmed and the speech is scheduled."""
max_speech_duration: float
"""Maximum user speech duration (s) for which preemptive generation
is attempted. Beyond this threshold, preemptive generation is skipped
since long utterances are more likely to change and users may expect
slower responses. Defaults to ``10.0``."""
max_retries: int
"""Maximum number of preemptive generation attempts per user turn.
The counter resets when the turn completes. Defaults to ``3``."""
_PREEMPTIVE_GENERATION_DEFAULTS: PreemptiveGenerationOptions = {
"enabled": True,
"preemptive_tts": False,
"max_speech_duration": 10.0,
"max_retries": 3,
}
class UserTurnLimitOptions(TypedDict, total=False):
"""Configuration for detecting when a user has been speaking too long
without the agent successfully responding.
The framework tracks accumulated word count and wall-clock duration
across consecutive user turns. Counters only reset when the agent
transitions to ``speaking`` state (i.e., produces audio output).
Both thresholds default to ``None`` (disabled). Set at least one to
enable the feature.
"""
max_words: int | None
"""Maximum accumulated word count before triggering. Uses the
framework's WordTokenizer for counting. ``None`` disables word-based
limiting. Defaults to ``None``."""
max_duration: float | None
"""Maximum wall-clock duration (seconds) since the user first started
speaking in the current accumulation window. ``None`` disables
duration-based limiting. Defaults to ``None``."""
_USER_TURN_LIMIT_DEFAULTS: UserTurnLimitOptions = {
"max_words": None,
"max_duration": None,
}
class TurnHandlingOptions(TypedDict, total=False):
"""Configuration for the turn handling system.
Can be passed as a plain dict::
AgentSession(
turn_handling={
"endpointing": {"min_delay": 0.3},
"interruption": {"enabled": False},
"preemptive_generation": {"preemptive_tts": True},
},
)
All keys are optional and default to sensible values.
"""
turn_detection: TurnDetectionMode | None
"""Strategy for deciding when the user has finished speaking.
Absent means the session auto-selects."""
endpointing: EndpointingOptions
"""Endpointing configuration. Defaults to ``{"min_delay": 0.5, "max_delay": 3.0}``."""
interruption: InterruptionOptions
"""Interruption handling configuration. Use ``{"enabled": False}`` to disable."""
preemptive_generation: PreemptiveGenerationOptions
"""Preemptive generation configuration. Use ``{"enabled": False}`` to disable."""
user_turn_limit: UserTurnLimitOptions
"""User turn limit configuration. Use ``{"max_words": 50}`` to enable."""
def _resolve_preemptive_generation(
config: PreemptiveGenerationOptions | None = None,
) -> PreemptiveGenerationOptions:
"""Fill in defaults for missing keys."""
if config is None:
return PreemptiveGenerationOptions(**_PREEMPTIVE_GENERATION_DEFAULTS)
return PreemptiveGenerationOptions(**{**_PREEMPTIVE_GENERATION_DEFAULTS, **config})
def _resolve_endpointing(
config: EndpointingOptions | None = None,
*,
turn_detection: TurnDetectionMode | None = None,
) -> EndpointingOptions:
"""Fill in defaults for missing keys.
When ``turn_detection`` is a streaming turn detector, keys the caller did
not provide fall back to the tighter streaming defaults instead of the
legacy ones."""
base = (
_STREAMING_ENDPOINTING_DEFAULTS
if isinstance(turn_detection, _StreamingTurnDetector)
else _ENDPOINTING_DEFAULTS
)
if config is None:
return EndpointingOptions(**base)
return EndpointingOptions(**{**base, **config})
def _resolve_interruption(
config: InterruptionOptions | None = None,
) -> InterruptionOptions:
"""Fill in defaults for missing keys (``mode`` stays absent if not provided)."""
if config is None:
return InterruptionOptions(**_INTERRUPTION_DEFAULTS)
return InterruptionOptions(**{**_INTERRUPTION_DEFAULTS, **config})
def _resolve_user_turn_limit(
config: UserTurnLimitOptions | None = None,
) -> UserTurnLimitOptions:
"""Fill in defaults for missing keys."""
if config is None:
return UserTurnLimitOptions(**_USER_TURN_LIMIT_DEFAULTS)
return UserTurnLimitOptions(**{**_USER_TURN_LIMIT_DEFAULTS, **config})
def _migrate_turn_handling(
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 | None] = 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,
preemptive_generation: NotGivenOr[bool] = NOT_GIVEN,
) -> TurnHandlingOptions:
"""Build a TurnHandlingOptions from deprecated keyword arguments."""
if is_given(agent_false_interruption_timeout):
false_interruption_timeout = agent_false_interruption_timeout
result: TurnHandlingOptions = {}
# endpointing — only include keys that were explicitly provided
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 endpointing_opts:
result["endpointing"] = endpointing_opts
# interruption — only include keys that were explicitly provided
interruption: InterruptionOptions = {}
if allow_interruptions is False:
interruption["enabled"] = False
if is_given(discard_audio_if_uninterruptible):
interruption["discard_audio_if_uninterruptible"] = discard_audio_if_uninterruptible
if is_given(min_interruption_duration):
interruption["min_duration"] = min_interruption_duration
if is_given(min_interruption_words):
interruption["min_words"] = min_interruption_words
if is_given(false_interruption_timeout):
interruption["false_interruption_timeout"] = false_interruption_timeout
if is_given(resume_false_interruption):
interruption["resume_false_interruption"] = resume_false_interruption
if interruption:
result["interruption"] = interruption
if is_given(turn_detection):
result["turn_detection"] = turn_detection
if is_given(preemptive_generation):
result["preemptive_generation"] = {"enabled": preemptive_generation}
return result