@livekit/agents 1.0.30 → 1.0.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/dist/ipc/inference_proc_executor.cjs +6 -3
  2. package/dist/ipc/inference_proc_executor.cjs.map +1 -1
  3. package/dist/ipc/inference_proc_executor.d.ts.map +1 -1
  4. package/dist/ipc/inference_proc_executor.js +6 -3
  5. package/dist/ipc/inference_proc_executor.js.map +1 -1
  6. package/dist/ipc/job_proc_executor.cjs +6 -1
  7. package/dist/ipc/job_proc_executor.cjs.map +1 -1
  8. package/dist/ipc/job_proc_executor.d.ts.map +1 -1
  9. package/dist/ipc/job_proc_executor.js +6 -1
  10. package/dist/ipc/job_proc_executor.js.map +1 -1
  11. package/dist/ipc/job_proc_lazy_main.cjs +1 -1
  12. package/dist/ipc/job_proc_lazy_main.cjs.map +1 -1
  13. package/dist/ipc/job_proc_lazy_main.js +1 -1
  14. package/dist/ipc/job_proc_lazy_main.js.map +1 -1
  15. package/dist/ipc/supervised_proc.cjs +29 -7
  16. package/dist/ipc/supervised_proc.cjs.map +1 -1
  17. package/dist/ipc/supervised_proc.d.ts.map +1 -1
  18. package/dist/ipc/supervised_proc.js +29 -7
  19. package/dist/ipc/supervised_proc.js.map +1 -1
  20. package/dist/ipc/supervised_proc.test.cjs +145 -0
  21. package/dist/ipc/supervised_proc.test.cjs.map +1 -0
  22. package/dist/ipc/supervised_proc.test.js +122 -0
  23. package/dist/ipc/supervised_proc.test.js.map +1 -0
  24. package/dist/job.cjs +5 -1
  25. package/dist/job.cjs.map +1 -1
  26. package/dist/job.d.ts.map +1 -1
  27. package/dist/job.js +5 -1
  28. package/dist/job.js.map +1 -1
  29. package/dist/llm/chat_context.cjs +19 -2
  30. package/dist/llm/chat_context.cjs.map +1 -1
  31. package/dist/llm/chat_context.d.cts +8 -0
  32. package/dist/llm/chat_context.d.ts +8 -0
  33. package/dist/llm/chat_context.d.ts.map +1 -1
  34. package/dist/llm/chat_context.js +19 -2
  35. package/dist/llm/chat_context.js.map +1 -1
  36. package/dist/llm/provider_format/google.cjs +6 -2
  37. package/dist/llm/provider_format/google.cjs.map +1 -1
  38. package/dist/llm/provider_format/google.d.ts.map +1 -1
  39. package/dist/llm/provider_format/google.js +6 -2
  40. package/dist/llm/provider_format/google.js.map +1 -1
  41. package/dist/llm/realtime.cjs.map +1 -1
  42. package/dist/llm/realtime.d.cts +4 -0
  43. package/dist/llm/realtime.d.ts +4 -0
  44. package/dist/llm/realtime.d.ts.map +1 -1
  45. package/dist/llm/realtime.js.map +1 -1
  46. package/dist/log.cjs +3 -3
  47. package/dist/log.cjs.map +1 -1
  48. package/dist/log.d.cts +5 -0
  49. package/dist/log.d.ts +5 -0
  50. package/dist/log.d.ts.map +1 -1
  51. package/dist/log.js +3 -3
  52. package/dist/log.js.map +1 -1
  53. package/dist/stream/stream_channel.cjs +8 -1
  54. package/dist/stream/stream_channel.cjs.map +1 -1
  55. package/dist/stream/stream_channel.d.cts +1 -0
  56. package/dist/stream/stream_channel.d.ts +1 -0
  57. package/dist/stream/stream_channel.d.ts.map +1 -1
  58. package/dist/stream/stream_channel.js +8 -1
  59. package/dist/stream/stream_channel.js.map +1 -1
  60. package/dist/telemetry/otel_http_exporter.cjs +13 -10
  61. package/dist/telemetry/otel_http_exporter.cjs.map +1 -1
  62. package/dist/telemetry/otel_http_exporter.d.ts.map +1 -1
  63. package/dist/telemetry/otel_http_exporter.js +13 -10
  64. package/dist/telemetry/otel_http_exporter.js.map +1 -1
  65. package/dist/telemetry/traces.cjs +22 -4
  66. package/dist/telemetry/traces.cjs.map +1 -1
  67. package/dist/telemetry/traces.d.ts.map +1 -1
  68. package/dist/telemetry/traces.js +22 -4
  69. package/dist/telemetry/traces.js.map +1 -1
  70. package/dist/voice/agent_activity.cjs +25 -5
  71. package/dist/voice/agent_activity.cjs.map +1 -1
  72. package/dist/voice/agent_activity.d.cts +1 -0
  73. package/dist/voice/agent_activity.d.ts +1 -0
  74. package/dist/voice/agent_activity.d.ts.map +1 -1
  75. package/dist/voice/agent_activity.js +26 -6
  76. package/dist/voice/agent_activity.js.map +1 -1
  77. package/dist/voice/generation.cjs +3 -1
  78. package/dist/voice/generation.cjs.map +1 -1
  79. package/dist/voice/generation.d.ts.map +1 -1
  80. package/dist/voice/generation.js +3 -1
  81. package/dist/voice/generation.js.map +1 -1
  82. package/package.json +1 -1
  83. package/src/ipc/inference_proc_executor.ts +11 -3
  84. package/src/ipc/job_proc_executor.ts +11 -1
  85. package/src/ipc/job_proc_lazy_main.ts +1 -1
  86. package/src/ipc/supervised_proc.test.ts +153 -0
  87. package/src/ipc/supervised_proc.ts +27 -9
  88. package/src/job.ts +4 -1
  89. package/src/llm/chat_context.ts +28 -2
  90. package/src/llm/provider_format/google.ts +6 -2
  91. package/src/llm/realtime.ts +5 -0
  92. package/src/log.ts +9 -3
  93. package/src/stream/stream_channel.ts +9 -1
  94. package/src/telemetry/otel_http_exporter.ts +14 -10
  95. package/src/telemetry/traces.ts +28 -4
  96. package/src/voice/agent_activity.ts +27 -2
  97. package/src/voice/generation.ts +2 -0
  98. package/src/llm/__snapshots__/utils.test.ts.snap +0 -65
@@ -0,0 +1,153 @@
1
+ // SPDX-FileCopyrightText: 2024 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import { fork, spawn } from 'node:child_process';
5
+ import { unlinkSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import pidusage from 'pidusage';
9
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
10
+
11
+ const childScript = join(tmpdir(), 'test_child.mjs');
12
+
13
+ beforeAll(() => {
14
+ writeFileSync(
15
+ childScript,
16
+ `process.on('message', (msg) => process.send?.({ echo: msg }));
17
+ setInterval(() => {}, 1000);`,
18
+ );
19
+ });
20
+
21
+ afterAll(() => {
22
+ try {
23
+ unlinkSync(childScript);
24
+ } catch {}
25
+ });
26
+
27
+ async function getChildMemoryUsageMB(pid: number | undefined): Promise<number> {
28
+ if (!pid) return 0;
29
+ try {
30
+ const stats = await pidusage(pid);
31
+ return stats.memory / (1024 * 1024);
32
+ } catch (err) {
33
+ const code = (err as NodeJS.ErrnoException).code;
34
+ if (code === 'ENOENT' || code === 'ESRCH') {
35
+ return 0;
36
+ }
37
+ throw err;
38
+ }
39
+ }
40
+
41
+ describe('pidusage on dead process', () => {
42
+ it('raw pidusage throws on dead pid', async () => {
43
+ const child = spawn('sleep', ['10']);
44
+ const pid = child.pid!;
45
+
46
+ child.kill('SIGKILL');
47
+ await new Promise<void>((r) => child.on('exit', r));
48
+
49
+ await expect(pidusage(pid)).rejects.toThrow();
50
+ });
51
+
52
+ it('fixed version returns 0 instead of crashing', async () => {
53
+ const child = spawn('sleep', ['10']);
54
+ const pid = child.pid!;
55
+
56
+ child.kill('SIGKILL');
57
+ await new Promise<void>((r) => child.on('exit', r));
58
+
59
+ const mem = await getChildMemoryUsageMB(pid);
60
+ expect(mem).toBe(0);
61
+ });
62
+
63
+ it('handles concurrent calls on dying process', async () => {
64
+ const child = spawn('sleep', ['10']);
65
+ const pid = child.pid!;
66
+ const exitPromise = new Promise<void>((r) => child.on('exit', r));
67
+
68
+ child.kill('SIGKILL');
69
+
70
+ const results = await Promise.all([
71
+ getChildMemoryUsageMB(pid),
72
+ getChildMemoryUsageMB(pid),
73
+ getChildMemoryUsageMB(pid),
74
+ ]);
75
+
76
+ await exitPromise;
77
+ expect(results.every((r) => r === 0)).toBe(true);
78
+ });
79
+ });
80
+
81
+ describe('IPC send on dead process', () => {
82
+ it('child.connected becomes false when child dies', async () => {
83
+ const child = fork(childScript, [], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] });
84
+ const exitPromise = new Promise<void>((r) => child.on('exit', r));
85
+
86
+ await new Promise((r) => setTimeout(r, 50));
87
+ expect(child.connected).toBe(true);
88
+
89
+ child.kill('SIGKILL');
90
+ await exitPromise;
91
+
92
+ expect(child.connected).toBe(false);
93
+ });
94
+
95
+ it('checking connected before send prevents crash', async () => {
96
+ const child = fork(childScript, [], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] });
97
+ const exitPromise = new Promise<void>((r) => child.on('exit', r));
98
+
99
+ // Suppress EPIPE errors that can occur due to race conditions between
100
+ // child.connected check and the actual pipe state
101
+ child.on('error', (err: NodeJS.ErrnoException) => {
102
+ if (err.code !== 'EPIPE') throw err;
103
+ });
104
+
105
+ let sent = 0;
106
+ let skipped = 0;
107
+
108
+ const interval = setInterval(() => {
109
+ if (child.connected) {
110
+ child.send({ ping: Date.now() });
111
+ sent++;
112
+ } else {
113
+ skipped++;
114
+ }
115
+ }, 20);
116
+
117
+ await new Promise((r) => setTimeout(r, 60));
118
+ child.kill('SIGKILL');
119
+ await exitPromise;
120
+ await new Promise((r) => setTimeout(r, 80));
121
+ clearInterval(interval);
122
+
123
+ expect(sent).toBeGreaterThan(0);
124
+ expect(skipped).toBeGreaterThan(0);
125
+ });
126
+ });
127
+
128
+ describe('timer cleanup', () => {
129
+ it('clearInterval stops the interval', async () => {
130
+ let count = 0;
131
+ const interval = setInterval(() => count++, 30);
132
+
133
+ await new Promise((r) => setTimeout(r, 80));
134
+ const countAtClear = count;
135
+ clearInterval(interval);
136
+
137
+ await new Promise((r) => setTimeout(r, 80));
138
+ expect(count).toBe(countAtClear);
139
+ });
140
+
141
+ it('double clear is safe', () => {
142
+ const interval = setInterval(() => {}, 100);
143
+ const timeout = setTimeout(() => {}, 1000);
144
+
145
+ clearInterval(interval);
146
+ clearTimeout(timeout);
147
+
148
+ expect(() => {
149
+ clearInterval(interval);
150
+ clearTimeout(timeout);
151
+ }).not.toThrow();
152
+ });
153
+ });
@@ -80,7 +80,9 @@ export abstract class SupervisedProc {
80
80
  await this.init.await;
81
81
 
82
82
  this.#pingInterval = setInterval(() => {
83
- this.proc!.send({ case: 'pingRequest', value: { timestamp: Date.now() } });
83
+ if (this.proc?.connected) {
84
+ this.proc.send({ case: 'pingRequest', value: { timestamp: Date.now() } });
85
+ }
84
86
  }, this.#opts.pingInterval);
85
87
 
86
88
  this.#pongTimeout = setTimeout(() => {
@@ -141,6 +143,7 @@ export abstract class SupervisedProc {
141
143
  });
142
144
 
143
145
  this.proc!.on('exit', () => {
146
+ this.clearTimers();
144
147
  this.#join.resolve();
145
148
  });
146
149
 
@@ -159,11 +162,13 @@ export abstract class SupervisedProc {
159
162
 
160
163
  async initialize() {
161
164
  const timer = setTimeout(() => {
162
- const err = new Error('runner initialization timed out');
163
- this.init.reject(err);
164
- throw err;
165
+ this.init.reject(new Error('runner initialization timed out'));
165
166
  }, this.#opts.initializeTimeout);
166
- this.proc!.send({
167
+ if (!this.proc?.connected) {
168
+ this.init.reject(new Error('process not connected'));
169
+ return;
170
+ }
171
+ this.proc.send({
167
172
  case: 'initializeRequest',
168
173
  value: {
169
174
  loggerOptions,
@@ -187,7 +192,9 @@ export abstract class SupervisedProc {
187
192
  }
188
193
  this.#closing = true;
189
194
 
190
- this.proc!.send({ case: 'shutdownRequest' });
195
+ if (this.proc?.connected) {
196
+ this.proc.send({ case: 'shutdownRequest' });
197
+ }
191
198
 
192
199
  const timer = setTimeout(() => {
193
200
  this.#logger.error('job shutdown is taking too much time');
@@ -203,8 +210,11 @@ export abstract class SupervisedProc {
203
210
  if (this.#runningJob) {
204
211
  throw new Error('executor already has a running job');
205
212
  }
213
+ if (!this.proc?.connected) {
214
+ throw new Error('process not connected');
215
+ }
206
216
  this.#runningJob = info;
207
- this.proc!.send({ case: 'startJobRequest', value: { runningJob: info } });
217
+ this.proc.send({ case: 'startJobRequest', value: { runningJob: info } });
208
218
  }
209
219
 
210
220
  private async getChildMemoryUsageMB(): Promise<number> {
@@ -212,8 +222,16 @@ export abstract class SupervisedProc {
212
222
  if (!pid) {
213
223
  return 0;
214
224
  }
215
- const stats = await pidusage(pid);
216
- return stats.memory / (1024 * 1024); // Convert bytes to MB
225
+ try {
226
+ const stats = await pidusage(pid);
227
+ return stats.memory / (1024 * 1024);
228
+ } catch (err) {
229
+ const code = (err as NodeJS.ErrnoException).code;
230
+ if (code === 'ENOENT' || code === 'ESRCH') {
231
+ return 0;
232
+ }
233
+ throw err;
234
+ }
217
235
  }
218
236
 
219
237
  private clearTimers() {
package/src/job.ts CHANGED
@@ -126,7 +126,10 @@ export class JobContext {
126
126
  this.#onShutdown = onShutdown;
127
127
  this.onParticipantConnected = this.onParticipantConnected.bind(this);
128
128
  this.#room.on(RoomEvent.ParticipantConnected, this.onParticipantConnected);
129
- this.#logger = log().child({ info: this.#info });
129
+ this.#logger = log().child({
130
+ jobId: this.#info.job.id,
131
+ roomName: this.#info.job.room?.name,
132
+ });
130
133
  this.#inferenceExecutor = inferenceExecutor;
131
134
  this._sessionDirectory = path.join(os.tmpdir(), 'livekit-agents', `job-${this.#info.job.id}`);
132
135
  }
@@ -189,19 +189,35 @@ export class FunctionCall {
189
189
 
190
190
  createdAt: number;
191
191
 
192
+ /**
193
+ * Opaque signature for Gemini thinking mode.
194
+ * When using Gemini 3+ models with thinking enabled, this signature must be
195
+ * preserved and returned with function responses to maintain thought context.
196
+ */
197
+ thoughtSignature?: string;
198
+
192
199
  constructor(params: {
193
200
  callId: string;
194
201
  name: string;
195
202
  args: string;
196
203
  id?: string;
197
204
  createdAt?: number;
205
+ thoughtSignature?: string;
198
206
  }) {
199
- const { callId, name, args, id = shortuuid('item_'), createdAt = Date.now() } = params;
207
+ const {
208
+ callId,
209
+ name,
210
+ args,
211
+ id = shortuuid('item_'),
212
+ createdAt = Date.now(),
213
+ thoughtSignature,
214
+ } = params;
200
215
  this.id = id;
201
216
  this.callId = callId;
202
217
  this.args = args;
203
218
  this.name = name;
204
219
  this.createdAt = createdAt;
220
+ this.thoughtSignature = thoughtSignature;
205
221
  }
206
222
 
207
223
  static create(params: {
@@ -210,6 +226,7 @@ export class FunctionCall {
210
226
  args: string;
211
227
  id?: string;
212
228
  createdAt?: number;
229
+ thoughtSignature?: string;
213
230
  }) {
214
231
  return new FunctionCall(params);
215
232
  }
@@ -224,6 +241,10 @@ export class FunctionCall {
224
241
  args: this.args,
225
242
  };
226
243
 
244
+ if (this.thoughtSignature) {
245
+ result.thoughtSignature = this.thoughtSignature;
246
+ }
247
+
227
248
  if (!excludeTimestamp) {
228
249
  result.createdAt = this.createdAt;
229
250
  }
@@ -602,7 +623,12 @@ export class ChatContext {
602
623
  return false;
603
624
  }
604
625
  } else if (a.type === 'function_call' && b.type === 'function_call') {
605
- if (a.name !== b.name || a.callId !== b.callId || a.args !== b.args) {
626
+ if (
627
+ a.name !== b.name ||
628
+ a.callId !== b.callId ||
629
+ a.args !== b.args ||
630
+ a.thoughtSignature !== b.thoughtSignature
631
+ ) {
606
632
  return false;
607
633
  }
608
634
  } else if (a.type === 'function_call_output' && b.type === 'function_call_output') {
@@ -67,13 +67,17 @@ export async function toChatCtx(
67
67
  }
68
68
  }
69
69
  } else if (msg.type === 'function_call') {
70
- parts.push({
70
+ const functionCallPart: Record<string, unknown> = {
71
71
  functionCall: {
72
72
  id: msg.callId,
73
73
  name: msg.name,
74
74
  args: JSON.parse(msg.args || '{}'),
75
75
  },
76
- });
76
+ };
77
+ if (msg.thoughtSignature) {
78
+ functionCallPart.thoughtSignature = msg.thoughtSignature;
79
+ }
80
+ parts.push(functionCallPart);
77
81
  } else if (msg.type === 'function_call_output') {
78
82
  const response = msg.isError ? { error: msg.output } : { output: msg.output };
79
83
  parts.push({
@@ -26,6 +26,8 @@ export interface GenerationCreatedEvent {
26
26
  messageStream: ReadableStream<MessageGeneration>;
27
27
  functionStream: ReadableStream<FunctionCall>;
28
28
  userInitiated: boolean;
29
+ /** Response ID for correlating metrics with spans */
30
+ responseId?: string;
29
31
  }
30
32
 
31
33
  export interface RealtimeModelError {
@@ -63,6 +65,9 @@ export abstract class RealtimeModel {
63
65
  return this._capabilities;
64
66
  }
65
67
 
68
+ /** The model name/identifier used by this realtime model */
69
+ abstract get model(): string;
70
+
66
71
  abstract session(): RealtimeSession;
67
72
 
68
73
  abstract close(): Promise<void>;
package/src/log.ts CHANGED
@@ -62,6 +62,11 @@ class OtelDestination extends Writable {
62
62
  * Enable OTEL logging by reconfiguring the logger with multistream.
63
63
  * Uses a custom destination that receives full JSON logs (with msg, level, time).
64
64
  *
65
+ * The base logger level is set to 'debug' so all logs are generated,
66
+ * while each stream filters to its own level:
67
+ * - Terminal: user-specified level (default: 'info')
68
+ * - OTEL/Cloud: always 'debug' to capture all logs for observability
69
+ *
65
70
  * @internal
66
71
  */
67
72
  export const enableOtelLogging = () => {
@@ -73,11 +78,12 @@ export const enableOtelLogging = () => {
73
78
 
74
79
  const { pretty, level } = loggerOptions;
75
80
 
76
- const logLevel = level || 'info';
81
+ const terminalLevel = level || 'info';
77
82
  const streams: { stream: DestinationStream; level: string }[] = [
78
- { stream: pretty ? pinoPretty({ colorize: true }) : process.stdout, level: logLevel },
83
+ { stream: pretty ? pinoPretty({ colorize: true }) : process.stdout, level: terminalLevel },
79
84
  { stream: new OtelDestination(), level: 'debug' },
80
85
  ];
81
86
 
82
- logger = pino({ level: logLevel }, multistream(streams));
87
+ // Base level must be 'debug' to generate all logs; each stream filters independently
88
+ logger = pino({ level: 'debug' }, multistream(streams));
83
89
  };
@@ -8,25 +8,33 @@ export interface StreamChannel<T> {
8
8
  write(chunk: T): Promise<void>;
9
9
  close(): Promise<void>;
10
10
  stream(): ReadableStream<T>;
11
+ readonly closed: boolean;
11
12
  }
12
13
 
13
14
  export function createStreamChannel<T>(): StreamChannel<T> {
14
15
  const transform = new IdentityTransform<T>();
15
16
  const writer = transform.writable.getWriter();
17
+ let isClosed = false;
16
18
 
17
19
  return {
18
20
  write: (chunk: T) => writer.write(chunk),
19
21
  stream: () => transform.readable,
20
22
  close: async () => {
21
23
  try {
22
- return await writer.close();
24
+ const result = await writer.close();
25
+ isClosed = true;
26
+ return result;
23
27
  } catch (e) {
24
28
  if (e instanceof Error && e.name === 'TypeError') {
25
29
  // Ignore error if the stream is already closed
30
+ isClosed = true;
26
31
  return;
27
32
  }
28
33
  throw e;
29
34
  }
30
35
  },
36
+ get closed() {
37
+ return isClosed;
38
+ },
31
39
  };
32
40
  }
@@ -122,16 +122,20 @@ export class SimpleOTLPHttpLogExporter {
122
122
  }))
123
123
  : [];
124
124
 
125
- const logRecords = records.map((record) => ({
126
- timeUnixNano: String(BigInt(Math.floor(record.timestampMs * 1_000_000))),
127
- observedTimeUnixNano: String(BigInt(Date.now()) * BigInt(1_000_000)),
128
- severityNumber: record.severityNumber ?? SeverityNumber.UNSPECIFIED,
129
- severityText: record.severityText ?? 'unspecified',
130
- body: { stringValue: record.body },
131
- attributes: this.convertAttributes(record.attributes),
132
- traceId: '',
133
- spanId: '',
134
- }));
125
+ const logRecords = records.map((record) => {
126
+ // Ensure timestampMs is a valid number, fallback to current time if NaN/undefined
127
+ const timestampMs = Number.isFinite(record.timestampMs) ? record.timestampMs : Date.now();
128
+ return {
129
+ timeUnixNano: String(BigInt(Math.floor(timestampMs * 1_000_000))),
130
+ observedTimeUnixNano: String(BigInt(Date.now()) * BigInt(1_000_000)),
131
+ severityNumber: record.severityNumber ?? SeverityNumber.UNSPECIFIED,
132
+ severityText: record.severityText ?? 'unspecified',
133
+ body: { stringValue: record.body },
134
+ attributes: this.convertAttributes(record.attributes),
135
+ traceId: '',
136
+ spanId: '',
137
+ };
138
+ });
135
139
 
136
140
  return {
137
141
  resourceLogs: [
@@ -457,9 +457,15 @@ export async function uploadSessionReport(options: {
457
457
  // get reordered by the dashboard
458
458
  let lastTimestamp = 0;
459
459
  for (const item of report.chatHistory.items) {
460
+ // Skip null/undefined items
461
+ if (!item) continue;
462
+
460
463
  // Ensure monotonically increasing timestamps for proper ordering
461
464
  // Add 0.001ms (1 microsecond) offset when timestamps collide
462
- let itemTimestamp = item.createdAt;
465
+ // Also handle undefined/NaN timestamps from realtime mode (defensive)
466
+ const hasValidTimestamp = Number.isFinite(item.createdAt);
467
+ let itemTimestamp = hasValidTimestamp ? item.createdAt : Date.now();
468
+
463
469
  if (itemTimestamp <= lastTimestamp) {
464
470
  itemTimestamp = lastTimestamp + 0.001; // Add 1 microsecond
465
471
  }
@@ -482,6 +488,7 @@ export async function uploadSessionReport(options: {
482
488
  severityText,
483
489
  });
484
490
  }
491
+
485
492
  await logExporter.export(logRecords);
486
493
 
487
494
  const apiKey = process.env.LIVEKIT_API_KEY;
@@ -574,13 +581,30 @@ export async function uploadSessionReport(options: {
574
581
  }
575
582
 
576
583
  if (res.statusCode && res.statusCode >= 400) {
577
- reject(
578
- new Error(`Failed to upload session report: ${res.statusCode} ${res.statusMessage}`),
579
- );
584
+ // Read response body for error details
585
+ let body = '';
586
+ res.on('data', (chunk) => {
587
+ body += chunk.toString();
588
+ });
589
+ res.on('error', (readErr) => {
590
+ reject(
591
+ new Error(
592
+ `Failed to upload session report: ${res.statusCode} ${res.statusMessage} (body read error: ${readErr.message})`,
593
+ ),
594
+ );
595
+ });
596
+ res.on('end', () => {
597
+ reject(
598
+ new Error(
599
+ `Failed to upload session report: ${res.statusCode} ${res.statusMessage} - ${body}`,
600
+ ),
601
+ );
602
+ });
580
603
  return;
581
604
  }
582
605
 
583
606
  res.resume(); // Drain the response
607
+ res.on('error', (readErr) => reject(new Error(`Response read error: ${readErr.message}`)));
584
608
  res.on('end', () => resolve());
585
609
  },
586
610
  );
@@ -37,7 +37,7 @@ import type {
37
37
  } from '../metrics/base.js';
38
38
  import { DeferredReadableStream } from '../stream/deferred_stream.js';
39
39
  import { STT, type STTError, type SpeechEvent } from '../stt/stt.js';
40
- import { traceTypes, tracer } from '../telemetry/index.js';
40
+ import { recordRealtimeMetrics, traceTypes, tracer } from '../telemetry/index.js';
41
41
  import { splitWords } from '../tokenize/basic/word.js';
42
42
  import { TTS, type TTSError } from '../tts/tts.js';
43
43
  import { Future, Task, cancelAndWait, waitFor } from '../utils.js';
@@ -91,6 +91,7 @@ export class AgentActivity implements RecognitionHooks {
91
91
  private started = false;
92
92
  private audioRecognition?: AudioRecognition;
93
93
  private realtimeSession?: RealtimeSession;
94
+ private realtimeSpans?: Map<string, Span>; // Maps response_id to OTEL span for metrics recording
94
95
  private turnDetectionMode?: Exclude<TurnDetectionMode, _TurnDetector>;
95
96
  private logger = log();
96
97
  private _draining = false;
@@ -218,6 +219,7 @@ export class AgentActivity implements RecognitionHooks {
218
219
 
219
220
  if (this.llm instanceof RealtimeModel) {
220
221
  this.realtimeSession = this.llm.session();
222
+ this.realtimeSpans = new Map<string, Span>();
221
223
  this.realtimeSession.on('generation_created', (ev) => this.onGenerationCreated(ev));
222
224
  this.realtimeSession.on('input_speech_started', (ev) => this.onInputSpeechStarted(ev));
223
225
  this.realtimeSession.on('input_speech_stopped', (ev) => this.onInputSpeechStopped(ev));
@@ -505,6 +507,16 @@ export class AgentActivity implements RecognitionHooks {
505
507
  if (speechHandle && (ev.type === 'llm_metrics' || ev.type === 'tts_metrics')) {
506
508
  ev.speechId = speechHandle.id;
507
509
  }
510
+
511
+ // Record realtime metrics on the associated span (if available)
512
+ if (ev.type === 'realtime_model_metrics' && this.realtimeSpans) {
513
+ const span = this.realtimeSpans.get(ev.requestId);
514
+ if (span) {
515
+ recordRealtimeMetrics(span, ev);
516
+ this.realtimeSpans.delete(ev.requestId);
517
+ }
518
+ }
519
+
508
520
  this.agentSession.emit(
509
521
  AgentSessionEventTypes.MetricsCollected,
510
522
  createMetricsCollectedEvent({ metrics: ev }),
@@ -1722,6 +1734,12 @@ export class AgentActivity implements RecognitionHooks {
1722
1734
  throw new Error('llm is not a realtime model');
1723
1735
  }
1724
1736
 
1737
+ // Store span for metrics recording when they arrive later
1738
+ span.setAttribute(traceTypes.ATTR_GEN_AI_REQUEST_MODEL, this.llm.model);
1739
+ if (this.realtimeSpans && ev.responseId) {
1740
+ this.realtimeSpans.set(ev.responseId, span);
1741
+ }
1742
+
1725
1743
  this.logger.debug(
1726
1744
  { speech_id: speechHandle.id, stepIndex: speechHandle.numSteps },
1727
1745
  'realtime generation started',
@@ -2009,7 +2027,13 @@ export class AgentActivity implements RecognitionHooks {
2009
2027
 
2010
2028
  await executeToolsTask.result;
2011
2029
 
2012
- if (toolOutput.output.length === 0) return;
2030
+ if (toolOutput.output.length === 0) {
2031
+ // return to listening state for thinking-only turns (no audio output, no tools)
2032
+ if (!speechHandle.interrupted) {
2033
+ this.agentSession._updateAgentState('listening');
2034
+ }
2035
+ return;
2036
+ }
2013
2037
 
2014
2038
  // important: no agent ouput should be used after this point
2015
2039
  const { maxToolSteps } = this.agentSession.options;
@@ -2274,6 +2298,7 @@ export class AgentActivity implements RecognitionHooks {
2274
2298
  }
2275
2299
 
2276
2300
  this.detachAudioInput();
2301
+ this.realtimeSpans?.clear();
2277
2302
  await this.realtimeSession?.close();
2278
2303
  await this.audioRecognition?.close();
2279
2304
  await this._mainTask?.cancelAndWait();
@@ -442,6 +442,8 @@ export function performLLMInference(
442
442
  callId: `${data.id}/fnc_${data.generatedToolCalls.length}`,
443
443
  name: tool.name,
444
444
  args: tool.args,
445
+ // Preserve thought signature for Gemini 3+ thinking mode
446
+ thoughtSignature: tool.thoughtSignature,
445
447
  });
446
448
 
447
449
  data.generatedToolCalls.push(toolCall);
@@ -1,65 +0,0 @@
1
- // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
-
3
- exports[`oaiParams > common types 1`] = `
4
- {
5
- "additionalProperties": false,
6
- "properties": {
7
- "address": {
8
- "additionalProperties": false,
9
- "properties": {
10
- "city": {
11
- "type": "string",
12
- },
13
- "state": {
14
- "type": "string",
15
- },
16
- "street": {
17
- "type": "string",
18
- },
19
- "zip": {
20
- "type": "string",
21
- },
22
- },
23
- "required": [
24
- "street",
25
- "city",
26
- "state",
27
- "zip",
28
- ],
29
- "type": "object",
30
- },
31
- "age": {
32
- "type": "number",
33
- },
34
- "favoriteColor": {
35
- "enum": [
36
- "red",
37
- "green",
38
- "blue",
39
- ],
40
- "type": "string",
41
- },
42
- "hobbies": {
43
- "items": {
44
- "type": "string",
45
- },
46
- "type": "array",
47
- },
48
- "isStudent": {
49
- "type": "boolean",
50
- },
51
- "name": {
52
- "type": "string",
53
- },
54
- },
55
- "required": [
56
- "name",
57
- "age",
58
- "isStudent",
59
- "hobbies",
60
- "favoriteColor",
61
- "address",
62
- ],
63
- "type": "object",
64
- }
65
- `;