Skip to content

feat(azure): add STT cancellation recovery#2009

Open
rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
port-azure-stt-cancellation-recovery
Open

feat(azure): add STT cancellation recovery#2009
rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
port-azure-stt-cancellation-recovery

Conversation

@rosetta-livekit-bot

@rosetta-livekit-bot rosetta-livekit-bot Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Ports livekit/agents#6362 by adding the missing Azure streaming STT plugin infrastructure to agents-js and preserving the cancellation-error recovery behavior: Azure error cancellations wake the stream, surface an APIConnectionError, and allow the base STT retry/fallback path to proceed instead of hanging.

Source diff coverage

Coverage
Source file Classification
livekit-plugins/livekit-plugins-azure/livekit/plugins/azure/stt.py Adapted to plugins/azure/src/stt.ts. The target repo had no Azure plugin/package, so this PR adds the Azure STT package infrastructure and ports the streaming STT behavior needed for the source fix, including _cancellationError tracking, clearing it per recognition run, waking the stopped event from error cancellations, and raising a retryable APIConnectionError when the stopped path observes the cancellation.
tests/test_plugin_azure_stt.py Adapted to plugins/azure/src/stt.test.ts. The source unit tests are ported to Vitest and target the JS SDK cancellation event shape while preserving the same assertions: error cancellations unblock the run and non-error cancellations are ignored.

Validation

  • pnpm test plugins/azure
  • pnpm --filter @livekit/agents-plugin-azure lint
  • pnpm --filter @livekit/agents-plugin-azure build
  • pnpm --filter @livekit/agents-plugin-azure api:check
  • pnpm build
  • pnpm lint (passes; existing warnings remain in unrelated packages)
  • cue-cli text-mode runtime validation with a temporary uncommitted JS agent: observed user and assistant conversation_item_added framework events and agent state transitions.

Notes

The source diff was 2 files and below the >20 files or >1000 lines planning POST threshold, so no port-plan POST was required.


Ported from livekit/agents#6362

Original PR description

If Azure STT hits an error cancellation (like a service timeout), the stream just goes silent. _on_canceled only logged a warning, and Azure doesn't reliably send session_stopped after that, so _run blocks on its asyncio.wait forever. Audio keeps flowing into a dead recognizer and nothing raises, so retry and fallback never kick in.

Now an error cancel records the details and sets the stopped event, so _run wakes and raises APIConnectionError. The base retry loop and FallbackAdapter handle it from there.

I kept it on the base retry path instead of adding retry inside the plugin like the earlier attempt (#5333). Happy to flip it if you'd rather.

Tests in tests/test_plugin_azure_stt.py cover both cases.

Fixes #5322

@rosetta-livekit-bot rosetta-livekit-bot Bot requested a review from a team as a code owner July 9, 2026 08:28
@changeset-bot

changeset-bot Bot commented Jul 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0203008

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 37 packages
Name Type
@livekit/agents-plugin-azure Major
@livekit/agents Major
@livekit/agents-plugin-anam Major
@livekit/agents-plugin-anthropic Major
@livekit/agents-plugin-assemblyai Major
@livekit/agents-plugin-baseten Major
@livekit/agents-plugin-bey Major
@livekit/agents-plugin-cartesia Major
@livekit/agents-plugin-cerebras Major
@livekit/agents-plugin-deepgram Major
@livekit/agents-plugin-did Major
@livekit/agents-plugin-elevenlabs Major
@livekit/agents-plugin-fishaudio Major
@livekit/agents-plugin-google Major
@livekit/agents-plugin-hedra Major
@livekit/agents-plugin-hume Major
@livekit/agents-plugin-inworld Major
@livekit/agents-plugin-lemonslice Major
@livekit/agents-plugin-liveavatar Major
@livekit/agents-plugin-livekit Major
@livekit/agents-plugin-minimax Major
@livekit/agents-plugin-mistral Major
@livekit/agents-plugin-mistralai Major
@livekit/agents-plugin-neuphonic Major
@livekit/agents-plugin-openai Major
@livekit/agents-plugin-perplexity Major
@livekit/agents-plugin-phonic Major
@livekit/agents-plugin-resemble Major
@livekit/agents-plugin-rime Major
@livekit/agents-plugin-runway Major
@livekit/agents-plugin-sarvam Major
@livekit/agents-plugin-silero Major
@livekit/agents-plugin-soniox Major
@livekit/agents-plugin-tavus Major
@livekit/agents-plugin-trugen Major
@livekit/agents-plugin-xai Major
@livekit/agents-plugins-test Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@rosetta-livekit-bot rosetta-livekit-bot Bot requested a review from longcw July 9, 2026 08:28

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment thread plugins/azure/src/stt.ts
Comment on lines +264 to +269
const inputTask = this.#processInput(pushStream);
const completed = await Promise.race([
inputTask.then(() => 'input' as const),
this._reconnectEvent.wait().then(() => 'reconnect' as const),
this._sessionStoppedEvent.wait().then(() => 'stopped' as const),
]);

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.

🔴 Audio input processing task is never stopped on reconnect, causing audio frames to be lost

The background audio-reading task is never cancelled when the recognition session reconnects or errors out (#processInput keeps running at stt.ts:264), so a second reader starts on the same queue and audio frames are silently split between the dead and live sessions.

Impact: After any option update or cancellation-error recovery, roughly half of the user's speech is lost and never transcribed.

Concurrent input consumers on the shared queue after reconnect

In run() at plugins/azure/src/stt.ts:264, #processInput(pushStream) is launched as a fire-and-forget async task. When the Promise.race at line 265 resolves with 'reconnect' (line 283) or 'stopped' (line 271), the method proceeds to tear down the recognizer in the finally block (lines 292-297) and loops back to create a new recognizer and a new #processInput call.

However, the original #processInput is still alive — it is blocked on await this.input.next() at plugins/azure/src/stt.ts:304. Since this.input is a shared AsyncIterableQueue, both the old and new #processInput tasks now race to consume frames from it. Whichever task wins each .next() call gets the frame; the old task writes it to the now-dead pushStream, and the new task writes it to the live one.

Compare with the Deepgram v2 plugin (plugins/deepgram/src/stt_v2.ts:383-398), which races the reconnect event inside its send task so it can exit cleanly when a reconnect is triggered.

Additionally, pushStream.close() is only called in the 'input' path (line 288), so on reconnect/error the old push stream is leaked.

Prompt for agents
The #processInput task launched at line 264 is never cancelled when the Promise.race resolves with 'reconnect' or 'stopped'. This means on the next loop iteration a second #processInput starts consuming from the same this.input queue, splitting audio frames between the dead and live push streams.

To fix this, #processInput needs a way to be signalled to stop. Options:
1. Pass an AbortSignal (or a shared cancellation token) into #processInput and check it alongside this.input.next(). When the race resolves with 'reconnect' or 'stopped', abort the signal so #processInput exits its loop.
2. Alternatively, race this.input.next() against the reconnect/stopped events inside #processInput (similar to how Deepgram v2's #sendTask races against the reconnect event at plugins/deepgram/src/stt_v2.ts:383-398).
3. Close the pushStream in the reconnect and stopped paths (not just the 'input' path) to avoid resource leaks.

The key requirement is that only one #processInput task is ever consuming from this.input at a time.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants