Skip to content

fix(inference): treat a TTS gateway done as a generation boundary - #2146

Closed
toubatbrian wants to merge 1 commit into
brian/fix-tts-pool-session-leakfrom
brian/fix-tts-multi-done
Closed

fix(inference): treat a TTS gateway done as a generation boundary#2146
toubatbrian wants to merge 1 commit into
brian/fix-tts-pool-session-leakfrom
brian/fix-tts-multi-done

Conversation

@toubatbrian

@toubatbrian toubatbrian commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Stacked on #2144, which gates socket reuse on having seen a done; this PR changes what a
done means. Review only this commit.

Problem

Several gateway providers answer one session.flush with more than one generation, splitting at
roughly 1kB of text or 35s of audio and sending a done after each. A probe of
inworld/inworld-tts-2 got 6 dones for one flush, the first covering 39.4s of the 201.0s the
session went on to produce.

Stopping at the first done cut every reply longer than one generation off after 20-80% of its
audio while the transcript committed all of it, and released the socket into the
ConnectionPool mid-synthesis, so the next reply spoke the previous one's leftover audio. The
session is FIFO and never reset, so each reply queued behind the last one's unsynthesized text
and the lag compounded.

Fix

done is a generation boundary: keep reading, and end the flush only once the session has gone
quiet. A terminal event arriving during that wait is judged on what it proves —
session.closed still ends the reply but evicts the socket (session.create is only sent when
a socket opens, so a pooled socket would keep the closed session), and an error fails the
attempt, because a done never promised the reply was complete.

Testing

A fake gateway splitting one flush into three done-terminated generations under one session
id: on main the reply speaks 1 of 3 and the next reply speaks the first reply's second
generation. End to end, the barge-in scenario goes from 6 of 10 replies never spoken and the
transcript 26.8s ahead of the audio to 0ms of lag and 1-2 never spoken, in 3 of 3 runs.

Known limit

The wait is capped by how much synthesized audio is still unplayed, so it hides behind playout
and costs no turn latency. A provider streaming at ~1x realtime has nothing buffered, so the
wait collapses to zero and truncation persists there. Pinned by a test rather than lengthened:
the providers that do split a flush run far faster than realtime. agent-gateway#1105 fixes the
Inworld path server-side.

Python parity

inference/tts.py has the identical defect — end_input(); break on the first done — and
needs the same change with the grace in seconds. Filed separately.

@toubatbrian
toubatbrian requested a review from a team as a code owner July 28, 2026 05:58
@changeset-bot

changeset-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 21e00a1

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-hedra 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-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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9c78345f73

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +815 to +819
const unplayedMs =
firstEmittedAt === undefined
? 0
: Math.max(0, emittedAudioMs - (Date.now() - firstEmittedAt));
return Math.min(DRAIN_IDLE_TIMEOUT, unplayedMs);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Wait out the generation gap for real-time audio delivery

When a gateway streams a generation at roughly playback speed, emittedAudioMs grows at the same rate as Date.now() - firstEmittedAt, so unplayedMs is zero or nearly zero when done arrives. The next read therefore times out immediately instead of covering the documented inter-generation gap, finalizes after the first generation, and can recycle the socket while later audio is still coming. The new test sends each generation as a synchronous burst, so it does not exercise this common streaming cadence.

Useful? React with 👍 / 👎.

Comment on lines +918 to +920
if (wasDraining) {
await finalize();
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Evict the websocket after session.closed

When the gateway sends session.closed after a done, finalize() sets sessionDrained, causing the surrounding cleanup to skip pool.remove(ws) and withConnection to return this websocket to the pool. The server-side session is explicitly closed, however, and a pooled connection does not send another session.create; the next synthesis therefore sends its transcript and flush to a dead logical session and may wait for the receive timeout before retrying. The current reply can finish normally here, but this websocket must not be reused.

Useful? React with 👍 / 👎.

Comment thread agents/src/inference/tts.ts Outdated

@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: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

Several gateway providers answer one `session.flush` with more than one generation, splitting
at roughly 1kB of text or 35s of audio and sending a `done` after each. A probe of
`inworld/inworld-tts-2` saw 6 `done` events for a single flush, the first covering 39.4s of the
201.0s the session went on to produce.

Stopping at the first `done` cut replies off after 20-80% of their audio while the transcript
committed all of it, and released the websocket into the ConnectionPool mid-synthesis so the
next reply spoke the previous one's leftover audio. The FIFO session is never reset, so each
reply queued behind the last one's unsynthesized text and the lag compounded.

Keep reading after `done` and end the flush only once the session has stayed quiet. The wait
is capped by how much synthesized audio is still unplayed, so it is hidden behind playout and
collapses to near zero on replies too short for the gateway to split.

A terminal event that arrives during that wait is judged on what it actually proves.
`session.closed` still ends the reply normally, but its socket is evicted instead of pooled:
`session.create` is only ever sent when a socket is opened, so a pooled socket would keep the
closed session and the next reply would stall inside it. An `error` no longer resolves the
reply, because a `done` is only a candidate end and a provider that fails while preparing the
next generation has left the reply unfinished; a genuinely finished reply is protected from
that retry by the idle timeout, which resolves the run before a later error can be read.

The cap on the wait is a known limitation: a gateway streaming at roughly playback speed leaves
nothing buffered, so the wait collapses to about zero and the first `done` ends the flush. It
is documented and pinned by a test rather than lengthened — the bound is what keeps this off
the turn latency path, and the providers that do split a flush run far faster than realtime.

Co-authored-by: Cursor <cursoragent@cursor.com>
@toubatbrian
toubatbrian force-pushed the brian/fix-tts-multi-done branch from f854d37 to 21e00a1 Compare July 29, 2026 00:25
@toubatbrian

Copy link
Copy Markdown
Contributor Author

Closing: the multi-done behaviour is a gateway defect, not a protocol feature, and is being fixed at the source in agent-gateway#1105.

The gateway's own design notes treat done as the end of one turn/generation, one per flush, across all seven providers. Inworld splitting a single flush at ~1kB / 35s and emitting a done per fragment is that contract being broken. Since the gateway is LiveKit-operated rather than self-hosted, once #1105 ships no client sees multiple dones and the drain state machine here becomes dead code.

Against that, the cost is real: it diverges from Python, which breaks on the first done, and it carries a drain timeout, a reusability flag and reworked error semantics that already drew three P1 review findings. By its own documented limitation the protection also collapses when audio arrives at playback speed.

#2144 is unaffected and still wanted — a session dropped mid-synthesis being reported as a completed reply is an independent defect, and fixing it restores Python parity rather than breaking it.

Reopen if it turns out multiple dones per flush are an intended part of the client-facing gateway contract; that would make #1105 the wrong fix and this the right one.

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.

1 participant