Skip to content

fix(tts): don't fail the session while FallbackAdapter is falling back - #2568

Merged
tinalenguyen merged 8 commits into
livekit:mainfrom
vznh:fix/tts-fallback-child-errors
Sep 24, 2026
Merged

tinalenguyen merged 8 commits into
livekit:mainfrom
vznh:fix/tts-fallback-child-errors

Conversation

@vznh

@vznh vznh commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2399.

Problem

FallbackAdapter re-emitted every child TTS error verbatim. A non-retryable child failure (ElevenLabs 401 in the report) reached AgentSession as recoverable: false and closed the session while the adapter was successfully failing over. The recovery probe re-raises it every recoveryDelayMs (default 1 s), so no maxUnrecoverableErrors value survives a downed provider.

Fix

The adapter absorbs a child's failure only when it recovers from it, matching stt.FallbackAdapter and Python's tts/fallback_adapter.py. What it can't recover from still reaches the session once, as its own error: every instance failing, or a failure after audio went out, which fails without retrying since another instance would replay it.

Tests

14 new cases in fallback_adapter.test.ts; 13 fail on main, and the 14th guards the wrapper cleanup that close()'s removeAllListeners used to do by accident. vitest run agents/src: 2768 pass. Typecheck, lint, prettier and throws-check clean.

🤖 Generated with Claude Code

FallbackAdapter re-emitted every child TTS `error` event verbatim. A
non-retryable provider failure (an ElevenLabs 401, say) therefore reached
AgentSession as an unrecoverable `tts_error` and closed the session even
though the next instance was already serving audio. The recovery probe
made it terminal rather than occasional: it re-raises the same error
every `recoveryDelayMs`, so a downed provider exhausts
`maxUnrecoverableErrors` no matter how it is tuned.

Absorb child errors instead, matching stt.FallbackAdapter and Python's
tts/fallback_adapter.py, which forwards only `metrics_collected`. A
listener stays attached rather than being dropped entirely: a TTS with no
`error` listener throws ERR_UNHANDLED_ERROR on emit, and ChunkedStream's
main task has no catch, so the recovery probe would escape as an
unhandled rejection. Terminal failure still reaches the session — once
every instance has failed the adapter's own stream throws
APIConnectionError and the base class emits that here.

close() now removes only the listeners this adapter registered. It called
removeAllListeners on instances the caller owns and constructed, deleting
their own subscriptions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vznh
vznh requested a review from a team as a code owner September 22, 2026 23:59
@changeset-bot

changeset-bot Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 700dfac

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

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

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

devin-ai-integration[bot]

This comment was marked as resolved.

Devin flagged an unhandled rejection reachable from this adapter: a
non-streaming child is synthesized through a StreamAdapter, which
re-emits the child's errors onto itself with nothing listening, and Node
turns that into a synchronous throw inside the child's emitError.

The suggested fix — attaching an error sink to the temporary wrapper —
does not resolve it. Measured on the non-streaming fallback path:

  PR as-is                          2 rejections (ERR_UNHANDLED_ERROR)
  + sink on the wrapper             2 rejections (APIError)
  + ChunkedStream catch, no sink    0 rejections

The sink only changes which error escapes. Both escape from the same
place: ChunkedStream's main task is detached in the constructor with no
catch, unlike SynthesizeStream's, which has swallowed its rejection since
it was written. Give ChunkedStream the same treatment and the same
comment, rather than a sink per wrapper at every construction site.

Regression covered by the non-streaming fallback test, which now asserts
no unhandled rejection escapes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@tinalenguyen tinalenguyen left a comment

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.

thanks for the PR, left a small comment!

* this adapter's own stream throws `APIConnectionError` and the base class
* emits that here.
*/
private readonly absorbChildError = () => {};

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.

let's log provider errors, also for parity with python

A failing child stream reports its provider's error on the TTS `error`
event and then ends without throwing, so FallbackAdapter's failover and
recovery logs only ever carried its own stand-in — "TTS stream completed
but no audio was received" — never the provider's 401. Before child
errors were absorbed, the real error surfaced only as AgentSession shut
down.

Record the error each attempt's instance reports and log that instead,
matching Python's "error, switching to next TTS" and "recovery failed"
lines. The absorbing sink stays silent: it cannot tell a failover from a
recovery probe, and probes re-fail every recoveryDelayMs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

The previous commit recorded provider errors from the TTS-wide `error`
event for the duration of each attempt. That event is shared by every
stream of a TTS and doesn't identify its source, so when two requests
used one instance, a request whose attempt ended silently was logged
with the other request's 401.

A stream now keeps the error it failed with (`error`, @internal, beside
`startedTime`), set in emitError. Each attempt reads its own stream's
error when it finds no audio, so attribution holds under concurrency and
the listener plumbing goes away. The attempt still throws the declared
APIConnectionError; only the logged error changes. The recovery probe,
which has no Throws<> contract and doesn't branch on the error, throws
its stream's error directly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

A non-streaming TTS reaches FallbackAdapter's streaming path through a
StreamAdapter, whose wrapper synthesizes each sentence as a
ChunkedStream. A failed sentence ends that iteration without throwing
and the wrapper never read the sentence stream's error, so its own
`error` stayed undefined and a non-streaming provider's 401 was logged
only as "TTS stream completed but no audio was received".

The wrapper now keeps the first sentence failure and reports it through
`error` when it didn't fail itself. It still skips a failed sentence
rather than failing: propagating would emit a second `tts_error` for the
same provider failure on the wrapped TTS, which AgentSession counts
toward maxUnrecoverableErrors.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

…ting

StreamAdapterWrapper reported a skipped sentence's error even after a
later sentence delivered audio, contradicting the `error` contract (the
error the stream failed with). It now reports a sentence error only when
no sentence delivered audio: a wrapper that delivered anything didn't
fail, whatever it skipped.

Two more failures went unreported once child errors were absorbed:

- A provider that fails after delivering some audio was treated as a
  success and its error was dropped, so a cut-off reply left no log at
  all. Both attempt paths now log it with the provider's error, reusing
  the adapter's existing mid-utterance message.
- The recovery probe consulted its stream's error only when no audio
  arrived, so a provider that sent a little audio and then failed was
  marked recovered. A probe that errors now fails recovery, as in
  Python.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

Resolve StreamAdapterWrapper against livekit#2576, which added an `expressive`
snapshot beside this branch's `error` getter; both are kept. The API
report is regenerated from the merged tree and differs from main only by
this branch's three internal `error` getters.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

The previous commit logged a provider failure that cut off delivered
audio but completed the utterance, so AgentSession never learned speech
was lost; on main it did, through the child error this PR stops
forwarding. The adapter now fails its own stream in that case, without
retrying since another instance would replay the audio, so the session
hears about lost speech exactly once. The chunked path gains the
mid-utterance guard the streaming path already had instead of falling
back.

A sentence StreamAdapterWrapper skipped is lost speech too, so the
wrapper reports it even when other sentences delivered audio, restoring
the contract the previous round narrowed.

getStreamingInstance created a StreamAdapter for every attempt, skipped
ones included, and nothing closed it; close()'s removeAllListeners used
to sweep them up. Create it only for an attempt that runs and close it
when the attempt ends, as livekit#2468 does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@vznh

vznh commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

@tinalenguyen back for re-review, thank you!

@vznh
vznh requested a review from tinalenguyen September 24, 2026 20:48

@tinalenguyen tinalenguyen left a comment

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.

thanks for iterating!

@tinalenguyen
tinalenguyen merged commit 546dc33 into livekit:main Sep 24, 2026
8 of 9 checks passed
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.

tts.FallbackAdapter forwards child error events with recoverable: false, closing the AgentSession even though the adapter is switching to the next TTS

2 participants