Skip to content

Commit 379e79a

Browse files
toubatbriantinalenguyenqionghuang6
authored
Reusable Realtime Session across Handoffs & Agent Tasks (#1193)
Co-authored-by: Tina Nguyen <72938484+tinalenguyen@users.noreply.github.com> Co-authored-by: Qiong Zhou Huang <qiong@phonic.co>
1 parent ed40f9b commit 379e79a

22 files changed

Lines changed: 1549 additions & 261 deletions

.changeset/happy-yaks-bet.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@livekit/agents-plugin-phonic": patch
3+
---
4+
5+
Update phonic plugin to reuse session for handoffs

.changeset/plenty-baths-hug.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@livekit/agents': patch
3+
---
4+
5+
remove rt session say logic and add phonic logic for resetting ws conn

.changeset/sharp-apples-appear.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@livekit/agents": patch
3+
"@livekit/agents-plugin-google": patch
4+
"@livekit/agents-plugin-openai": patch
5+
"@livekit/agents-plugin-phonic": patch
6+
---
7+
8+
- Make reusable Realtime Session across Handoffs & Agent Tasks
9+
- Add say() capability to phonic realtime model

agents/src/llm/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ export {
6868
oaiParams,
6969
serializeImage,
7070
toJsonSchema,
71+
validateChatContextStructure,
72+
type ChatContextValidationIssue,
73+
type ChatContextValidationResult,
74+
type ChatContextValidationSeverity,
7175
type FormatChatHistoryOptions,
7276
type OpenAIFunctionParameters,
7377
type SerializedImage,

agents/src/llm/realtime.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import type { AudioFrame } from '@livekit/rtc-node';
55
import { EventEmitter } from 'events';
66
import type { ReadableStream } from 'node:stream/web';
7-
import { DeferredReadableStream } from '../stream/deferred_stream.js';
7+
import { MultiInputStream } from '../stream/multi_input_stream.js';
88
import { Task } from '../utils.js';
99
import type { TimedString } from '../voice/io.js';
1010
import type { ChatContext, FunctionCall } from './chat_context.js';
@@ -49,6 +49,10 @@ export interface RealtimeCapabilities {
4949
autoToolReplyGeneration: boolean;
5050
audioOutput: boolean;
5151
manualFunctionCalls: boolean;
52+
midSessionChatCtxUpdate?: boolean;
53+
midSessionInstructionsUpdate?: boolean;
54+
midSessionToolsUpdate?: boolean;
55+
perResponseToolChoice?: boolean;
5256
}
5357

5458
export interface InputTranscriptionCompleted {
@@ -84,7 +88,8 @@ export abstract class RealtimeModel {
8488

8589
export abstract class RealtimeSession extends EventEmitter {
8690
protected _realtimeModel: RealtimeModel;
87-
private deferredInputStream = new DeferredReadableStream<AudioFrame>();
91+
private inputAudioStream = new MultiInputStream<AudioFrame>();
92+
private inputAudioStreamId?: string;
8893
private _mainTask: Task<void>;
8994

9095
constructor(realtimeModel: RealtimeModel) {
@@ -146,6 +151,7 @@ export abstract class RealtimeSession extends EventEmitter {
146151

147152
async close(): Promise<void> {
148153
this._mainTask.cancel();
154+
await this.inputAudioStream.close();
149155
}
150156

151157
/**
@@ -156,7 +162,7 @@ export abstract class RealtimeSession extends EventEmitter {
156162
}
157163

158164
private async _mainTaskImpl(signal: AbortSignal): Promise<void> {
159-
const reader = this.deferredInputStream.stream.getReader();
165+
const reader = this.inputAudioStream.stream.getReader();
160166
while (true) {
161167
const { done, value } = await reader.read();
162168
if (done || signal.aborted) {
@@ -167,6 +173,9 @@ export abstract class RealtimeSession extends EventEmitter {
167173
}
168174

169175
setInputAudioStream(audioStream: ReadableStream<AudioFrame>): void {
170-
this.deferredInputStream.setSource(audioStream);
176+
if (this.inputAudioStreamId !== undefined) {
177+
void this.inputAudioStream.removeInputStream(this.inputAudioStreamId);
178+
}
179+
this.inputAudioStreamId = this.inputAudioStream.addInputStream(audioStream);
171180
}
172181
}

agents/src/llm/utils.test.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ import {
1212
FunctionCallOutput,
1313
type ImageContent,
1414
} from './chat_context.js';
15-
import { computeChatCtxDiff, formatChatHistory, serializeImage } from './utils.js';
15+
import {
16+
computeChatCtxDiff,
17+
formatChatHistory,
18+
serializeImage,
19+
validateChatContextStructure,
20+
} from './utils.js';
1621

1722
function createChatMessage(
1823
id: string,
@@ -457,6 +462,87 @@ describe('formatChatHistory', () => {
457462
});
458463
});
459464

465+
describe('validateChatContextStructure', () => {
466+
it('returns valid=true for well-formed chat context', () => {
467+
const ctx = new ChatContext([
468+
ChatMessage.create({
469+
id: 'msg_user',
470+
role: 'user',
471+
content: ['hello'],
472+
createdAt: 1,
473+
}),
474+
FunctionCall.create({
475+
id: 'fn_call',
476+
callId: 'call_1',
477+
name: 'lookup_order',
478+
args: '{"orderId":"123"}',
479+
createdAt: 2,
480+
}),
481+
FunctionCallOutput.create({
482+
id: 'fn_output',
483+
callId: 'call_1',
484+
name: 'lookup_order',
485+
output: '{"ok":true}',
486+
isError: false,
487+
createdAt: 3,
488+
}),
489+
]);
490+
491+
const result = validateChatContextStructure(ctx);
492+
expect(result.valid).toBe(true);
493+
expect(result.errors).toBe(0);
494+
expect(result.warnings).toBe(0);
495+
expect(result.issues).toEqual([]);
496+
});
497+
498+
it('detects duplicate ids and timestamp ordering issues', () => {
499+
const m1 = ChatMessage.create({
500+
id: 'dup_id',
501+
role: 'user',
502+
content: ['hello'],
503+
createdAt: 10,
504+
});
505+
const m2 = ChatMessage.create({
506+
id: 'dup_id',
507+
role: 'assistant',
508+
content: ['world'],
509+
createdAt: 5,
510+
});
511+
const ctx = new ChatContext([m1, m2]);
512+
513+
const result = validateChatContextStructure(ctx);
514+
expect(result.valid).toBe(false);
515+
expect(result.errors).toBeGreaterThanOrEqual(2);
516+
expect(result.issues.some((i) => i.code === 'duplicate_id')).toBe(true);
517+
expect(result.issues.some((i) => i.code === 'timestamp_order')).toBe(true);
518+
});
519+
520+
it('detects malformed terms and orphan function outputs', () => {
521+
const msg = ChatMessage.create({
522+
id: 'msg_1',
523+
role: 'user',
524+
content: [' '],
525+
createdAt: 1,
526+
});
527+
const output = FunctionCallOutput.create({
528+
id: 'fn_out_1',
529+
callId: 'call_missing',
530+
name: 'lookup_order',
531+
output: 'ok',
532+
isError: false,
533+
createdAt: 2,
534+
});
535+
const ctx = new ChatContext([msg, output]);
536+
537+
const result = validateChatContextStructure(ctx);
538+
expect(result.valid).toBe(true);
539+
expect(result.errors).toBe(0);
540+
expect(result.warnings).toBeGreaterThanOrEqual(2);
541+
expect(result.issues.some((i) => i.code === 'empty_text_term')).toBe(true);
542+
expect(result.issues.some((i) => i.code === 'orphan_function_call_output')).toBe(true);
543+
});
544+
});
545+
460546
describe('serializeImage', () => {
461547
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
462548

agents/src/llm/utils.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,33 @@ export interface FormatChatHistoryOptions {
247247
includeTimestamps?: boolean;
248248
}
249249

250+
export type ChatContextValidationSeverity = 'error' | 'warning';
251+
252+
export interface ChatContextValidationIssue {
253+
severity: ChatContextValidationSeverity;
254+
code:
255+
| 'duplicate_id'
256+
| 'timestamp_order'
257+
| 'empty_message_content'
258+
| 'empty_text_term'
259+
| 'missing_image_term'
260+
| 'invalid_audio_term'
261+
| 'invalid_function_call'
262+
| 'invalid_function_call_args'
263+
| 'invalid_function_call_output'
264+
| 'orphan_function_call_output';
265+
index: number;
266+
itemId: string;
267+
message: string;
268+
}
269+
270+
export interface ChatContextValidationResult {
271+
valid: boolean;
272+
errors: number;
273+
warnings: number;
274+
issues: ChatContextValidationIssue[];
275+
}
276+
250277
/**
251278
* Render a chat context into a readable multiline string for debugging and logging.
252279
*/
@@ -273,6 +300,148 @@ export function formatChatHistory(
273300
].join('\n');
274301
}
275302

303+
/**
304+
* Validate structural integrity of chat context items/terms for realtime usage.
305+
*/
306+
export function validateChatContextStructure(chatCtx: ChatContext): ChatContextValidationResult {
307+
const issues: ChatContextValidationIssue[] = [];
308+
const ids = new Set<string>();
309+
const seenFunctionCallIds = new Set<string>();
310+
let previousCreatedAt = -Infinity;
311+
312+
const pushIssue = (issue: ChatContextValidationIssue) => {
313+
issues.push(issue);
314+
};
315+
316+
for (let index = 0; index < chatCtx.items.length; index += 1) {
317+
const item = chatCtx.items[index]!;
318+
319+
if (ids.has(item.id)) {
320+
pushIssue({
321+
severity: 'error',
322+
code: 'duplicate_id',
323+
index,
324+
itemId: item.id,
325+
message: `Duplicate item id '${item.id}'`,
326+
});
327+
} else {
328+
ids.add(item.id);
329+
}
330+
331+
if (item.createdAt < previousCreatedAt) {
332+
pushIssue({
333+
severity: 'error',
334+
code: 'timestamp_order',
335+
index,
336+
itemId: item.id,
337+
message: `Item createdAt (${item.createdAt}) is older than previous item (${previousCreatedAt})`,
338+
});
339+
}
340+
previousCreatedAt = item.createdAt;
341+
342+
if (item.type === 'message') {
343+
if (item.content.length === 0) {
344+
pushIssue({
345+
severity: 'warning',
346+
code: 'empty_message_content',
347+
index,
348+
itemId: item.id,
349+
message: 'Message has empty content array',
350+
});
351+
}
352+
353+
item.content.forEach((term, termIndex) => {
354+
if (typeof term === 'string') {
355+
if (term.trim().length === 0) {
356+
pushIssue({
357+
severity: 'warning',
358+
code: 'empty_text_term',
359+
index,
360+
itemId: item.id,
361+
message: `Message term[${termIndex}] is empty text`,
362+
});
363+
}
364+
return;
365+
}
366+
367+
if (term.type === 'image_content') {
368+
if (!term.id || term.image === undefined || term.image === null) {
369+
pushIssue({
370+
severity: 'error',
371+
code: 'missing_image_term',
372+
index,
373+
itemId: item.id,
374+
message: `Message term[${termIndex}] has invalid image content`,
375+
});
376+
}
377+
return;
378+
}
379+
380+
if (!Array.isArray(term.frame)) {
381+
pushIssue({
382+
severity: 'error',
383+
code: 'invalid_audio_term',
384+
index,
385+
itemId: item.id,
386+
message: `Message term[${termIndex}] has invalid audio content`,
387+
});
388+
}
389+
});
390+
} else if (item.type === 'function_call') {
391+
if (!item.name || !item.callId) {
392+
pushIssue({
393+
severity: 'error',
394+
code: 'invalid_function_call',
395+
index,
396+
itemId: item.id,
397+
message: 'Function call is missing name or callId',
398+
});
399+
} else {
400+
seenFunctionCallIds.add(item.callId);
401+
}
402+
403+
try {
404+
JSON.parse(item.args);
405+
} catch {
406+
pushIssue({
407+
severity: 'warning',
408+
code: 'invalid_function_call_args',
409+
index,
410+
itemId: item.id,
411+
message: 'Function call args are not valid JSON',
412+
});
413+
}
414+
} else if (item.type === 'function_call_output') {
415+
if (!item.callId) {
416+
pushIssue({
417+
severity: 'error',
418+
code: 'invalid_function_call_output',
419+
index,
420+
itemId: item.id,
421+
message: 'Function call output is missing callId',
422+
});
423+
} else if (!seenFunctionCallIds.has(item.callId)) {
424+
pushIssue({
425+
severity: 'warning',
426+
code: 'orphan_function_call_output',
427+
index,
428+
itemId: item.id,
429+
message: `Function call output references unknown callId '${item.callId}'`,
430+
});
431+
}
432+
}
433+
}
434+
435+
const errors = issues.filter((issue) => issue.severity === 'error').length;
436+
const warnings = issues.length - errors;
437+
return {
438+
valid: errors === 0,
439+
errors,
440+
warnings,
441+
issues,
442+
};
443+
}
444+
276445
function formatChatHistoryItem(
277446
item: ChatItem,
278447
index: number,

0 commit comments

Comments
 (0)