conversation-aware STT recognition (keyterms + chat context) - #6039
Conversation
Show user-defined terms to the detection LLM as applied so it stops re-proposing them every pass; add misrecognition rules to the default prompt (sound-alike variants of tracked terms, user-only garbled phrases, interrupted-line fragments) and route corrections through the normal confirmation gate.
User-defined keyterms were silently dropped unless detection.enabled was set: start() returned before binding the STT, so the initial push and later set_user_keyterms() were no-ops. Bind the STT unconditionally (skipping the push when there are no terms, so sessions without keyterms see no capability warning or reconnect) and gate only the detection setup on enabled.
| "enabled": False, | ||
| "llm": None, | ||
| "turn_interval": 1, | ||
| "max_keyterms": None, |
There was a problem hiding this comment.
some of vendors impose a max limit, maybe we should check this in the extract-for-model function.
| endpoint_url=endpoint_url, | ||
| ) | ||
|
|
||
| def _update_keyterms(self, keyterms: list[str]) -> None: |
There was a problem hiding this comment.
Only the flux models can support mid-stream keyterm update: https://developers.deepgram.com/docs/keyterm#dynamic-keyterm-updates-flux-only should we disable this for nova models?
There was a problem hiding this comment.
now update_options is called after END_OF_SPEECH event, to avoid reconnection in the middle of the turn. meanwhile, the audio during reconnection is buffered in input_ch so ideally it won't missing user transcript.
The keyterm detector no longer reuses the agent's LLM. `detection.llm` accepts an LLM instance or a model string (resolved via the inference gateway) and defaults to a built-in detection model. Also tightens the default extraction prompt.
Rename KeytermOptions to STTContextOptions and move it under the stt package as recognition_context. Adds a chat_context mechanism alongside keyterms and keyterm_detection: STTs that natively consume conversation context (e.g. AssemblyAI u3-rt-pro agent_context) receive forwarded turns via a new _push_conversation_item hook, while keyterm detection remains LLM-based for STTs that accept a term list.
| # automatically detect keyterms and apply them to the STT per user turn | ||
| stt_context_options={ | ||
| "keyterms": ["LiveKit"], | ||
| "keyterm_detection": {"enabled": True, "turn_interval": 1}, |
There was a problem hiding this comment.
"turn_interval": 1 is the default, probably not necessary to put it here.
There was a problem hiding this comment.
also, I had to search the userbase to understand what turn_interval represents, maybe a better name is detect_every_n_turns.
| return {"keyterm": list(keyterms)} | ||
| if model.startswith("assemblyai/"): | ||
| return {"keyterms_prompt": list(keyterms)} | ||
| if model.startswith("speechmatics/"): |
There was a problem hiding this comment.
would it make sense to add STTCapabilities.keyterms to the speechmatics plugin?
| return [], [], [] | ||
| req_ctx = ChatContext.empty() | ||
| req_ctx.add_message(role="system", content=instructions or _DEFAULT_KEYTERM_INSTRUCTIONS) | ||
| req_ctx.add_message(role="user", content=user_msg) |
There was a problem hiding this comment.
small nit: should we format user_msg in a prefix caching friendly way so this detector LLM can run faster?
There was a problem hiding this comment.
the instructions is already fixed, but the turns added to the user message is a sliding window that will change in every request. and I think the total token per request won't reach the caching threshold for many models.
| # between turns is the only blank line and reliably marks a turn boundary | ||
| body = "\n".join(line for line in text.splitlines() if line.strip()) | ||
| turns.append(f"{item.role.upper()}: {body}") | ||
| if len(turns) >= _MAX_TRANSCRIPT_MESSAGES: |
There was a problem hiding this comment.
should this be configurable?
The framework keyterm hook now carries only the session-managed set (static stt_context_options.keyterms plus auto-detection) and is renamed _update_session_keyterms. Plugins keep the user's own keyterms (constructor / update_options) in a separate slot and apply the union of the two, so enabling detection or session keyterms no longer drops keyterms a user configured directly on the plugin. Each plugin holds its own _session_keyterms, recomputes the effective set on change, and pushes it through stream.update_options — which already reconnects (Deepgram v1/v2, Google) or sends a live update (AssemblyAI, inference) — so there is one apply path and no separate push helper or manual reconnect. The base STT carries no keyterm state; its default hook only warns and skips for STTs without keyterm support. Google biases session terms at the minimum user-keyword boost (or a moderate default when none), so detected terms never outweigh an explicit keyword. Inference folds the merge into _keyterms_extra_for_model across the deepgram/assemblyai/speechmatics keys. The detector's internal terms are renamed user/auto to static/detected to avoid clashing with the plugin-level "user" keyterms.
Deepgram's keyterm extra is typed str | list[str], so a bare string was splat character-by-character when merged with framework session keyterms.
adaptation shadows keywords in build_adaptation, so keyterms could never reach the recognizer. Gating the capability on init-time adaptation stops the framework from running LLM detection passes whose results would be discarded.
Applying detected/session keyterms reopened the connection immediately, which could cut off the in-flight utterance. Now each reconnecting stream stashes the new set in a single typed slot while the user is speaking and flushes it via update_options() at END_OF_SPEECH; if idle it still applies immediately. Explicit user update_options() is unchanged. Deepgram and Google reconnect; inference applies live but defers too since the gateway may reconnect upstream. The base stream is untouched.
The v2 STT (Flux) reconnected immediately on a session keyterm update, which could cut off the in-flight utterance. Mirror v1: stash the merged set while speaking and flush it via update_options() at END_OF_SPEECH.
update_options(keywords=…) overwrote _config.keywords with the raw user keywords, dropping any active session keyterms — and the early-return in _update_session_keyterms kept them from being reapplied. Extract the merge into _merge_keywords() and reassign the merged list before writing config and forwarding to streams.
53866c4 to
0a93cb8
Compare
AssemblyAI and inference forwarded the raw user keyterms to live streams, dropping any active session keyterms (the equality guard in _update_session_keyterms then kept them from being reapplied). Re-merge with the session set before forwarding, matching Deepgram and Google.
The keyterm detector runs its own LLM whose metrics_collected events had no subscriber, so its usage never reached session.usage, MetricsCollectedEvent, or OTel. KeytermDetector is now an EventEmitter that re-emits the detection LLM's metrics, and the agent activity wires it into the session metrics pipeline like every other model.
| self._stt._update_session_keyterms(keyterms) | ||
|
|
||
| def _push_conversation_item(self, item: ConversationItemAddedEvent) -> None: | ||
| self._stt._push_conversation_item(item) |
There was a problem hiding this comment.
Should it be a update_chat_ctx instead? Seems more consistent?
There was a problem hiding this comment.
using push because each call carries a single chat item as it's added and feeds it incrementally, update_chat_ctx feels like it's a replacing.
| llm: NotGivenOr[llm.LLM | llm.RealtimeModel | LLMModels | str] = NOT_GIVEN, | ||
| tts: NotGivenOr[tts.TTS | TTSModels | str] = NOT_GIVEN, | ||
| turn_handling: NotGivenOr[TurnHandlingOptions] = NOT_GIVEN, | ||
| stt_context_options: NotGivenOr[STTContextOptions] = NOT_GIVEN, |
There was a problem hiding this comment.
I think we should name this feature keyterms_options directly inside the AgentSession
There was a problem hiding this comment.
what about the chat_ctx options?
There was a problem hiding this comment.
or we enable it if the STT.capability support chat context, and user can enable/disable it via STT args.
| @property | ||
| def keyterms(self) -> list[str]: | ||
| """The effective keyterms (user-defined + auto-detected) currently applied to the STT.""" | ||
| return self._keyterm_detector.keyterms |
There was a problem hiding this comment.
I think we don't need a new property if it's inside AgentSessionOptions
There was a problem hiding this comment.
this is the keyterms applied to the STT (including the detected terms), expose it here in case user want to save the keyterms.
…om STT capability Move the keyterm detector to voice/keyterm_detection.py and drop chat_context from the session options. Conversation-context carryover is now gated by STTCapabilities.chat_context, toggled per-provider via STT args (AssemblyAI: agent_context_carryover, default False, warns when set on an unsupported model).
Deepgram v1's UtteranceEnd path ended speech without calling _on_end_of_speech, so a keyterm change deferred while speaking was stuck until the next speech_final. Also, across the deepgram, deepgram v2, google, and inference STTs, an explicit update_options keyterm/keywords/extra now clears the pending value so a stale snapshot can't briefly revert a newer update at end-of-speech.
Makes STT recognition conversation-aware via a new
stt_context_optionsonAgentSession, grouping static keyterms with two independent, composable mechanisms for biasing recognition during a call.Overview
keyterms: user-defined terms applied wherever the STT accepts a term list; never modified by detection.keyterm_detection: an LLM-based detector (enabled,llm,turn_interval,max_keyterms,instructions) that runs a background pass per user turn over the recent transcript and maintains the keyterm set with a confirmation gate — a new term startspendingand only biases the STT once later transcript evidence confirms it;removeapplies only to spellings the user explicitly corrected.chat_context: native conversation-context carryover for STTs that consume context directly (no LLM), forwarding each conversation turn to the provider's own field (e.g. AssemblyAI u3-rt-proagent_context).Details
STT._push_conversation_item()hook andchat_contextcapability flag, alongside the existing_update_keyterms()/keytermsflag; both are forwarded by the fallback and stream adapters.keytermscapability implemented for deepgram (v1/v2), assemblyai, google, and livekit inference STT;chat_contextimplemented for assemblyai u3-rt-pro.