Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
59f1a58
wip
longcw Jun 9, 2026
ebbe1b5
update fnc tool
longcw Jun 10, 2026
8234757
clean
longcw Jun 10, 2026
73eb033
refactor to KeytermDetector
longcw Jun 10, 2026
a6096af
fix test
longcw Jun 10, 2026
12672e0
improve detection prompt, expose user keyterms to the detector
longcw Jun 10, 2026
65cd307
bind STT in start() even when detection is disabled
longcw Jun 10, 2026
2ca544a
Merge remote-tracking branch 'origin/main' into longc/auto-stt-keyterms
longcw Jun 11, 2026
ffd105c
make _update_keyterms private
longcw Jun 11, 2026
e7b7c92
fix(google): preserve user-tuned keywords on keyterms update
longcw Jun 11, 2026
cb0b308
Merge remote-tracking branch 'origin/main' into longc/auto-stt-keyterms
longcw Jun 15, 2026
7780b6f
feat(voice): default keyterm detection to its own LLM + shorter prompt
longcw Jun 19, 2026
603417b
feat(stt): add stt_context_options with native chat-context carryover
longcw Jun 22, 2026
ef23cc3
revert example
longcw Jun 22, 2026
d734805
Merge remote-tracking branch 'origin/main' into longc/auto-stt-keyterms
longcw Jun 22, 2026
d140b9e
refactor(stt): merge plugin and session keyterms instead of replacing
longcw Jun 23, 2026
abf645f
fix(stt): wrap bare-string keyterm before merging session keyterms
longcw Jun 23, 2026
7cf1d68
fix(google): don't claim keyterms support when adaptation is set
longcw Jun 23, 2026
86f4f81
feat(stt): defer session keyterm reconnect to end of speech
longcw Jun 23, 2026
2e82b55
fix(deepgram): defer keyterm reconnect at end of speech in v2 STT
longcw Jun 23, 2026
0a93cb8
fix(google): re-merge session keyterms on user keywords update
longcw Jun 23, 2026
8ba5370
fix(stt): re-merge session keyterms on user keyterm update
longcw Jun 23, 2026
04751d1
fix(stt): expose keyterm detector LLM metrics
longcw Jun 24, 2026
b442136
timeout keyterm detection pass so a stuck call can't stall detection
longcw Jun 29, 2026
a4a0012
expose detection timeout as keyterm_detection option
longcw Jun 29, 2026
5328bbd
forward merged keywords to active google stt streams on user update
longcw Jun 29, 2026
a7acb41
rename stt_context_options to keyterms_options; drive chat context fr…
longcw Jun 29, 2026
1f6f803
Merge remote-tracking branch 'origin/main' into longc/auto-stt-keyterms
longcw Jun 30, 2026
c744533
flush deferred keyterms on utterance-end and drop stale pending values
longcw Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion examples/voice_agents/basic_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def __init__(self) -> None:
"with that in mind keep your responses concise and to the point."
"do not use emojis, asterisks, markdown, or other special characters in your responses."
"You are curious and friendly, and have a sense of humor."
"you will speak english to the user",
"You will speak english to the user over voice.",
tools=[EndCallTool()],
)

Expand Down Expand Up @@ -116,10 +116,17 @@ async def entrypoint(ctx: JobContext) -> None:
"filter_markdown",
text_transforms.replace({"LiveKit": "<<ˈ|l|aɪ|v>> <<ˈ|k|ɪ|t>>"}),
],
# automatically detect keyterms and apply them to the STT per user turn
keyterm_options={
"terms": ["LiveKit"],
"detection": {"enabled": True, "turn_interval": 1},
},
Comment thread
longcw marked this conversation as resolved.
)

@session.on("metrics_collected")
def _on_metrics_collected(ev: MetricsCollectedEvent) -> None:
if ev.metrics.type == "stt_metrics":
return
Comment thread
longcw marked this conversation as resolved.
metrics.log_metrics(ev.metrics)

async def log_usage():
Expand Down
3 changes: 3 additions & 0 deletions livekit-agents/livekit/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@
AMDPredictionEvent,
)
from .voice.background_audio import AudioConfig, BackgroundAudioPlayer, BuiltinAudioClip, PlayHandle
from .voice.keyterms import KeytermDetectionOptions, KeytermOptions
from .voice.room_io import RoomInputOptions, RoomIO, RoomOutputOptions
from .voice.run_result import (
AgentHandoffEvent,
Expand Down Expand Up @@ -256,6 +257,8 @@ def __getattr__(name: str) -> typing.Any:
"InterruptionOptions",
"PreemptiveGenerationOptions",
"UserTurnLimitOptions",
"KeytermOptions",
"KeytermDetectionOptions",
"UserTurnExceededEvent",
]

Expand Down
30 changes: 30 additions & 0 deletions livekit-agents/livekit/agents/inference/stt.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,24 @@ def _diarization_enabled(extra_kwargs: dict[str, Any] | None) -> bool:
return False


def _keyterms_extra_for_model(model: NotGivenOr[str], keyterms: list[str]) -> dict[str, Any] | None:
"""Map a provider-agnostic keyterms list onto the active provider's extra_kwargs key.

Returns None when the model does not support keyterm prompting. Called with an empty
list, it doubles as a capability check (non-None ⇒ supported). Keep every provider's
keyterm key here so capability inference and _update_keyterms can't diverge.
"""
if not (is_given(model) and isinstance(model, str)):
return None
if model.startswith("deepgram/"):
return {"keyterm": list(keyterms)}
if model.startswith("assemblyai/"):
return {"keyterms_prompt": list(keyterms)}
if model.startswith("speechmatics/"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it make sense to add STTCapabilities.keyterms to the speechmatics plugin?

return {"additional_vocab": [{"content": term} for term in keyterms]}
return None


STTLanguages = Literal["multi", "en", "de", "es", "fr", "ja", "pt", "zh", "hi"]


Expand Down Expand Up @@ -517,6 +535,7 @@ def __init__(
diarization=diarization_enabled,
aligned_transcript="word",
offline_recognize=False,
keyterms=_keyterms_extra_for_model(model, []) is not None,
),
)

Expand Down Expand Up @@ -634,6 +653,10 @@ def update_options(

self._opts.model = model
self._vad = _resolve_vad_for_model(model, self._vad)
self._capabilities = replace(
self._capabilities,
keyterms=_keyterms_extra_for_model(self._opts.model, []) is not None,
)
if is_given(language):
self._opts.language = LanguageCode(language)
if is_given(extra):
Expand All @@ -646,6 +669,13 @@ def update_options(
for stream in self._streams:
stream.update_options(model=model, language=language, extra=extra)

def _update_keyterms(self, keyterms: list[str]) -> None:
extra = _keyterms_extra_for_model(self._opts.model, keyterms)
if extra is None:
super()._update_keyterms(keyterms) # warn-and-skip for unsupported models
return
self.update_options(extra=extra)
Comment thread
longcw marked this conversation as resolved.
Outdated

def _sanitize_options(
self, *, language: NotGivenOr[STTLanguages | str] = NOT_GIVEN
) -> STTOptions:
Expand Down
6 changes: 6 additions & 0 deletions livekit-agents/livekit/agents/stt/fallback_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def __init__(
interim_results=all(t.capabilities.interim_results for t in stt),
diarization=all(t.capabilities.diarization for t in stt),
aligned_transcript=aligned_transcript,
keyterms=any(t.capabilities.keyterms for t in stt),
)
)

Expand Down Expand Up @@ -113,6 +114,11 @@ def model(self) -> str:
def provider(self) -> str:
return "livekit"

def _update_keyterms(self, keyterms: list[str]) -> None:
# forward to every underlying STT; unsupported ones warn-and-skip internally
for stt_instance in self._stt_instances:
stt_instance._update_keyterms(keyterms)

async def _try_recognize(
self,
*,
Expand Down
4 changes: 4 additions & 0 deletions livekit-agents/livekit/agents/stt/stream_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def __init__(self, *, stt: STT, vad: VAD) -> None:
streaming=True,
interim_results=False,
diarization=False, # diarization requires streaming STT
keyterms=stt.capabilities.keyterms,
)
)
self._vad = vad
Expand All @@ -42,6 +43,9 @@ def model(self) -> str:
def provider(self) -> str:
return self._stt.provider

def _update_keyterms(self, keyterms: list[str]) -> None:
self._stt._update_keyterms(keyterms)

async def _recognize_impl(
self,
buffer: utils.AudioBuffer,
Expand Down
19 changes: 19 additions & 0 deletions livekit-agents/livekit/agents/stt/stt.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ class STTCapabilities:
aligned_transcript: Literal["word", "chunk", False] = False
offline_recognize: bool = True
"""Whether the STT supports batch recognition via recognize() method"""
keyterms: bool = False
"""Whether the STT supports keyterm prompting (see STT._update_keyterms)"""


class STTError(BaseModel):
Expand All @@ -152,6 +154,7 @@ def __init__(self, *, capabilities: STTCapabilities) -> None:
self._capabilities = capabilities
self._label = f"{type(self).__module__}.{type(self).__name__}"
self._recognize_metrics_needed = True
self._keyterms_unsupported_warned = False

@property
def label(self) -> str:
Expand Down Expand Up @@ -264,6 +267,22 @@ def _emit_error(self, api_error: Exception, recoverable: bool) -> None:
),
)

def _update_keyterms(self, keyterms: list[str]) -> None:
"""Set the keyterms used to bias recognition toward specific words/phrases.

Internal hook called by the framework (e.g. keyterm detection). Plugins that
support keyterm prompting set ``STTCapabilities.keyterms`` and override this
to forward the terms to their provider-specific ``update_options()``.
"""
if not self._capabilities.keyterms:
if not self._keyterms_unsupported_warned:
self._keyterms_unsupported_warned = True
logger.warning(
"keyterms are not supported by this STT, ignoring keyterms update",
extra={"stt": self._label},
)
return

def stream(
self,
*,
Expand Down
3 changes: 3 additions & 0 deletions livekit-agents/livekit/agents/voice/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
UserStateChangedEvent,
UserTurnExceededEvent,
)
from .keyterms import KeytermDetectionOptions, KeytermOptions
from .remote_session import RemoteSession
from .room_io import (
_ParticipantAudioOutput,
Expand Down Expand Up @@ -51,6 +52,8 @@
"AgentFalseInterruptionEvent",
"RemoteSession",
"UserTurnExceededEvent",
"KeytermOptions",
"KeytermDetectionOptions",
"TranscriptSynchronizer",
"io",
"room_io",
Expand Down
10 changes: 10 additions & 0 deletions livekit-agents/livekit/agents/voice/agent_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,13 @@ async def _start_session(self, *, reuse_resources: _ReusableResources | None = N
else:
self._audio_recognition.start()

# bind the session's keyterm detector to this activity's STT and LLM
self._session._keyterm_detector.start(
self._session,
stt=self.stt if isinstance(self.stt, stt.STT) else None,
llm=self.llm if isinstance(self.llm, llm.LLM) else None,
)

@tracer.start_as_current_span("drain_agent_activity")
async def drain(
self, *, new_activity: AgentActivity | None = None
Expand Down Expand Up @@ -903,6 +910,8 @@ async def _pause_scheduling_task(
if self._scheduling_paused:
return

await self._session._keyterm_detector.aclose()

self._scheduling_paused = True
self._drain_blocked_tasks = blocked_tasks or []
self._wake_up_scheduling_task()
Expand Down Expand Up @@ -1037,6 +1046,7 @@ async def aclose(self) -> None:

self._closed = True
self._cancel_preemptive_generation()
await self._session._keyterm_detector.aclose()

# on_exit_task should be awaited in `drain`
self._on_exit_task = None
Expand Down
24 changes: 24 additions & 0 deletions livekit-agents/livekit/agents/voice/agent_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
UserStateChangedEvent,
)
from .ivr import IVRActivity
from .keyterms import KeytermDetectionOptions, KeytermDetector, KeytermOptions, _resolve_detection
from .recorder_io import RecorderIO
from .remote_session import RoomSessionTransport, SessionHost, SessionTransport
from .run_result import RunResult
Expand Down Expand Up @@ -142,6 +143,7 @@ class SessionConnectOptions:
@dataclass
class AgentSessionOptions:
turn_handling: TurnHandlingOptions
keyterm_detection: KeytermDetectionOptions
max_tool_steps: int
user_away_timeout: float | None
min_consecutive_speech_delay: float
Expand Down Expand Up @@ -229,6 +231,7 @@ def __init__(
llm: NotGivenOr[llm.LLM | llm.RealtimeModel | LLMModels | str] = NOT_GIVEN,
tts: NotGivenOr[tts.TTS | TTSModels | str] = NOT_GIVEN,
turn_handling: NotGivenOr[TurnHandlingOptions] = NOT_GIVEN,
keyterm_options: NotGivenOr[KeytermOptions] = NOT_GIVEN,
# Tool settings
tools: NotGivenOr[list[llm.Tool | llm.Toolset]] = NOT_GIVEN,
tool_handling: NotGivenOr[ToolHandlingOptions] = NOT_GIVEN,
Expand Down Expand Up @@ -286,6 +289,9 @@ def __init__(
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.
keyterm_options (KeytermOptions, optional): Keyterm prompting for the STT. Holds
user-defined ``terms`` and optional automatic ``detection`` config. Applies to
supported STTs; unsupported ones warn and ignore it.
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.
Expand Down Expand Up @@ -368,6 +374,8 @@ def __init__(
user_turn_limit = _resolve_user_turn_limit(turn_handling.get("user_turn_limit"))
raw_turn_detection = turn_handling.get("turn_detection", None)

keyterm_opts: KeytermOptions = keyterm_options if is_given(keyterm_options) else {}

# This is the "global" chat_context, it holds the entire conversation history
self._chat_ctx = ChatContext.empty()
self._opts = AgentSessionOptions(
Expand All @@ -378,6 +386,7 @@ def __init__(
preemptive_generation=preemptive_gen,
user_turn_limit=user_turn_limit,
),
keyterm_detection=_resolve_detection(keyterm_opts.get("detection")),
max_tool_steps=max_tool_steps,
user_away_timeout=user_away_timeout,
min_consecutive_speech_delay=min_consecutive_speech_delay,
Expand Down Expand Up @@ -410,6 +419,11 @@ def __init__(
self._llm = llm or None
self._tts = tts or None

self._keyterm_detector = KeytermDetector(
user_keyterms=keyterm_opts.get("terms"),
options=self._opts.keyterm_detection,
)

self._turn_detection = raw_turn_detection
self._interruption_detection = interruption.get("mode", NOT_GIVEN)
self._mcp_servers = mcp_servers or None
Expand Down Expand Up @@ -558,6 +572,11 @@ def conn_options(self) -> SessionConnectOptions:
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
Comment on lines +580 to +583

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we don't need a new property if it's inside AgentSessionOptions

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the keyterms applied to the STT (including the detected terms), expose it here in case user want to save the keyterms.


@property
def current_speech(self) -> SpeechHandle | None:
return self._activity.current_speech if self._activity is not None else None
Expand Down Expand Up @@ -1079,6 +1098,7 @@ def update_options(
*,
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,
Expand All @@ -1090,9 +1110,13 @@ def update_options(
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_user_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, "
Expand Down
Loading
Loading