Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/expose-realtime-request-ids.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents': patch
---

Expose realtime provider response IDs on assistant message metrics as `providerRequestIds`.
7 changes: 7 additions & 0 deletions agents/src/llm/chat_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,13 @@ export function createAudioContent(params: {
}

export interface MetricsReport {
/**
* Provider-known request or response IDs associated with this turn.
*
* Assistant `ChatMessage` only. These IDs can be used to correlate a turn with
* provider-side logs.
*/
providerRequestIds?: string[];
startedSpeakingAt?: number;
stoppedSpeakingAt?: number;
transcriptionDelay?: number;
Expand Down
6 changes: 6 additions & 0 deletions agents/src/voice/agent_activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3661,12 +3661,18 @@ export class AgentActivity implements RecognitionHooks {

traceTextParts.push(forwardedText);
if (addToChatCtx) {
const assistantMetrics: MetricsReport = {};
if (ev.responseId) {
assistantMetrics.providerRequestIds = [ev.responseId];
}

const message = ChatMessage.create({
role: 'assistant',
content: forwardedText,
id: output.message.messageId,
interrupted,
createdAt: startedSpeakingAt,
metrics: assistantMetrics,
});
this.agent._chatCtx.insert(message);
speechHandle._itemAdded([message]);
Expand Down
146 changes: 146 additions & 0 deletions agents/src/voice/realtime_message_metrics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
import type { AudioFrame } from '@livekit/rtc-node';
import { ReadableStream } from 'node:stream/web';
import { describe, expect, it } from 'vitest';
import { ChatContext, type FunctionCall } from '../llm/chat_context.js';
import {
type GenerationCreatedEvent,
type MessageGeneration,
type RealtimeCapabilities,
RealtimeModel,
RealtimeSession,
} from '../llm/realtime.js';
import { type ToolChoice, ToolContext } from '../llm/tool_context.js';
import { initializeLogger } from '../log.js';
import { Agent } from './agent.js';
import { AgentSession } from './agent_session.js';
import { AgentSessionEventTypes, type ConversationItemAddedEvent } from './events.js';

initializeLogger({ pretty: false, level: 'silent' });

function emptyStream<T>(): ReadableStream<T> {
return new ReadableStream<T>({
start(controller) {
controller.close();
},
});
}

function oneItemStream<T>(item: T): ReadableStream<T> {
return new ReadableStream<T>({
start(controller) {
controller.enqueue(item);
controller.close();
},
});
}

class FakeRealtimeSession extends RealtimeSession {
private _chatCtx = ChatContext.empty();
private _tools = ToolContext.empty();

get chatCtx(): ChatContext {
return this._chatCtx;
}

get tools(): ToolContext {
return this._tools;
}

async updateInstructions(_instructions: string): Promise<void> {}

async updateChatCtx(chatCtx: ChatContext): Promise<void> {
this._chatCtx = chatCtx.copy();
}

async updateTools(tools: ToolContext): Promise<void> {
this._tools = tools.copy();
}

updateOptions(_options: { toolChoice?: ToolChoice | null }): void {}

pushAudio(_frame: AudioFrame): void {}

async generateReply(): Promise<GenerationCreatedEvent> {
const message: MessageGeneration = {
messageId: 'message-id',
textStream: oneItemStream('Hello'),
audioStream: emptyStream(),
modalities: Promise.resolve(['text']),
};

return {
messageStream: oneItemStream(message),
functionStream: emptyStream<FunctionCall>(),
userInitiated: true,
responseId: 'provider-response-id',
};
}

async commitAudio(): Promise<void> {}

async clearAudio(): Promise<void> {}

async interrupt(): Promise<void> {}

async truncate(): Promise<void> {}
}

class FakeRealtimeModel extends RealtimeModel {
readonly activeSession: FakeRealtimeSession;

constructor() {
const capabilities: RealtimeCapabilities = {
messageTruncation: false,
turnDetection: false,
userTranscription: false,
autoToolReplyGeneration: false,
audioOutput: false,
manualFunctionCalls: false,
midSessionChatCtxUpdate: true,
midSessionInstructionsUpdate: true,
midSessionToolsUpdate: true,
perResponseToolChoice: false,
};
super(capabilities);
this.activeSession = new FakeRealtimeSession(this);
}

get model(): string {
return 'fake-realtime';
}

session(): RealtimeSession {
return this.activeSession;
}

async close(): Promise<void> {}
}

describe('Realtime message metrics', () => {
it('makes realtime response IDs available on assistant messages', async () => {
const llm = new FakeRealtimeModel();
const session = new AgentSession({ llm, vad: null, turnHandling: { turnDetection: null } });
const conversationEvents: ConversationItemAddedEvent[] = [];

session.on(AgentSessionEventTypes.ConversationItemAdded, (ev) => {
conversationEvents.push(ev);
});

await session.start({ agent: new Agent({ instructions: 'test' }) });
try {
await session.generateReply().waitForPlayout();
} finally {
await session.close();
}

const assistantMessages = conversationEvents
.map((event) => event.item)
.filter((item) => item.type === 'message' && item.role === 'assistant');

expect(assistantMessages).toHaveLength(1);
expect(assistantMessages[0]?.metrics.providerRequestIds).toEqual(['provider-response-id']);
});
});
Loading