Skip to content

fix(voice): expose speech handle generation errors#1965

Open
rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
zooming-tauter-calving
Open

fix(voice): expose speech handle generation errors#1965
rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
zooming-tauter-calving

Conversation

@rosetta-livekit-bot

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

Copy link
Copy Markdown
Contributor

Summary

  • add SpeechHandle.exception() to expose stored speech generation errors without making await handle reject
  • mark realtime generateReply failures as completed speech handles carrying the error
  • propagate stored speech handle errors through RunResult

Testing

  • pnpm test agents/src/voice/speech_handle.test.ts
  • pnpm test agents/src/voice/testing/run_result.test.ts
  • pnpm build:agents
  • cue-cli text-mode runtime validation with a temporary local FakeLLM worker, asserting conversation_item_added(.item.message.role="ASSISTANT")

Ported from livekit/agents#6304

Original PR description

Fixes #6224. Alternative approach to #6226.

Problem: when a realtime generate_reply or say fails (e.g. RealtimeError("generate_reply timed out.")), the error is only logged inside _realtime_reply_task, so application code has no way to detect the failure and react.

Why not raise from await speech_handle (the approach in #6226): most speech handles are never awaited (e.g. replies generated from user turns), so setting the exception on the done future triggers "Future exception was never retrieved" warnings for every failure. It also changes behavior for all existing code awaiting handles (including framework-internal awaits like await speech_handle.interrupt() in _user_turn_completed_task), and diverges from the pipeline path, which intentionally keeps LLM failures off the done future (see _on_llm_task_done) and only raises through session.run().

Changes:

  • SpeechHandle._mark_done(error=...) records the error in an _error attribute; the done future always resolves normally, so await handle / wait_for_playout() never raise.
  • New public SpeechHandle.exception(), mirroring asyncio.Future.exception(): returns the error once the handle is done (None if it succeeded), raises InvalidStateError before that.
  • Both realtime failure paths (say and generate_reply) record the error; pipeline LLM failures are recorded into the same attribute, so both backends report errors consistently.
  • RunResult reads the same attribute, so await session.run(...) still raises on generation failures.
handle = session.generate_reply()
await handle  # never raises
if exc := handle.exception():
    ...  # e.g. RealtimeError("generate_reply timed out.")

@changeset-bot

changeset-bot Bot commented Jul 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8459a80

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

This PR includes changesets to release 35 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-assemblyai 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-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-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

@rosetta-livekit-bot
rosetta-livekit-bot Bot requested a review from longcw July 6, 2026 04:13

@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 2 potential issues.

Open in Devin Review

Comment on lines +246 to +250
const speechError = this.lastSpeechHandle.exception();
if (speechError) {
this.doneFut.reject(speechError);
return;
}

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.

🔍 Error propagation only checks the last-completing speech handle

In RunResult._markDone() (agents/src/voice/testing/run_result.ts:246), the new exception() check only inspects this.lastSpeechHandle, which is set to whichever handle last triggered _markDoneIfNeeded (agents/src/voice/testing/run_result.ts:226-227). If multiple speech handles are watched and the errored handle completes before a non-errored one, the error is silently lost because lastSpeechHandle will point to the non-errored handle. In practice this is unlikely since typical runs involve a single speech handle, but multi-handle scenarios (e.g. tool-triggered child handles) could mask errors.

Open in Devin Review

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

Comment on lines 3903 to +3915
return;
}

const generationEvent = await generationPromise;
let generationEvent: GenerationCreatedEvent;
try {
generationEvent = await generationPromise;
} catch (error) {
const cause = asError(error);
this.logger.error(cause, 'failed to generate a reply');
speechHandle._markDone(cause);
this.agentSession._updateAgentState('listening');
return;
}

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.

🔍 Reachability of try/catch depends on ThrowsPromise.race semantics

In realtimeReplyTask, waitIfNotInterrupted at agents/src/voice/agent_activity.ts:3900 races generationPromise against the interrupt future using ThrowsPromise.race. If generationPromise rejects and ThrowsPromise.race propagates that rejection (like standard Promise.race), the error would throw from line 3900 and never reach the try/catch at lines 3907-3915. The try/catch is only reachable if ThrowsPromise.race treats rejections as settled values rather than propagating them. The existing pre-PR code also did await generationPromise after waitIfNotInterrupted without a try/catch, suggesting ThrowsPromise does NOT propagate rejections from race — otherwise the old code would have had unhandled errors in production. This is worth confirming against the @livekit/throws-transformer implementation.

(Refers to lines 3900-3915)

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