Skip to content

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

Open
rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
synods-enduring-sinister
Open

fix(xai): align realtime Voice Agent defaults and API coverage#2255
rosetta-livekit-bot[bot] wants to merge 1 commit 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: 13f15af

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-openai Patch
@livekit/agents-plugin-xai 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-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

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`);
}

@devin-ai-integration devin-ai-integration Bot Aug 10, 2026

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 added without documentation comments

The newly added scripted-speech entry point is exported without any documentation comment (say() at agents/src/llm/realtime.ts:173), which the repository's contribution rules require for every new public method, interface or class.
Impact: The generated API documentation for the new public surface is empty, and contributors have no stated contract for the new method.

Affected additions

CONTRIBUTING.md states: "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 public API in this PR:

  • RealtimeSession.say() in agents/src/llm/realtime.ts:173-178 (no TSDoc describing behaviour, thrown error, or the signal option)
  • RealtimeSession.say() override in plugins/xai/src/realtime/realtime_model.ts:159-225
  • ForceMessageItemCreate and ForceMessageCreateEvent in plugins/openai/src/realtime/api_proto.ts:397-401 and 429-432

(The new supportsSay capability is documented and is fine.)

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[bot]

This comment was marked as resolved.

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

View 7 additional findings in Devin Review.

Open in Devin Review

Comment on lines +733 to +744
for (const [index, item] of newChatCtx.items.entries()) {
const remoteItem = remoteCtx.getById(item.id);
if (
remoteItem &&
JSON.stringify(remoteItem.toJSON(true)) !== JSON.stringify(item.toJSON(true)) &&
!diffOps.toRemove.includes(item.id) &&
!diffOps.toCreate.some(([, id]) => id === item.id)
) {
diffOps.toRemove.push(item.id);
diffOps.toCreate.push([newChatCtx.items[index - 1]?.id ?? null, item.id]);
}
}

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.

🟡 Conversation history items are needlessly deleted and re-sent to the model on every history update

Existing history entries are compared against the model's copy using fields the model never returns (JSON.stringify(remoteItem.toJSON(true)) at plugins/openai/src/realtime/realtime_model.ts:737), so entries such as interrupted replies never compare equal and get deleted and re-uploaded on every single history update.
Impact: Each agent handoff or history change makes the assistant re-send the same messages to the provider, adding avoidable latency and round trips and risking history-update timeouts.

Why the comparison never converges: client-only metadata is not mirrored back

computeChatCtxDiff (agents/src/llm/utils.ts:701) is id-based, and this new block adds a content-equality pass. However, the remote mirror is built from server echoes via openAIItemToLivekitItem (plugins/openai/src/realtime/realtime_model.ts:2222-2260), which only sets id, role, and content. ChatMessage.toJSON (agents/src/llm/chat_context.ts:396-419) additionally serializes interrupted, metrics, transcriptConfidence, and extra, and FunctionCallOutput serializes isError.

The agent's own context sets those fields (e.g. interrupted at agents/src/voice/agent_activity.ts:3509, metrics at agents/src/voice/agent_activity.ts:2532). Therefore, for any interrupted assistant message (or error tool output), the JSON strings differ permanently: after the delete+create round trip the server echo still lacks interrupted: true, so the next updateChatCtx schedules the same delete+create again, indefinitely.

A robust check should compare only the fields actually sent to the provider (role plus text/image content), e.g. reuse the message-equality helper that ignores client-only metadata, or compare the serialized livekitItemToOpenAIItem payloads.

Prompt for agents
In plugins/openai/src/realtime/realtime_model.ts, createChatCtxUpdateEvents now augments the id-based diff with a content-change check that compares JSON.stringify(remoteItem.toJSON(true)) against JSON.stringify(item.toJSON(true)). The remote mirror is reconstructed from server echoes by openAIItemToLivekitItem, which only carries id, role and content, while the agent's local items also carry client-only metadata (interrupted, metrics, transcriptConfidence, extra for messages; isError for function call outputs). As a result, items like interrupted assistant messages will always compare as changed, so they are deleted and re-created on the server on every updateChatCtx call, and the comparison never converges. Change the comparison so it only considers state actually mirrored by the provider (for example compare role plus the text/image content, or compare the payload produced by livekitItemToOpenAIItem) so items are only recreated when the provider-visible representation truly differs.
Open in Devin Review

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

Comment on lines +1104 to +1105
const generation = await realtimeSession.say(text, { signal: abortController.signal });
await this.realtimeGenerationTask(speechHandle, generation, {}, abortController);

@devin-ai-integration devin-ai-integration Bot Aug 14, 2026

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 on realtime models keeps waiting after the user interrupts

The scripted-speech step waits for the model's reply without watching for an interruption (await realtimeSession.say(...) at agents/src/voice/agent_activity.ts:1104), so a barge-in during that wait does not release the turn until the model replies or the internal 10 second timeout fires.
Impact: If a user interrupts right after the agent is asked to speak a scripted line, the agent can appear stuck and the next turn is delayed by up to ten seconds.

Divergence from the realtime reply path

SpeechHandle.interrupt() only resolves an interrupt future (agents/src/voice/speech_handle.ts:290-300); it does not abort the speech task's AbortController. Cooperative tasks must therefore race the pending work against the interrupt future.

The realtime reply task does exactly that (agents/src/voice/agent_activity.ts:3870-3876): it wraps generateReply() in speechHandle.waitIfNotInterrupted([generationPromise]), aborts the controller and returns when interrupted (also pre-attaching a catch to avoid an orphan rejection).

realtimeSayTask awaits realtimeSession.say() directly, so the task stays alive (and the speech handle stays not-done) until the xAI session resolves the say future — which only happens on response.created or the 10s say timeout in plugins/xai/src/realtime/realtime_model.ts:202-210.

Open in Devin Review

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

fix(xai): align realtime Voice Agent defaults and add scripted speech

Align xAI Realtime voice defaults, add native scripted speech support via
say(), emit interim and one final user transcript per turn, and settle
rejected realtime chat context updates.

Bring realtime error handling to parity with the Python plugin: treat fatal
codes (quota / auth / billing) as non-recoverable on a failed response.done,
break the receive loop on them instead of reconnecting, and stop reporting
input_audio_buffer_commit_empty as an error when server turn detection is on.

Rebased onto main, which independently landed overlapping realtime work
(isFatalError, chatCtxEventFutures, allSettled, protected members, exported
generation internals, closeCurrentGeneration, the onmessage try/catch) plus a
ToolContext class API and computeChatCtxDiff.toUpdate. Kept main throughout;
only the deltas unique to this branch are retained:
- protected _options/responseCreatedFutures/discardedEventIds (xAI subclass)
- loggableEvent truncates the GA response.output_audio.delta payload
- wsCloseFuture done-guard in onmessage, alongside main wsConn.close()
- input_audio_buffer_commit_empty suppression under server turn detection
- handleError responseCreatedFutures else-branch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
@tinalenguyen
tinalenguyen force-pushed the synods-enduring-sinister branch from 47a9084 to 13f15af Compare August 14, 2026 22:39

@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 +94 to +118
if (this.sessionConnectedAt > 0) {
const realtimeModel = this.realtimeModel;
this.emit('metrics_collected', {
type: 'realtime_model_metrics',
label: realtimeModel.label(),
requestId: 'session_close',
timestamp: Date.now(),
durationMs: 0,
sessionDurationMs: Date.now() - this.sessionConnectedAt,
ttftMs: -1,
cancelled: false,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
tokensPerSecond: 0,
inputTokenDetails: {
audioTokens: 0,
textTokens: 0,
imageTokens: 0,
cachedTokens: 0,
},
outputTokenDetails: { audioTokens: 0, textTokens: 0, imageTokens: 0 },
metadata: { modelName: realtimeModel.model, modelProvider: realtimeModel.provider },
} satisfies metrics.RealtimeModelMetrics);
}

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.

🟡 Session-length usage numbers reported when a voice session ends are never collected

The end-of-session usage report is announced (emit('metrics_collected', ...) at plugins/xai/src/realtime/realtime_model.ts:96) only after the framework has already stopped listening for usage reports from the session, so the numbers are discarded.
Impact: Session duration for xAI realtime sessions never shows up in metrics or usage totals.

Mechanism: listener is detached before the session is closed

AgentActivity._closeSessionResources removes the metrics listener from the realtime session (agents/src/voice/agent_activity.ts:5104) and only afterwards calls await this.realtimeSession?.close() (agents/src/voice/agent_activity.ts:5136). The new xAI close() override emits metrics_collected inside that close call, so by then no listener exists and onMetricsCollected (which feeds _usageCollector and the MetricsCollected event) is never invoked. The same holds for the handoff/detach path, which also calls off('metrics_collected', ...) before closing (agents/src/voice/agent_activity.ts:837).

Prompt for agents
plugins/xai/src/realtime/realtime_model.ts close() emits a 'metrics_collected' event carrying sessionDurationMs, but AgentActivity detaches its metrics listener from the realtime session before calling realtimeSession.close() (agents/src/voice/agent_activity.ts around lines 5104 and 5136, and the handoff detach path around line 837). As a result the session-close metric is always dropped. Either emit the session metric at a point where the framework is still subscribed (e.g. when the websocket connection ends / before the framework tears down, or from a session-level hook), or change the framework teardown order so the realtime session is closed before its metrics listener is removed.
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.

1 participant