Skip to content

fix(xai): align realtime Voice Agent defaults and API coverage - #2255

Open
rosetta-livekit-bot[bot] wants to merge 3 commits into
mainfrom
synods-enduring-sinister
Open

fix(xai): align realtime Voice Agent defaults and API coverage#2255
rosetta-livekit-bot[bot] wants to merge 3 commits into
mainfrom
synods-enduring-sinister

Conversation

@rosetta-livekit-bot

@rosetta-livekit-bot rosetta-livekit-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • default xAI Realtime to grok-voice-latest with grok-transcribe
  • add native realtime say() support using xAI force_message, including FIFO response correlation and cancellation cleanup
  • forward xAI realtime session options and live transcription updates

Source diff coverage

Source diff coverage
  • livekit-plugins/livekit-plugins-xai/livekit/plugins/xai/realtime/__init__.py: not applicable. plugins/xai/src/realtime/index.ts already exports the target-native RealtimeSession implementation.
  • livekit-plugins/livekit-plugins-xai/livekit/plugins/xai/realtime/realtime_model.py: adapted to plugins/xai/src/realtime/realtime_model.ts, with the missing realtime supportsSay/say() voice-pipeline infrastructure added in agents/src/llm/realtime.ts and agents/src/voice/agent_activity.ts, and reusable protocol/session seams added in plugins/openai/src/realtime/api_proto.ts and plugins/openai/src/realtime/realtime_model.ts. Python futures/tasks are represented with JS promises, abort signals, and stream cancellation.
  • livekit-plugins/livekit-plugins-xai/livekit/plugins/xai/types.py: ported to the colocated GrokRealtimeModels type in plugins/xai/src/realtime/realtime_model.ts.
  • tests/test_realtime/test_realtime.py: adapted to plugins/xai/src/realtime/realtime_model.test.ts. The target has no shared credential-gated realtime provider/WAV harness; its existing xAI chat-context test already covers history deletion, and the source say() smoke is covered at the wire/generation boundary.
  • tests/test_realtime/test_xai_realtime_model.py: ported to plugins/xai/src/realtime/realtime_model.test.ts, including defaults, session updates, live captions, say() cancellation/close handling, and FIFO correlation.

Test plan

  • env -u LIVEKIT_URL -u LIVEKIT_API_KEY -u LIVEKIT_API_SECRET pnpm vitest run agents (1065 passed, 2 skipped)
  • env -u OPENAI_API_KEY pnpm vitest run plugins/openai (56 passed, 7 skipped)
  • pnpm vitest run plugins/xai (29 passed, 1 skipped)
  • pnpm build
  • pnpm lint (passes with existing warnings)
  • pnpm format:check

Validation notes

  • cue-cli dispatched the xAI realtime voice agent and reached AgentActivity.realtime_say; the configured xAI endpoint rejected the WebSocket handshake with HTTP 400 before framework assistant events could be emitted.
  • API Extractor is currently blocked repository-wide by unsupported export * as declarations and a missing plugins/bey/api-extractor.json; the touched packages build and emit declarations successfully.

Upstream: livekit/agents#6755


Ported from livekit/agents#6755

Original PR description

Summary

  • Default xAI Realtime model to grok-voice-latest (resolves to grok-voice-think-fast-2.0) and default input transcription to grok-transcribe
  • Add force_message support via RealtimeSession.say() / supports_say, emit live caption updates from conversation.item.input_audio_transcription.updated, and forward reasoning / transcription / speed options
  • Expand live/unit coverage (including history delete + say smoke) and document the audit in livekit-xai-issues.md

Test plan

  • uv run pytest tests/test_realtime/test_xai_realtime_model.py -q --allow-uncategorized (20 passed)
  • XAI_API_KEY=… uv run pytest tests/test_realtime/test_realtime.py -k xai -q --allow-uncategorized (16 passed)
  • Live WS smoke: connect with grok-voice-latest, session.update (reasoning / idle_timeout / grok-transcribe), cancel, force_message, delete
  • CI unit gate on the PR

Made with Cursor

@changeset-bot

changeset-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3e5bca2

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 Major
@livekit/agents-plugin-openai Major
@livekit/agents-plugin-xai Major
@livekit/agents-plugin-anam 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-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-plugins-test Major
@livekit/agents-plugin-trugen 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

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

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +214 to +218
} catch (error) {
delete this.responseCreatedFutures[eventId];
if (!forceMessageSent) this.dropPendingSayTag(eventId);
if (doneFut.done) return await doneFut.await;
throw error;

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.

🔴 Scripted speech stops working after the voice connection reconnects

A scripted-speech request that is still waiting when the connection drops leaves its tracking tag in the pending queue forever (the tag is only removed when the message was never sent, at plugins/xai/src/realtime/realtime_model.ts:216), so every later scripted-speech request is matched to the wrong reply and eventually fails.
Impact: After a reconnect, say() on xAI realtime times out (~10s) and the reply that should have been spoken is cancelled instead, so the agent stays silent.

Pending say tag queue desynchronizes because reconnect clears futures but not the FIFO tag list

On reconnect the base session rejects all pending response futures with Session reconnected and clears both responseCreatedFutures and discardedEventIds (plugins/openai/src/realtime/realtime_model.ts:1103-1110). In the xAI subclass, the rejected say() lands in the catch block: since forceMessageSent is true, dropPendingSayTag(eventId) is skipped and no stale-cleanup timer is scheduled (scheduleStaleSayCleanup is only invoked from discardSay and the timeout path, plugins/xai/src/realtime/realtime_model.ts:202-210,255-263). The dead id therefore stays in pendingSayEventIds indefinitely.

The next say() pushes its own id behind the stale one. When the server sends response.created, handleResponseCreated shifts the stale id and stamps it as client_event_id (plugins/xai/src/realtime/realtime_model.ts:322-328); the base handler finds no matching future, so the new say() never resolves and rejects after 10s, at which point its id is added to discardedEventIds and the following response is cancelled.

A fix would clear pendingSayEventIds (and staleSayTimers) whenever the session reconnects, and drop the tag on any terminal failure of a sent say, not just aborts before sending.

Prompt for agents
In plugins/xai/src/realtime/realtime_model.ts, the FIFO correlation list pendingSayEventIds can retain dead entries. When the underlying OpenAI base session reconnects it rejects every entry in responseCreatedFutures with 'Session reconnected' and clears responseCreatedFutures/discardedEventIds, but nothing clears the xAI-side pendingSayEventIds or staleSayTimers. Because say()'s catch block only drops the pending tag when the force_message was never sent (forceMessageSent === false), a say that was in flight at reconnect time leaves its id in pendingSayEventIds permanently. handleResponseCreated then shifts that dead id onto the next response.created, so subsequent say() calls never resolve (they time out after 10s) and the response after that gets cancelled as 'discarded'. Consider hooking the session-reconnected path (or overriding whatever runs on reconnect) to reset pendingSayEventIds/staleSayTimers, and make say() drop or schedule cleanup of its tag on any terminal rejection, not only on pre-send aborts.
Open in Devin Review

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

Comment on lines +150 to +155
say(
_text: string | ReadableStream<string>,
_options: { signal?: AbortSignal } = {},
): Promise<GenerationCreatedEvent> {
throw new Error(`${this.constructor.name} does not implement say(); use a TTS model instead`);
}

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.

🟡 New public methods and protocol types are added without documentation comments

The newly added scripted-speech method on the realtime session base class (say() at agents/src/llm/realtime.ts:150) has no documentation comment, which the repository's contribution rules require for every new method, interface or class.
Impact: The generated API documentation is missing entries for newly exposed public API.

Rule source and other affected declarations

CONTRIBUTING.md: "If writing new methods/interfaces/enums/classes, document them. This project uses TypeDoc for automatic API documentation generation, and every new addition has to be properly documented."

Undocumented new declarations in this PR:

  • RealtimeSession.say()agents/src/llm/realtime.ts:150-155
  • ForceMessageItemCreateplugins/openai/src/realtime/api_proto.ts:395-399
  • ForceMessageCreateEventplugins/openai/src/realtime/api_proto.ts:427-430

(The new supportsSay capability flag is correctly documented.)

Suggested change
say(
_text: string | ReadableStream<string>,
_options: { signal?: AbortSignal } = {},
): Promise<GenerationCreatedEvent> {
throw new Error(`${this.constructor.name} does not implement say(); use a TTS model instead`);
}
/**
* Speak the given scripted text directly through the realtime session.
*
* Only supported by providers that advertise {@link RealtimeCapabilities.supportsSay};
* the default implementation throws.
*
* @param _text - The text (or text stream) to speak.
* @param _options - Optional abort signal used to cancel the request.
*/
say(
_text: string | ReadableStream<string>,
_options: { signal?: AbortSignal } = {},
): Promise<GenerationCreatedEvent> {
throw new Error(`${this.constructor.name} does not implement say(); use a TTS model instead`);
}
Open in Devin Review

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

Comment on lines +255 to +263
private discardSay(eventId: string): void {
delete this.responseCreatedFutures[eventId];
if (!this.discardedEventIds.has(eventId)) {
this.sendEvent({ type: 'response.cancel' });
this.discardedEventIds.add(eventId);
this.scheduleStaleSayCleanup(eventId);
}
this.ensurePendingSayTag(eventId);
}

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.

🟡 Cancelled scripted speech can silently suppress the agent's reply to the user

When a scripted speech request is cancelled after it was sent, its tracking tag is deliberately kept in the pending queue (ensurePendingSayTag at plugins/xai/src/realtime/realtime_model.ts:262), so whichever reply the server produces next — including a genuine reply to the user — is thrown away.
Impact: For up to 10 seconds after a cancelled scripted line, the agent can silently drop its answer to what the user just said.

Mechanism: discarded tag is applied to an arbitrary next response

discardSay adds the event id to discardedEventIds and re-inserts it into pendingSayEventIds (plugins/xai/src/realtime/realtime_model.ts:255-263). handleResponseCreated stamps the head of pendingSayEventIds onto any incoming response.created that lacks a client_event_id (plugins/xai/src/realtime/realtime_model.ts:322-328). With server VAD (create_response: true, the xAI default at plugins/xai/src/realtime/realtime_model.ts:21-28), the server autonomously creates a response as soon as the user finishes speaking. If such a response is the first one to arrive after the cancellation, the base handler sees a discarded id and cancels/discards it (plugins/openai/src/realtime/realtime_model.ts:1462-1472), so the user's turn goes unanswered. The tag is only released by the 10s cleanup timer (plugins/xai/src/realtime/realtime_model.ts:265-277).

A safer correlation would only discard responses whose creation timestamp/order can actually be attributed to the cancelled force_message (e.g. discard only until the very next response, and drop the tag as soon as any response has been observed).

Open in Devin Review

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

@rosetta-livekit-bot
rosetta-livekit-bot Bot requested a review from a team as a code owner August 12, 2026 09:19
Base automatically changed from reinsert-bastes-whom to main August 12, 2026 10:06

@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 new potential issue.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines +2049 to +2055
const recoverable = !isFatalError(event.error);
const error = new APIError(event.error.message, {
body: event.error,
retryable: recoverable,
});
if (!recoverable) throw error;
this.emitError({ error, recoverable: true });

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.

🔴 A billing or credential error from the voice provider now crashes the whole agent process

A fatal server error is re-thrown from inside the WebSocket message callback (throw error at plugins/openai/src/realtime/realtime_model.ts:2054) instead of being reported through the session's error channel, so the failure escapes as an uncaught exception and takes the process down.

Impact: Errors such as an exhausted quota or an invalid API key kill the running agent worker instead of surfacing a normal session error to the application.

Mechanism: throw escapes the ws `onmessage` handler with no catch

handleError is invoked synchronously from the wsConn.onmessage handler registered in runWs (plugins/openai/src/realtime/realtime_model.ts:1254-1337). That handler is called by the ws EventEmitter while draining socket data; nothing wraps it in a try/catch, and runWs's own try/finally only covers the awaited task promises, not the synchronous event callback. Therefore throw error propagates to Node as an uncaughtException.

Additionally, because the throw happens before this.emitError(...), the realtime_model_error event is never emitted for fatal codes (insufficient_quota, invalid_api_key, account_deactivated, billing_hard_limit_reached — see plugins/openai/src/realtime/realtime_model.ts:129-138), so AgentActivity.onError never sees it and the reconnect/retry logic in #mainTask is bypassed as well.

A safer shape is to emit a non-recoverable error (emitError({ error, recoverable: false })) and let the main task tear the session down, rather than throwing from the socket callback.

Suggested change
const recoverable = !isFatalError(event.error);
const error = new APIError(event.error.message, {
body: event.error,
retryable: recoverable,
});
if (!recoverable) throw error;
this.emitError({ error, recoverable: true });
const recoverable = !isFatalError(event.error);
const error = new APIError(event.error.message, {
body: event.error,
retryable: recoverable,
});
this.emitError({ error, recoverable });
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