@kuralle-syrinx/core 4.1.0 → 4.3.0

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 (48) hide show
  1. package/README.md +4 -0
  2. package/package.json +22 -3
  3. package/src/audio/alaw.ts +56 -0
  4. package/src/audio/g722.ts +329 -0
  5. package/src/audio/index.ts +12 -0
  6. package/src/audio/loudness.ts +87 -0
  7. package/src/confidence-to-wait.ts +30 -0
  8. package/src/idle-timeout.ts +1 -0
  9. package/src/incremental-unit.ts +19 -0
  10. package/src/index.ts +101 -1
  11. package/src/interaction-coordinator.ts +240 -0
  12. package/src/interaction-policy.ts +116 -0
  13. package/src/iu-ledger.ts +110 -0
  14. package/src/observability-observer.ts +8 -4
  15. package/src/observability.ts +30 -1
  16. package/src/packet-factories.ts +137 -2
  17. package/src/packets.ts +140 -4
  18. package/src/plugin-contract.ts +41 -0
  19. package/src/policies/defer.ts +15 -0
  20. package/src/policies/rule-based.ts +155 -0
  21. package/src/pricing.ts +137 -0
  22. package/src/primary-speaker-gate.ts +23 -3
  23. package/src/reasoner-hedge.ts +216 -0
  24. package/src/reasoner-route.ts +113 -0
  25. package/src/reasoner.ts +28 -1
  26. package/src/spend-cap.ts +52 -0
  27. package/src/turn-arbiter.ts +45 -4
  28. package/src/voice-agent-session.ts +682 -53
  29. package/src/voice-text.ts +69 -0
  30. package/src/audio/audio.test.ts +0 -285
  31. package/src/audio-envelope.test.ts +0 -167
  32. package/src/error-handler.test.ts +0 -56
  33. package/src/latency-filler.test.ts +0 -62
  34. package/src/observability-observer.test.ts +0 -245
  35. package/src/observability.test.ts +0 -85
  36. package/src/packet-factories.test.ts +0 -34
  37. package/src/pipeline-bus.g10.test.ts +0 -145
  38. package/src/pipeline-bus.test.ts +0 -211
  39. package/src/primary-speaker-gate.test.ts +0 -150
  40. package/src/provider-fallback.test.ts +0 -87
  41. package/src/reasoner.test.ts +0 -69
  42. package/src/retry.test.ts +0 -83
  43. package/src/tts-playout-clock.test.ts +0 -125
  44. package/src/turn-arbiter.characterization.test.ts +0 -477
  45. package/src/turn-arbiter.test.ts +0 -567
  46. package/src/voice-agent-session.test.ts +0 -2879
  47. package/src/voice-text.test.ts +0 -94
  48. package/tsconfig.json +0 -21
@@ -31,6 +31,10 @@ import type {
31
31
  InterruptionDetectedPacket,
32
32
  DelegateQueryPacket,
33
33
  DelegateResultPacket,
34
+ ConversationMetricPacket,
35
+ AcousticSignalPacket,
36
+ UsageStage,
37
+ UsageRecordedPacket,
34
38
  LlmDeltaPacket,
35
39
  LlmResponseDonePacket,
36
40
  LlmToolCallPacket,
@@ -41,6 +45,8 @@ import type {
41
45
  SpeechToTextAudioPacket,
42
46
  SttResultPacket,
43
47
  SttInterimPacket,
48
+ SttPartialPacket,
49
+ TurnChangePacket,
44
50
  VadAudioPacket,
45
51
  VadSpeechStartedPacket,
46
52
  VadSpeechActivityPacket,
@@ -48,6 +54,7 @@ import type {
48
54
  EndOfSpeechPacket,
49
55
  InterimEndOfSpeechPacket,
50
56
  InjectMessagePacket,
57
+ SttReconfigurePacket,
51
58
  DisconnectRequestedPacket,
52
59
  InitializationFailedPacket,
53
60
  ModeSwitchRequestedPacket,
@@ -61,9 +68,20 @@ import {
61
68
  } from "./packets.js";
62
69
  import { LatencyFillerController } from "./latency-filler.js";
63
70
  import { PrimarySpeakerGate } from "./primary-speaker-gate.js";
64
- import { takeCompleteVoiceText, isCompleteVoiceText, appendVoiceText } from "./voice-text.js";
71
+ import { takeCompleteVoiceText, isCompleteVoiceText, appendVoiceText, normalizeForSpeech } from "./voice-text.js";
65
72
  import { TtsPlayoutClock } from "./tts-playout-clock.js";
66
73
  import { TurnArbiter, isBackchannel } from "./turn-arbiter.js";
74
+ import { InteractionCoordinator } from "./interaction-coordinator.js";
75
+ import { isLifecycleInteractionPolicy, type InteractionPolicy, type WordTiming } from "./interaction-policy.js";
76
+ import { pcm16BytesToSamples, pcm16SamplesToBytes } from "./audio/pcm.js";
77
+ import {
78
+ createLoudnessState,
79
+ normalizeLoudness,
80
+ type LoudnessConfig,
81
+ type LoudnessState,
82
+ } from "./audio/loudness.js";
83
+ import { DeferInteractionPolicy } from "./policies/defer.js";
84
+ import { RuleBasedInteractionPolicy } from "./policies/rule-based.js";
67
85
  import * as make from "./packet-factories.js";
68
86
  import { pluginStage, stageOrder, isAudioStage } from "./init-stage-order.js";
69
87
  import {
@@ -73,6 +91,7 @@ import {
73
91
  VoiceSessionWatchdogs,
74
92
  } from "./voice-agent-session-util.js";
75
93
  import { noopMetricsExporter, type MetricsExporter } from "./observability.js";
94
+ import { localizeTurn, type ObservabilityLayer } from "./observability.js";
76
95
  import { ObservabilityObserver } from "./observability-observer.js";
77
96
  import { TimerScheduler, type Scheduler } from "./scheduler.js";
78
97
 
@@ -134,6 +153,8 @@ export interface VoiceAgentSessionConfig {
134
153
  * instead of hanging. 0 disables. Default: 15000.
135
154
  */
136
155
  ttsStallMs?: number;
156
+ /** Optional per-session Int16 RMS normalization for outbound assistant audio. Disabled by default. */
157
+ outboundLoudness?: LoudnessConfig;
137
158
  /**
138
159
  * Max ms of silence on inbound user audio while the session is Ready before a
139
160
  * recoverable transport warning is emitted. Continuous streams (telephony, open
@@ -162,6 +183,21 @@ export interface VoiceAgentSessionConfig {
162
183
  * provider STT ownership; Smart Turn sessions must opt in explicitly.
163
184
  */
164
185
  endpointingOwner?: "provider_stt" | "smart_turn" | "timer";
186
+ /** The front model owns full-duplex interaction (turn-taking + barge-in). When true, the session's
187
+ * InteractionPolicy runs observe-only (DeferInteractionPolicy) — Syrinx does not drive its own
188
+ * turn/interrupt decisions; the front's native decisions stand. Default: false (Syrinx drives).
189
+ * A realtime factory sets this from RealtimeAdapter.caps.supportsFullDuplex. */
190
+ fullDuplex?: boolean;
191
+ /** When true, Syrinx suppresses policy-timed backchannel cue packets (front/provider owns them). */
192
+ emitsBackchannel?: boolean;
193
+ /**
194
+ * Optional interaction policy injected by the caller (learned controllers, Smart Turn policy, etc.).
195
+ * When omitted, the session uses RuleBasedInteractionPolicy. When `fullDuplex` is true, the
196
+ * coordinator runs observe-only via DeferInteractionPolicy regardless of this setting.
197
+ */
198
+ interactionPolicy?: InteractionPolicy;
199
+ /** Config passed to `interactionPolicy.initialize` when the injected policy is lifecycle-capable. */
200
+ interactionPolicyConfig?: Record<string, unknown>;
165
201
  readonly metricsExporter?: MetricsExporter;
166
202
  readonly scheduler?: Scheduler;
167
203
  readonly observability?: {
@@ -169,6 +205,7 @@ export interface VoiceAgentSessionConfig {
169
205
  readonly provider?: string;
170
206
  readonly model?: string;
171
207
  readonly region?: string;
208
+ readonly layer?: ObservabilityLayer;
172
209
  };
173
210
  }
174
211
 
@@ -181,7 +218,18 @@ export interface VoiceAgentSessionEvents {
181
218
  agent_tool_call: (event: { tsMs: number; turnId: string; id: string; name: string; args: Record<string, unknown> }) => void;
182
219
  agent_tool_result: (event: { tsMs: number; turnId: string; id: string; result: string; durationMs: number }) => void;
183
220
  delegate_query: (event: { tsMs: number; turnId: string; query: string; toolId?: string; toolName?: string }) => void;
184
- delegate_result: (event: { tsMs: number; turnId: string; query: string; answer: string; durationMs: number; grounded: boolean; toolId?: string; toolName?: string }) => void;
221
+ delegate_result: (event: {
222
+ tsMs: number;
223
+ turnId: string;
224
+ query: string;
225
+ answer: string;
226
+ durationMs: number;
227
+ grounded: boolean;
228
+ toolId?: string;
229
+ toolName?: string;
230
+ control?: { name: string; payload: unknown };
231
+ blocked?: { userFacingMessage: string; payload?: unknown };
232
+ }) => void;
185
233
  /**
186
234
  * G3: typed preamble/filler lifecycle for a pending tool call (Vapi-shaped:
187
235
  * started / delayed / complete / failed). `delayed` is time-triggered by
@@ -191,29 +239,60 @@ export interface VoiceAgentSessionEvents {
191
239
  */
192
240
  tool_call_cue: (event: { tsMs: number; turnId: string; phase: "started" | "delayed" | "complete" | "failed"; toolId: string; toolName: string; afterMs?: number }) => void;
193
241
  /**
194
- * Per-turn latency decomposition, emitted once at the turn's first TTS audio.
242
+ * Per-turn latency decomposition, timestamped at the turn's first TTS audio.
243
+ * When generation is still active at first audio, emission waits for `tts.end` so
244
+ * provider passes that follow a spoken tool preamble are included in the totals.
195
245
  * `ttfaMs` is anchored to the real end of user speech (VAD speech-end, falling
196
- * back to the endpoint decision) — and `fillerUsed` flags turns where a latency
197
- * filler spoke first, so TTFA on those turns is not mistaken for reasoning speed.
246
+ * back to the endpoint decision) — `fillerUsed` flags turns where a latency filler spoke
247
+ * first, and `backchannelUsed` flags turns where a wait-gap cue played before the answer.
198
248
  * Decomposition: eouDelayMs (speech end → endpoint) + llmTtftMs (endpoint →
199
- * first LLM delta) + ttsTtfbMs (first TTS text dispatched → first audio).
249
+ * first LLM delta) + textAggregationMs (first LLM delta → first TTS text) +
250
+ * ttsTtfbMs (first TTS text dispatched → first audio). A stage is omitted when the
251
+ * front does not produce that Syrinx packet for this turn: realtime fronts may emit
252
+ * provider audio directly, and provider-owned endpointing may complete after audio.
253
+ * `unattributedMs` is the explicit residual after subtracting only the stages present.
200
254
  */
201
255
  turn_latency: (event: {
202
256
  tsMs: number;
203
257
  turnId: string;
204
258
  ttfaMs: number;
259
+ anchor: "speech_end" | "eos";
205
260
  eouDelayMs?: number;
206
261
  llmTtftMs?: number;
262
+ textAggregationMs?: number;
207
263
  ttsTtfbMs?: number;
264
+ unattributedMs: number;
265
+ llmCallCount?: number;
266
+ llmPassTtftMs?: readonly number[];
208
267
  fillerUsed: boolean;
268
+ backchannelUsed: boolean;
209
269
  }) => void;
210
270
  agent_first_audio: (event: { tsMs: number; turnId: string }) => void;
211
271
  agent_finished: (event: { tsMs: number; turnId: string } & Record<string, unknown>) => void;
272
+ /**
273
+ * End-of-session usage manifest — the total billable resource this session consumed,
274
+ * summed per stage. Emitted once at close. This is the metering seam a host reads to
275
+ * bill, cap spend, or attribute cost to a tenant; today only the LLM stage is populated
276
+ * (STT/TTS producers are not yet wired), so `stages` may hold a single entry.
277
+ */
278
+ usage: (event: { tsMs: number; stages: readonly SessionStageUsage[] }) => void;
212
279
  error: (event: { tsMs: number; stage: string; category: string; message: string }) => void;
213
280
  closed: (event: { tsMs: number; reason: string }) => void;
214
281
  state_changed: (event: { tsMs: number; from: SessionState; to: SessionState }) => void;
215
282
  }
216
283
 
284
+ /** Per-stage usage totals for a session. Absent fields were never reported by that stage. */
285
+ export interface SessionStageUsage {
286
+ readonly stage: UsageStage;
287
+ readonly inputTokens?: number;
288
+ readonly outputTokens?: number;
289
+ readonly totalTokens?: number;
290
+ readonly cachedInputTokens?: number;
291
+ readonly reasoningTokens?: number;
292
+ readonly audioSeconds?: number;
293
+ readonly characters?: number;
294
+ }
295
+
217
296
  type EventHandler<T> = (event: T) => void;
218
297
 
219
298
  interface TtsTextBuffer {
@@ -226,7 +305,10 @@ interface TurnTiming {
226
305
  eosMs?: number;
227
306
  firstLlmDeltaMs?: number;
228
307
  firstTtsTextMs?: number;
308
+ llmCallCount?: number;
309
+ llmPassTtftMs?: number[];
229
310
  fillerUsed?: boolean;
311
+ backchannelUsed?: boolean;
230
312
  }
231
313
 
232
314
  /** Suffix marking a context created to speak an error fallback, so it never recurses. */
@@ -237,6 +319,29 @@ function toolCueTimerKey(contextId: string, toolId: string): string {
237
319
  return `tool_cue:${contextId}:${toolId}`;
238
320
  }
239
321
 
322
+ function interactionPlayoutTimerKey(contextId: string): string {
323
+ return `interaction.playout:${contextId}`;
324
+ }
325
+
326
+ function turnLocalizationTimerKey(contextId: string): string {
327
+ return `turn.localization:${contextId}`;
328
+ }
329
+
330
+ function isInfrastructureMetric(name: string): boolean {
331
+ return /(?:^|\.)(?:vad|stt|tts|tool|input|pipeline)\.(?:error|malfunction|timeout|stall|cadence)|(?:^|\.)(?:stage|error)\./i.test(name);
332
+ }
333
+
334
+ function isConversationEvaluationMetric(name: string, value: string): boolean {
335
+ if (!/(?:^|\.)(?:outcome|eval|evaluation|satisfaction|task_success|observer)(?:\.|$)/i.test(name)) {
336
+ return false;
337
+ }
338
+ return !/^(?:0|false|none|success|successful|passed|pass|good|healthy|ok)$/i.test(value.trim());
339
+ }
340
+
341
+ function isConversationControlName(name: string): boolean {
342
+ return /^(?:conversation-outcome|outcome|escalation|handoff|flow-transition|node-enter|node-exit|flow-enter|flow-end)$/.test(name);
343
+ }
344
+
240
345
  // =============================================================================
241
346
  // Session Implementation
242
347
  // =============================================================================
@@ -268,25 +373,50 @@ export class VoiceAgentSession {
268
373
  private ttsTextBuffers = new Map<string, TtsTextBuffer>();
269
374
  private readonly minInterruptionMs: number;
270
375
  private readonly primarySpeakerGate: PrimarySpeakerGate;
271
- private readonly turnArbiter!: TurnArbiter;
376
+ private readonly ruleBasedPolicy!: RuleBasedInteractionPolicy;
377
+ private readonly injectedInteractionPolicy: InteractionPolicy | null;
378
+ private readonly activeInteractionPolicy: InteractionPolicy;
379
+ private readonly interaction!: InteractionCoordinator;
272
380
  private readonly latencyFiller: LatencyFillerController;
273
381
  private firstLlmDeltaReceived = new Set<string>();
274
382
  private readonly vaqiMissedResponseMs: number;
275
383
  private readonly ttsStallMs: number;
384
+ private readonly outboundLoudnessConfig: LoudnessConfig | null;
385
+ private readonly outboundLoudnessState: LoudnessState | null;
276
386
  private readonly inputCadenceTimeoutMs: number;
277
387
  private readonly watchdogs!: VoiceSessionWatchdogs;
278
388
  private readonly observabilityObserver: ObservabilityObserver;
389
+ private readonly metricsExporter: MetricsExporter;
390
+ private readonly observabilityDims: {
391
+ provider: string;
392
+ model: string;
393
+ region: string;
394
+ layer?: ObservabilityLayer;
395
+ };
279
396
  private turnUserStoppedAtMs = new Map<string, number>();
280
397
  private turnTimings = new Map<string, TurnTiming>();
398
+ private pendingTurnLatency = new Map<string, number>();
399
+ /** Running usage totals per stage, summed across the session; emitted at close. */
400
+ private readonly usageByStage = new Map<UsageStage, SessionStageUsage>();
281
401
  private speakerEnrollmentContextId: string | null = null;
282
402
  private firstTtsAudioFired = new Set<string>();
403
+ private readonly pendingInteractionPlayoutTimers = new Set<string>();
283
404
  private readonly errorFallbackText: string;
284
405
  private fallbackInjectedContexts = new Set<string>();
285
406
  // G3: pending tool calls per context (toolId → toolName) driving the tool_call_cue lifecycle.
286
407
  private readonly delayCueAfterMs: number;
287
408
  private pendingToolCues = new Map<string, Map<string, string>>();
288
409
  private readonly endpointingOwner: "provider_stt" | "smart_turn" | "timer";
410
+ private readonly fullDuplex: boolean;
411
+ private readonly emitsBackchannel: boolean;
412
+ private userSpeaking = false;
289
413
  private lastFinalizedContextId = "";
414
+ private readonly sttPartialWordTimings = new Map<string, readonly WordTiming[]>();
415
+ private readonly turnLocalizationStates = new Map<
416
+ string,
417
+ { infrastructureBreached: boolean; conversationFlagged: boolean }
418
+ >();
419
+ private readonly emittedTurnLocalizations = new Set<string>();
290
420
 
291
421
  constructor(config: VoiceAgentSessionConfig) {
292
422
  const owner = config.endpointingOwner;
@@ -294,7 +424,11 @@ export class VoiceAgentSession {
294
424
  throw new Error(`Unsupported endpointingOwner: ${owner}`);
295
425
  }
296
426
  this.endpointingOwner = owner ?? "provider_stt";
427
+ this.fullDuplex = config.fullDuplex === true;
428
+ this.emitsBackchannel = config.emitsBackchannel === true;
297
429
  this.config = config;
430
+ this.outboundLoudnessConfig = config.outboundLoudness ?? null;
431
+ this.outboundLoudnessState = this.outboundLoudnessConfig ? createLoudnessState() : null;
298
432
  this.scheduler = config.scheduler ?? new TimerScheduler();
299
433
  this.ttsPlayout = new TtsPlayoutClock(this.scheduler);
300
434
  this.sttForceFinalizeTimeoutMs = config.sttForceFinalizeTimeoutMs ?? 7000;
@@ -302,6 +436,14 @@ export class VoiceAgentSession {
302
436
  this.delayCueAfterMs = config.delayCueAfterMs ?? 2000;
303
437
  this.primarySpeakerGate = new PrimarySpeakerGate({
304
438
  enabled: config.primarySpeakerBargeInEnabled !== false,
439
+ onDecision: (decision) => {
440
+ this.emitAcousticSignal(decision.contextId, Date.now(), decision.signal, {
441
+ accepted: decision.accepted,
442
+ frameCount: decision.frameCount,
443
+ primaryHits: decision.primaryHits,
444
+ echoRejectedFrames: decision.echoRejectedFrames,
445
+ });
446
+ },
305
447
  });
306
448
  this.latencyFiller = new LatencyFillerController({
307
449
  enabled: config.latencyFillerEnabled === true,
@@ -344,12 +486,32 @@ export class VoiceAgentSession {
344
486
  },
345
487
  });
346
488
 
347
- this.turnArbiter = new TurnArbiter({
489
+ this.injectedInteractionPolicy = config.interactionPolicy ?? null;
490
+ this.ruleBasedPolicy = new RuleBasedInteractionPolicy({
348
491
  bus: this.bus,
349
492
  primarySpeakerGate: this.primarySpeakerGate,
350
493
  ttsPlayout: this.ttsPlayout,
351
494
  minInterruptionMs: this.minInterruptionMs,
352
495
  });
496
+ const coordinatorPolicy: InteractionPolicy = this.fullDuplex
497
+ ? new DeferInteractionPolicy()
498
+ : (this.injectedInteractionPolicy ?? this.ruleBasedPolicy);
499
+ this.activeInteractionPolicy = coordinatorPolicy;
500
+ coordinatorPolicy.setAcousticSignalSink?.((signal) => {
501
+ this.emitAcousticSignal(signal.contextId, signal.timestampMs, signal.signal, signal.payload);
502
+ });
503
+ this.interaction = new InteractionCoordinator({
504
+ bus: this.bus,
505
+ policy: coordinatorPolicy,
506
+ executor: this.ruleBasedPolicy.arbiter,
507
+ scheduler: this.scheduler,
508
+ caps: { emitsBackchannel: this.emitsBackchannel },
509
+ isUserSpeaking: () => this.userSpeaking,
510
+ isTtsActive: () => this.ttsPlayout.activeContexts().length > 0,
511
+ onBackchannelEmitted: (contextId) => {
512
+ this.timingFor(contextId).backchannelUsed = true;
513
+ },
514
+ });
353
515
  this.watchdogs = new VoiceSessionWatchdogs({
354
516
  bus: this.bus,
355
517
  plugins: this.plugins,
@@ -367,15 +529,18 @@ export class VoiceAgentSession {
367
529
  });
368
530
 
369
531
  const obs = config.observability;
532
+ this.metricsExporter = config.metricsExporter ?? noopMetricsExporter;
533
+ this.observabilityDims = {
534
+ provider: obs?.provider ?? "unknown",
535
+ model: obs?.model ?? "unknown",
536
+ region: obs?.region ?? "unknown",
537
+ layer: obs?.layer,
538
+ };
370
539
  this.observabilityObserver = new ObservabilityObserver({
371
540
  bus: this.bus,
372
- exporter: config.metricsExporter ?? noopMetricsExporter,
541
+ exporter: this.metricsExporter,
373
542
  sessionId: obs?.sessionId ?? "",
374
- dims: {
375
- provider: obs?.provider ?? "unknown",
376
- model: obs?.model ?? "unknown",
377
- region: obs?.region ?? "unknown",
378
- },
543
+ dims: this.observabilityDims,
379
544
  getContextId: () => this.currentContextId,
380
545
  });
381
546
 
@@ -386,6 +551,10 @@ export class VoiceAgentSession {
386
551
  this.modeSwitcher = new ModeSwitcher(this.bus);
387
552
  }
388
553
 
554
+ private get turnArbiter(): TurnArbiter {
555
+ return this.ruleBasedPolicy.arbiter;
556
+ }
557
+
389
558
  // =========================================================================
390
559
  // Public API
391
560
  // =========================================================================
@@ -412,6 +581,7 @@ export class VoiceAgentSession {
412
581
 
413
582
  // 1. Wire all bus handlers
414
583
  this.wireBusHandlers();
584
+ this.interaction.initialize();
415
585
 
416
586
  // 2. Start bus drain loop
417
587
  this.busStartPromise = this.bus.start();
@@ -430,6 +600,28 @@ export class VoiceAgentSession {
430
600
  this._state = SessionState.Ready;
431
601
  }
432
602
 
603
+ /**
604
+ * Best-effort warm of registered plugins' remote/expensive resources. Call after start() to
605
+ * wake scaled-to-zero endpoints before the first user turn. The host decides when to invoke
606
+ * this — it is not called automatically from start(). Never throws; failed plugin prewarms
607
+ * are swallowed and may emit a prewarm.failed metric on the bus.
608
+ */
609
+ async prewarm(): Promise<void> {
610
+ const entries = [...this.plugins.entries()];
611
+ const results = await Promise.allSettled(
612
+ entries.map(async ([, plugin]) => {
613
+ await plugin.prewarm?.();
614
+ }),
615
+ );
616
+ for (let i = 0; i < results.length; i++) {
617
+ const result = results[i];
618
+ if (result?.status === "rejected") {
619
+ const [name] = entries[i]!;
620
+ this.bus.push(Route.Background, make.metric("", "prewarm.failed", name));
621
+ }
622
+ }
623
+ }
624
+
433
625
  /** Shut down the session. Runs finalize chain in reverse order. */
434
626
  async close(): Promise<void> {
435
627
  if (this._state === SessionState.Closed) return;
@@ -443,17 +635,30 @@ export class VoiceAgentSession {
443
635
 
444
636
  // 1. Stop idle timeout
445
637
  this.idleTimeout.dispose();
638
+ this.interaction.dispose();
446
639
  this.watchdogs.dispose();
447
640
  this.observabilityObserver.dispose();
448
641
  this.ttsPlayout.clear();
642
+ for (const contextId of this.pendingInteractionPlayoutTimers) {
643
+ this.scheduler.cancel(interactionPlayoutTimerKey(contextId));
644
+ }
645
+ this.pendingInteractionPlayoutTimers.clear();
449
646
  this.turnArbiter.clear();
450
647
  this.turnUserStoppedAtMs.clear();
451
648
  this.turnTimings.clear();
649
+ for (const contextId of this.turnLocalizationStates.keys()) {
650
+ this.scheduler.cancel(turnLocalizationTimerKey(contextId));
651
+ this.emitTurnLocalization(contextId);
652
+ }
653
+ this.turnLocalizationStates.clear();
654
+ this.emittedTurnLocalizations.clear();
655
+ this.pendingTurnLatency.clear();
452
656
  this.firstTtsAudioFired.clear();
453
657
  this.fallbackInjectedContexts.clear();
454
658
  this.ttsTextBuffers.clear();
455
659
  this.interruptedGenerationContextIds.clear();
456
660
  this.firstLlmDeltaReceived.clear();
661
+ this.sttPartialWordTimings.clear();
457
662
  for (const [contextId, pending] of this.pendingToolCues) {
458
663
  for (const toolId of pending.keys()) this.scheduler.cancel(toolCueTimerKey(contextId, toolId));
459
664
  }
@@ -462,6 +667,9 @@ export class VoiceAgentSession {
462
667
  // 2. Run finalize chain (reverse order)
463
668
  await runFinalizeChain(this.initSteps);
464
669
 
670
+ // 2b. Emit the end-of-session usage manifest before the bus stops — the metering seam.
671
+ this.emitSessionUsage();
672
+
465
673
  // 3. Stop bus
466
674
  this.bus.stop();
467
675
  await this.busStartPromise;
@@ -538,6 +746,7 @@ export class VoiceAgentSession {
538
746
  // STT results
539
747
  this.bus.on("stt.audio", this.handleSttAudio.bind(this));
540
748
  this.bus.on("stt.interim", this.handleSttInterim.bind(this));
749
+ this.bus.on("stt.partial", this.handleSttPartial.bind(this));
541
750
  this.bus.on("stt.result", this.handleSttResult.bind(this));
542
751
 
543
752
  // VAD
@@ -547,8 +756,12 @@ export class VoiceAgentSession {
547
756
  this.bus.on("vad.audio", this.handleVadAudioForSpeakerGate.bind(this));
548
757
 
549
758
  // EOS
550
- this.bus.on("turn.change", () => {
759
+ this.bus.on("turn.change", (pkt: TurnChangePacket) => {
551
760
  this.lastFinalizedContextId = "";
761
+ if (pkt.previousContextId) {
762
+ this.sttPartialWordTimings.delete(pkt.previousContextId);
763
+ this.carryTurnTimingAcrossContextChange(pkt.previousContextId, pkt.contextId);
764
+ }
552
765
  });
553
766
  this.bus.on("eos.turn_complete", this.handleTurnComplete.bind(this));
554
767
  this.bus.on("eos.interim", this.handleEosInterim.bind(this));
@@ -558,6 +771,8 @@ export class VoiceAgentSession {
558
771
  this.bus.on("llm.done", this.handleLlmDone.bind(this));
559
772
  this.bus.on("llm.tool_call", this.handleLlmToolCall.bind(this));
560
773
  this.bus.on("llm.tool_result", this.handleLlmToolResult.bind(this));
774
+ this.bus.on("metric.conversation", this.handleConversationMetric.bind(this));
775
+ this.bus.on("usage.recorded", (pkt) => this.handleUsageRecorded(pkt as UsageRecordedPacket));
561
776
 
562
777
  // Delegate (Responder-Thinker) observability — G2, RFC bimodel-delegate-seam
563
778
  this.bus.on("delegate.query", this.handleDelegateQuery.bind(this));
@@ -592,6 +807,9 @@ export class VoiceAgentSession {
592
807
  // Injected messages — push through LLM path for natural TTS
593
808
  this.bus.on("inject.message", this.handleInjectMessage.bind(this));
594
809
 
810
+ // Per-turn STT reconfigure (keyterms / endpointing / EOT thresholds)
811
+ this.bus.on("stt.reconfigure", this.handleSttReconfigure.bind(this));
812
+
595
813
  // Disconnect
596
814
  this.bus.on("session.disconnect", this.handleDisconnect.bind(this));
597
815
 
@@ -635,9 +853,22 @@ export class VoiceAgentSession {
635
853
  timestampMs: pkt.timestampMs,
636
854
  });
637
855
 
856
+ this.observeAudioFrame(pkt);
857
+
638
858
  this.watchdogs.scheduleInputCadenceWatchdog(pkt.contextId);
639
859
  }
640
860
 
861
+ private observeAudioFrame(pkt: UserAudioReceivedPacket): void {
862
+ if (pkt.audio.byteLength < 2 || pkt.audio.byteLength % 2 !== 0) return;
863
+ this.interaction.observe({
864
+ kind: "audio_frame",
865
+ contextId: pkt.contextId,
866
+ timestampMs: pkt.timestampMs,
867
+ audio: pcm16BytesToSamples(pkt.audio),
868
+ sampleRateHz: pkt.sampleRateHz ?? 16_000,
869
+ });
870
+ }
871
+
641
872
  private handleSttAudio(pkt: SpeechToTextAudioPacket): void {
642
873
  this.watchdogs.scheduleSttForceFinalize(pkt.contextId);
643
874
  }
@@ -647,9 +878,14 @@ export class VoiceAgentSession {
647
878
  this.bus.push(Route.Main, make.eosTurnComplete(pkt.contextId, pkt.timestampMs, pkt.text, []));
648
879
  }
649
880
 
881
+ private handleSttPartial(pkt: SttPartialPacket): void {
882
+ if (pkt.wordTimings) {
883
+ this.sttPartialWordTimings.set(pkt.contextId, pkt.wordTimings);
884
+ }
885
+ }
886
+
650
887
  private handleSttInterim(pkt: SttInterimPacket): void {
651
- this.turnArbiter.noteInterimEvidence(pkt.text);
652
- this.maybeBargeInFromProviderStt(pkt.contextId, pkt.text, pkt.timestampMs);
888
+ this.observeSttForBargeIn(pkt.contextId, pkt.timestampMs, pkt.text, "stt_partial");
653
889
  this.currentTurnId = pkt.contextId;
654
890
  this.emit("user_input_partial", {
655
891
  tsMs: pkt.timestampMs,
@@ -666,8 +902,7 @@ export class VoiceAgentSession {
666
902
 
667
903
  private handleSttResult(pkt: SttResultPacket): void {
668
904
  this.watchdogs.clearSttForceFinalizeIfContext(pkt.contextId);
669
- this.turnArbiter.noteInterimEvidence(pkt.text, pkt.confidence);
670
- this.maybeBargeInFromProviderStt(pkt.contextId, pkt.text, pkt.timestampMs);
905
+ this.observeSttForBargeIn(pkt.contextId, pkt.timestampMs, pkt.text, "stt_final", pkt.confidence);
671
906
  this.currentTurnId = pkt.contextId;
672
907
  this.emit("user_input_final", {
673
908
  tsMs: pkt.timestampMs,
@@ -688,7 +923,7 @@ export class VoiceAgentSession {
688
923
  }
689
924
 
690
925
  private handleVadAudioForSpeakerGate(pkt: VadAudioPacket): void {
691
- if (this.turnArbiter.observeBargeInAudio(pkt)) return;
926
+ if (this.interaction.observeBargeInAudio(pkt)) return;
692
927
 
693
928
  if (this.shouldEnrollPrimarySpeaker(pkt.contextId)) {
694
929
  this.primarySpeakerGate.enrollUserTurnChunk(pkt.audio);
@@ -707,16 +942,43 @@ export class VoiceAgentSession {
707
942
  // Provider transcripts arriving while TTS playout is active are the speech
708
943
  // evidence instead (echo of our own playout is mitigated by client AEC plus
709
944
  // the arbiter's backchannel / low-confidence suppression).
710
- private maybeBargeInFromProviderStt(contextId: string, text: string, timestampMs: number): void {
711
- if (this.endpointingOwner !== "provider_stt") return;
712
- if (!text.trim()) return;
713
- const interruptedContextId = this.latestActiveTtsContextId();
714
- if (!interruptedContextId) return;
715
- this.turnArbiter.onProviderSttEvidence(contextId, timestampMs, interruptedContextId);
945
+ private observeSttForBargeIn(
946
+ contextId: string,
947
+ timestampMs: number,
948
+ text: string,
949
+ kind: "stt_partial" | "stt_final",
950
+ confidence?: number,
951
+ ): void {
952
+ const interruptedContextId =
953
+ this.endpointingOwner === "provider_stt" && text.trim()
954
+ ? this.latestActiveTtsContextId() ?? undefined
955
+ : undefined;
956
+ const wordTimings = this.sttPartialWordTimings.get(contextId);
957
+ if (kind === "stt_partial") {
958
+ this.interaction.observe({
959
+ kind: "stt_partial",
960
+ contextId,
961
+ timestampMs,
962
+ text,
963
+ interruptedContextId,
964
+ ...(wordTimings ? { wordTimings } : {}),
965
+ });
966
+ return;
967
+ }
968
+ this.interaction.observe({
969
+ kind: "stt_final",
970
+ contextId,
971
+ timestampMs,
972
+ text,
973
+ confidence,
974
+ interruptedContextId,
975
+ ...(wordTimings ? { wordTimings } : {}),
976
+ });
716
977
  }
717
978
 
718
979
  private handleVadSpeechStarted(pkt: VadSpeechStartedPacket): void {
719
980
  this.lastFinalizedContextId = "";
981
+ this.userSpeaking = true;
720
982
 
721
983
  if (this.latencyFiller.isFillerOnly(this.currentTurnId)) {
722
984
  this.cancelLatencyFillerTurn(this.currentTurnId, pkt.timestampMs);
@@ -739,17 +1001,27 @@ export class VoiceAgentSession {
739
1001
  const interruptedContextId = this.latestActiveTtsContextId();
740
1002
  if (!interruptedContextId) {
741
1003
  this.speakerEnrollmentContextId = pkt.contextId;
742
- return;
743
1004
  }
744
1005
 
745
- this.turnArbiter.onSpeechStarted(pkt, interruptedContextId);
1006
+ this.interaction.observe({
1007
+ kind: "vad_speech_started",
1008
+ contextId: pkt.contextId,
1009
+ timestampMs: pkt.timestampMs,
1010
+ confidence: pkt.confidence,
1011
+ ...(interruptedContextId ? { interruptedContextId } : {}),
1012
+ });
746
1013
  }
747
1014
 
748
1015
  private handleVadSpeechActivity(pkt: VadSpeechActivityPacket): void {
749
- this.turnArbiter.onSpeechActivity(pkt);
1016
+ this.interaction.observe({
1017
+ kind: "vad_speech_activity",
1018
+ contextId: pkt.contextId,
1019
+ timestampMs: pkt.timestampMs,
1020
+ });
750
1021
  }
751
1022
 
752
1023
  private handleVadSpeechEnded(pkt: VadSpeechEndedPacket): void {
1024
+ this.userSpeaking = false;
753
1025
  this.emit("user_stopped_speaking", {
754
1026
  tsMs: pkt.timestampMs,
755
1027
  turnId: pkt.contextId,
@@ -765,13 +1037,35 @@ export class VoiceAgentSession {
765
1037
  this.speakerEnrollmentContextId = null;
766
1038
  }
767
1039
 
768
- this.turnArbiter.onSpeechEnded(pkt, Boolean(this.latestActiveTtsContextId()));
1040
+ this.interaction.observe({
1041
+ kind: "vad_speech_ended",
1042
+ contextId: pkt.contextId,
1043
+ timestampMs: pkt.timestampMs,
1044
+ hasActiveTts: Boolean(this.latestActiveTtsContextId()),
1045
+ });
769
1046
 
770
1047
  this.turnUserStoppedAtMs.set(pkt.contextId, pkt.timestampMs);
771
1048
  this.timingFor(pkt.contextId).speechEndedMs = pkt.timestampMs;
772
1049
  this.watchdogs.startVaqiMissedResponseTimer(pkt.contextId, pkt.timestampMs);
773
1050
  }
774
1051
 
1052
+ /**
1053
+ * A realtime front rotates its contextId when the provider starts responding
1054
+ * (`RealtimeBridge.onResponseStarted`), so the user-side anchors (`speechEndedMs`, `eosMs`)
1055
+ * are recorded under the PREVIOUS context while `tts.audio` — and therefore the
1056
+ * `turn_latency` emit — lands under the new one. Without carrying the record across the
1057
+ * rotation, `emitTurnLatency` finds no anchor and silently drops every native turn.
1058
+ *
1059
+ * Only fills gaps: anything the new context already recorded wins.
1060
+ */
1061
+ private carryTurnTimingAcrossContextChange(previousContextId: string, contextId: string): void {
1062
+ const previous = this.turnTimings.get(previousContextId);
1063
+ if (!previous) return;
1064
+ this.turnTimings.delete(previousContextId);
1065
+ const current = this.turnTimings.get(contextId);
1066
+ this.turnTimings.set(contextId, { ...previous, ...current });
1067
+ }
1068
+
775
1069
  private timingFor(contextId: string): TurnTiming {
776
1070
  let timing = this.turnTimings.get(contextId);
777
1071
  if (!timing) {
@@ -781,27 +1075,82 @@ export class VoiceAgentSession {
781
1075
  return timing;
782
1076
  }
783
1077
 
1078
+ /** Emit a deferred turn_latency if one is pending, then clear it. Safe to call twice. */
1079
+ private flushPendingTurnLatency(contextId: string): void {
1080
+ const firstAudioMs = this.pendingTurnLatency.get(contextId);
1081
+ if (firstAudioMs === undefined) return;
1082
+ this.pendingTurnLatency.delete(contextId);
1083
+ this.emitTurnLatency(contextId, firstAudioMs);
1084
+ }
1085
+
784
1086
  private emitTurnLatency(contextId: string, firstAudioMs: number): void {
785
- const timing = this.turnTimings.get(contextId);
1087
+ const timing = this.timingFor(contextId);
786
1088
  this.turnTimings.delete(contextId);
787
- if (!timing) return;
788
1089
  const anchorMs = timing.speechEndedMs ?? timing.eosMs;
789
1090
  if (anchorMs === undefined) return; // text-injected or fallback turn — not a voice TTFA
1091
+ const anchor = timing.speechEndedMs !== undefined ? "speech_end" : "eos";
1092
+ const eouDelayMs =
1093
+ timing.speechEndedMs !== undefined &&
1094
+ timing.eosMs !== undefined &&
1095
+ timing.eosMs <= firstAudioMs
1096
+ ? timing.eosMs - timing.speechEndedMs
1097
+ : undefined;
1098
+ const llmAnchorMs = timing.eosMs ?? timing.speechEndedMs;
1099
+ const llmTtftMs =
1100
+ llmAnchorMs !== undefined && timing.firstLlmDeltaMs !== undefined
1101
+ ? timing.firstLlmDeltaMs - llmAnchorMs
1102
+ : undefined;
1103
+ const textAggregationMs =
1104
+ timing.firstLlmDeltaMs !== undefined && timing.firstTtsTextMs !== undefined
1105
+ ? timing.firstTtsTextMs - timing.firstLlmDeltaMs
1106
+ : undefined;
1107
+ const ttsTtfbMs =
1108
+ timing.firstTtsTextMs !== undefined
1109
+ ? firstAudioMs - timing.firstTtsTextMs
1110
+ : undefined;
1111
+ const attributedMs = [eouDelayMs, llmTtftMs, textAggregationMs, ttsTtfbMs]
1112
+ .filter((value): value is number => value !== undefined)
1113
+ .reduce((sum, value) => sum + value, 0);
1114
+ const ttfaMs = firstAudioMs - anchorMs;
1115
+ const unattributedMs = ttfaMs - attributedMs;
1116
+ if (unattributedMs >= 500) this.markInfrastructureBreach(contextId);
1117
+ const fillerUsed = timing.fillerUsed === true;
1118
+ const backchannelUsed = timing.backchannelUsed === true;
790
1119
  this.emit("turn_latency", {
791
1120
  tsMs: firstAudioMs,
792
1121
  turnId: contextId,
793
- ttfaMs: firstAudioMs - anchorMs,
794
- ...(timing.speechEndedMs !== undefined && timing.eosMs !== undefined
795
- ? { eouDelayMs: timing.eosMs - timing.speechEndedMs }
796
- : {}),
797
- ...(timing.eosMs !== undefined && timing.firstLlmDeltaMs !== undefined
798
- ? { llmTtftMs: timing.firstLlmDeltaMs - timing.eosMs }
799
- : {}),
800
- ...(timing.firstTtsTextMs !== undefined
801
- ? { ttsTtfbMs: firstAudioMs - timing.firstTtsTextMs }
802
- : {}),
803
- fillerUsed: timing.fillerUsed === true,
1122
+ ttfaMs,
1123
+ anchor,
1124
+ ...(eouDelayMs !== undefined ? { eouDelayMs } : {}),
1125
+ ...(llmTtftMs !== undefined ? { llmTtftMs } : {}),
1126
+ ...(textAggregationMs !== undefined ? { textAggregationMs } : {}),
1127
+ ...(ttsTtfbMs !== undefined ? { ttsTtfbMs } : {}),
1128
+ unattributedMs,
1129
+ ...(timing.llmCallCount !== undefined ? { llmCallCount: timing.llmCallCount } : {}),
1130
+ ...(timing.llmPassTtftMs !== undefined ? { llmPassTtftMs: [...timing.llmPassTtftMs] } : {}),
1131
+ fillerUsed,
1132
+ backchannelUsed,
804
1133
  });
1134
+
1135
+ const tags = {
1136
+ ...this.observabilityDims,
1137
+ layer: "infrastructure" as const,
1138
+ cancelled: this.interruptedGenerationContextIds.has(contextId) ? "true" : "false",
1139
+ anchor,
1140
+ filler_used: String(fillerUsed),
1141
+ backchannel_used: String(backchannelUsed),
1142
+ };
1143
+ const histograms: Array<readonly [string, number | undefined]> = [
1144
+ ["turn.ttfa_ms", ttfaMs],
1145
+ ["turn.eou_delay_ms", eouDelayMs],
1146
+ ["turn.llm_ttft_ms", llmTtftMs],
1147
+ ["turn.text_aggregation_ms", textAggregationMs],
1148
+ ["turn.tts_ttfb_ms", ttsTtfbMs],
1149
+ ["turn.unattributed_ms", unattributedMs],
1150
+ ];
1151
+ for (const [name, valueMs] of histograms) {
1152
+ if (valueMs !== undefined) this.metricsExporter.observeHistogram(name, valueMs, tags);
1153
+ }
805
1154
  }
806
1155
 
807
1156
  private handleTurnComplete(pkt: EndOfSpeechPacket): void {
@@ -820,10 +1169,16 @@ export class VoiceAgentSession {
820
1169
  const otherTtsActive = this.ttsPlayout.activeContexts().some((c) => c !== pkt.contextId);
821
1170
  if (otherTtsActive && isBackchannel(pkt.text)) {
822
1171
  this.bus.push(Route.Background, make.metric(pkt.contextId, "turn.backchannel_dropped", pkt.text));
1172
+ this.emitAcousticSignal(pkt.contextId, pkt.timestampMs, "backchannel", { text: pkt.text });
823
1173
  return;
824
1174
  }
825
1175
 
1176
+ if (this.emittedTurnLocalizations.has(pkt.contextId)) {
1177
+ this.emittedTurnLocalizations.delete(pkt.contextId);
1178
+ this.turnLocalizationStates.delete(pkt.contextId);
1179
+ }
826
1180
  this.lastFinalizedContextId = pkt.contextId;
1181
+ this.interaction.reset(pkt.contextId);
827
1182
 
828
1183
  // Re-arm per-turn guard state for the next turn. Transports with a stable
829
1184
  // per-call contextId (telephony callSid) reuse one id across turns, so these
@@ -832,6 +1187,7 @@ export class VoiceAgentSession {
832
1187
  // - interruptedGenerationContextIds: else turn N+1's LLM/TTS packets are dropped after a prior barge-in
833
1188
  // - fallbackInjectedContexts: else only one error fallback can ever be spoken per call
834
1189
  this.firstTtsAudioFired.delete(pkt.contextId);
1190
+ this.pendingTurnLatency.delete(pkt.contextId);
835
1191
  this.interruptedGenerationContextIds.delete(pkt.contextId);
836
1192
  this.fallbackInjectedContexts.delete(pkt.contextId);
837
1193
 
@@ -961,8 +1317,11 @@ export class VoiceAgentSession {
961
1317
  if (complete.text) {
962
1318
  const timing = this.turnTimings.get(contextId);
963
1319
  if (timing) timing.firstTtsTextMs ??= tsMs ?? Date.now();
964
- this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), complete.text));
965
- buffer.emitted = appendVoiceText(buffer.emitted, complete.text);
1320
+ // Strip markdown before it reaches TTS, and track the SPOKEN form as the heard
1321
+ // prefix so barge-in truncation reconciles against what was actually said.
1322
+ const spoken = normalizeForSpeech(complete.text);
1323
+ this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), spoken));
1324
+ buffer.emitted = appendVoiceText(buffer.emitted, spoken);
966
1325
  }
967
1326
  buffer.pending = complete.remaining;
968
1327
  this.ttsTextBuffers.set(contextId, buffer);
@@ -971,7 +1330,7 @@ export class VoiceAgentSession {
971
1330
  private flushTtsText(contextId: string, tsMs?: number): string {
972
1331
  const buffer = this.ttsTextBuffers.get(contextId);
973
1332
  if (!buffer) return "";
974
- const tail = buffer.pending.trim();
1333
+ const tail = normalizeForSpeech(buffer.pending.trim());
975
1334
  if (tail) {
976
1335
  const timing = this.turnTimings.get(contextId);
977
1336
  if (timing) timing.firstTtsTextMs ??= tsMs ?? Date.now();
@@ -1036,14 +1395,22 @@ export class VoiceAgentSession {
1036
1395
  toolName: string,
1037
1396
  afterMs?: number,
1038
1397
  ): void {
1398
+ const tsMs = Date.now();
1039
1399
  this.emit("tool_call_cue", {
1040
- tsMs: Date.now(),
1400
+ tsMs,
1041
1401
  turnId: contextId,
1042
1402
  phase,
1043
1403
  toolId,
1044
1404
  toolName,
1045
1405
  ...(afterMs !== undefined ? { afterMs } : {}),
1046
1406
  });
1407
+ this.interaction.observe({
1408
+ kind: "delegate_state",
1409
+ contextId,
1410
+ timestampMs: tsMs,
1411
+ delegateInFlight: phase === "started" || phase === "delayed",
1412
+ toolCallPhase: phase,
1413
+ });
1047
1414
  }
1048
1415
 
1049
1416
  /** G3: resolve one pending tool call with a terminal cue phase. */
@@ -1085,6 +1452,9 @@ export class VoiceAgentSession {
1085
1452
  }
1086
1453
 
1087
1454
  private handleDelegateResult(pkt: DelegateResultPacket): void {
1455
+ if (pkt.control && isConversationControlName(pkt.control.name)) {
1456
+ this.markConversationFlag(pkt.contextId);
1457
+ }
1088
1458
  this.emit("delegate_result", {
1089
1459
  tsMs: pkt.timestampMs,
1090
1460
  turnId: pkt.contextId,
@@ -1094,6 +1464,8 @@ export class VoiceAgentSession {
1094
1464
  grounded: pkt.grounded,
1095
1465
  toolId: pkt.toolId,
1096
1466
  toolName: pkt.toolName,
1467
+ ...(pkt.control ? { control: pkt.control } : {}),
1468
+ ...(pkt.blocked ? { blocked: pkt.blocked } : {}),
1097
1469
  });
1098
1470
  this.debugPush({
1099
1471
  component: "delegate",
@@ -1128,6 +1500,125 @@ export class VoiceAgentSession {
1128
1500
  });
1129
1501
  }
1130
1502
 
1503
+ private handleConversationMetric(pkt: ConversationMetricPacket): void {
1504
+ if (isInfrastructureMetric(pkt.name)) this.markInfrastructureBreach(pkt.contextId);
1505
+ if (isConversationEvaluationMetric(pkt.name, pkt.value)) this.markConversationFlag(pkt.contextId);
1506
+ if (pkt.name === "input.cadence_stall_ms") {
1507
+ this.emitAcousticSignal(pkt.contextId, pkt.timestampMs, "cadence", { value: pkt.value });
1508
+ }
1509
+ if (pkt.name === "llm.call_started") {
1510
+ const timing = this.timingFor(pkt.contextId);
1511
+ timing.llmCallCount = (timing.llmCallCount ?? 0) + 1;
1512
+ return;
1513
+ }
1514
+ if (pkt.name === "llm.pass_ttft_ms") {
1515
+ const ttftMs = Number(pkt.value);
1516
+ if (!Number.isFinite(ttftMs)) return;
1517
+ const timing = this.timingFor(pkt.contextId);
1518
+ (timing.llmPassTtftMs ??= []).push(ttftMs);
1519
+ }
1520
+ }
1521
+
1522
+ private emitAcousticSignal(
1523
+ contextId: string,
1524
+ timestampMs: number,
1525
+ signal: AcousticSignalPacket["signal"],
1526
+ payload?: Readonly<Record<string, unknown>>,
1527
+ ): void {
1528
+ this.bus.push(Route.Background, make.acousticSignal(contextId, timestampMs, signal, payload));
1529
+ this.metricsExporter.observeCounter?.(`acoustic.${signal}`, 1, {
1530
+ provider: this.observabilityDims.provider,
1531
+ model: this.observabilityDims.model,
1532
+ region: this.observabilityDims.region,
1533
+ layer: "conversation",
1534
+ });
1535
+ }
1536
+
1537
+ private localizationStateFor(contextId: string): {
1538
+ infrastructureBreached: boolean;
1539
+ conversationFlagged: boolean;
1540
+ } {
1541
+ const existing = this.turnLocalizationStates.get(contextId);
1542
+ if (existing) return existing;
1543
+ const state = { infrastructureBreached: false, conversationFlagged: false };
1544
+ this.turnLocalizationStates.set(contextId, state);
1545
+ return state;
1546
+ }
1547
+
1548
+ private markInfrastructureBreach(contextId: string): void {
1549
+ this.localizationStateFor(contextId).infrastructureBreached = true;
1550
+ }
1551
+
1552
+ private markConversationFlag(contextId: string): void {
1553
+ this.localizationStateFor(contextId).conversationFlagged = true;
1554
+ }
1555
+
1556
+ private emitTurnLocalization(contextId: string): void {
1557
+ if (this.emittedTurnLocalizations.has(contextId)) return;
1558
+ const state = this.localizationStateFor(contextId);
1559
+ this.emittedTurnLocalizations.add(contextId);
1560
+ this.bus.push(
1561
+ Route.Background,
1562
+ make.turnLocalization(
1563
+ contextId,
1564
+ Date.now(),
1565
+ localizeTurn(state),
1566
+ state.infrastructureBreached,
1567
+ state.conversationFlagged,
1568
+ ),
1569
+ );
1570
+ }
1571
+
1572
+ private scheduleTurnLocalization(contextId: string): void {
1573
+ this.scheduler.schedule(turnLocalizationTimerKey(contextId), 0, () => {
1574
+ this.emitTurnLocalization(contextId);
1575
+ });
1576
+ }
1577
+
1578
+ private static readonly USAGE_FIELDS = [
1579
+ "inputTokens",
1580
+ "outputTokens",
1581
+ "totalTokens",
1582
+ "cachedInputTokens",
1583
+ "reasoningTokens",
1584
+ "audioSeconds",
1585
+ "characters",
1586
+ ] as const;
1587
+
1588
+ private handleUsageRecorded(pkt: UsageRecordedPacket): void {
1589
+ // Accumulate into the per-stage running total. A missing field stays missing
1590
+ // (never coerced to 0) so an un-reported dimension is distinguishable from a real 0.
1591
+ const prev = this.usageByStage.get(pkt.stage) ?? { stage: pkt.stage };
1592
+ const next: Record<string, unknown> = { ...prev };
1593
+ for (const field of VoiceAgentSession.USAGE_FIELDS) {
1594
+ const add = pkt[field];
1595
+ if (typeof add === "number") next[field] = ((prev[field] as number | undefined) ?? 0) + add;
1596
+ }
1597
+ this.usageByStage.set(pkt.stage, next as unknown as SessionStageUsage);
1598
+
1599
+ // Export each recorded unit as a counter, tagged low-cardinality only — same
1600
+ // discipline as turn_latency. provider/model are exemplar-safe; contextId is not,
1601
+ // and must never become a metric dimension (LiveKit caps at model/provider).
1602
+ const exporter = this.metricsExporter;
1603
+ if (exporter.observeCounter) {
1604
+ const tags = {
1605
+ stage: pkt.stage,
1606
+ provider: pkt.provider ?? "",
1607
+ model: pkt.model ?? "",
1608
+ layer: "infrastructure" as const,
1609
+ };
1610
+ for (const field of VoiceAgentSession.USAGE_FIELDS) {
1611
+ const value = pkt[field];
1612
+ if (typeof value === "number") exporter.observeCounter(`usage.${field}`, value, tags);
1613
+ }
1614
+ }
1615
+ }
1616
+
1617
+ private emitSessionUsage(): void {
1618
+ if (this.usageByStage.size === 0) return;
1619
+ this.emit("usage", { tsMs: Date.now(), stages: [...this.usageByStage.values()] });
1620
+ }
1621
+
1131
1622
  private handleTtsAudio(pkt: TextToSpeechAudioPacket): void {
1132
1623
  if (this.interruptedGenerationContextIds.has(pkt.contextId)) {
1133
1624
  this.bus.push(
@@ -1148,19 +1639,34 @@ export class VoiceAgentSession {
1148
1639
  );
1149
1640
  this.turnUserStoppedAtMs.delete(pkt.contextId);
1150
1641
  }
1151
- this.emitTurnLatency(pkt.contextId, pkt.timestampMs);
1642
+ if (this.generatingContextIds.has(pkt.contextId)) {
1643
+ this.pendingTurnLatency.set(pkt.contextId, pkt.timestampMs);
1644
+ } else {
1645
+ this.emitTurnLatency(pkt.contextId, pkt.timestampMs);
1646
+ }
1152
1647
  }
1153
1648
 
1154
- this.primarySpeakerGate.observeAssistantPlayout(pkt.audio);
1649
+ const audio = this.normalizeOutboundAudio(pkt.audio);
1650
+ // Transport subscribers receive this same packet after the core handler.
1651
+ if (audio !== pkt.audio) Object.assign(pkt, { audio });
1652
+ this.primarySpeakerGate.observeAssistantPlayout(audio);
1155
1653
  // Audio just arrived — (re)arm the stall watchdog for this turn's TTS output.
1156
1654
  this.watchdogs.armTtsStallTimer(pkt.contextId);
1157
1655
 
1158
1656
  // Mark active and advance this context's playout cursor by the chunk's
1159
1657
  // realtime duration.
1160
1658
  const sampleRateHz = requireTtsAudioSampleRate(pkt.sampleRateHz);
1161
- const audioDurationMs = estimatePcm16Duration(pkt.audio, sampleRateHz);
1659
+ const audioDurationMs = estimatePcm16Duration(audio, sampleRateHz);
1162
1660
  const now = Date.now();
1163
1661
  this.ttsPlayout.noteAudio(pkt.contextId, audioDurationMs, now);
1662
+ this.interaction.observe({
1663
+ kind: "playout_tick",
1664
+ contextId: pkt.contextId,
1665
+ timestampMs: pkt.timestampMs,
1666
+ ttsActive: true,
1667
+ audio: pcm16BytesToSamples(audio),
1668
+ sampleRateHz,
1669
+ });
1164
1670
 
1165
1671
  // Anchor the idle timer to when playout actually *ends* (P2), not to chunk
1166
1672
  // arrival. TTS streams faster than realtime, so extending by each chunk's
@@ -1175,19 +1681,42 @@ export class VoiceAgentSession {
1175
1681
  type: "audio",
1176
1682
  data: {
1177
1683
  context_id: pkt.contextId,
1178
- bytes: String(pkt.audio.length),
1684
+ bytes: String(audio.length),
1179
1685
  },
1180
1686
  timestampMs: pkt.timestampMs,
1181
1687
  });
1182
1688
 
1183
- this.bus.push(Route.Main, make.recordAssistantAudio(pkt.contextId, Date.now(), pkt.audio, sampleRateHz));
1689
+ this.bus.push(Route.Main, make.recordAssistantAudio(pkt.contextId, Date.now(), audio, sampleRateHz));
1690
+ }
1691
+
1692
+ private normalizeOutboundAudio(audio: Uint8Array): Uint8Array {
1693
+ if (
1694
+ !this.outboundLoudnessConfig ||
1695
+ !this.outboundLoudnessState ||
1696
+ audio.byteLength % 2 !== 0
1697
+ ) {
1698
+ return audio;
1699
+ }
1700
+ const samples = pcm16BytesToSamples(audio);
1701
+ return pcm16SamplesToBytes(
1702
+ normalizeLoudness(samples, this.outboundLoudnessState, this.outboundLoudnessConfig),
1703
+ );
1184
1704
  }
1185
1705
 
1186
1706
  private handleTtsEnd(pkt: TextToSpeechEndPacket): void {
1187
1707
  // Generation finished, but the streamed audio is still playing out. Keep the
1188
1708
  // context interruptible until its playout estimate elapses, then release it.
1189
1709
  this.generatingContextIds.delete(pkt.contextId);
1190
- this.ttsPlayout.scheduleRelease(pkt.contextId, Date.now());
1710
+ const firstAudioMs = this.pendingTurnLatency.get(pkt.contextId);
1711
+ if (firstAudioMs !== undefined) {
1712
+ this.pendingTurnLatency.delete(pkt.contextId);
1713
+ this.emitTurnLatency(pkt.contextId, firstAudioMs);
1714
+ }
1715
+ const now = Date.now();
1716
+ this.ttsPlayout.scheduleRelease(pkt.contextId, now);
1717
+ const playoutEndMs = this.ttsPlayout.playoutEnd(pkt.contextId);
1718
+ const remainingMs = playoutEndMs === undefined ? 0 : Math.max(0, playoutEndMs - now);
1719
+ this.scheduleInteractionPlayoutTick(pkt.contextId, remainingMs);
1191
1720
  this.watchdogs.clearTtsStallTimerFor(pkt.contextId);
1192
1721
  this.debugPush({
1193
1722
  component: "tts",
@@ -1195,16 +1724,54 @@ export class VoiceAgentSession {
1195
1724
  data: {},
1196
1725
  timestampMs: Date.now(),
1197
1726
  });
1727
+ this.scheduleTurnLocalization(pkt.contextId);
1728
+ }
1729
+
1730
+ private scheduleInteractionPlayoutTick(contextId: string, delayMs: number): void {
1731
+ this.pendingInteractionPlayoutTimers.add(contextId);
1732
+ this.scheduler.schedule(interactionPlayoutTimerKey(contextId), delayMs, () => {
1733
+ this.pendingInteractionPlayoutTimers.delete(contextId);
1734
+ if (this.ttsPlayout.isActive(contextId)) return;
1735
+ this.interaction.observe({
1736
+ kind: "playout_tick",
1737
+ contextId,
1738
+ timestampMs: Date.now(),
1739
+ ttsActive: false,
1740
+ });
1741
+ });
1742
+ }
1743
+
1744
+ private cancelInteractionPlayoutTick(contextId: string): void {
1745
+ if (!this.pendingInteractionPlayoutTimers.delete(contextId)) return;
1746
+ this.scheduler.cancel(interactionPlayoutTimerKey(contextId));
1198
1747
  }
1199
1748
 
1200
1749
  private handleTtsPlayoutProgress(pkt: TextToSpeechPlayoutProgressPacket): void {
1750
+ this.cancelInteractionPlayoutTick(pkt.contextId);
1201
1751
  this.ttsPlayout.noteProgress(pkt.contextId, pkt.complete, pkt.playedOutMs);
1752
+ this.interaction.observe({
1753
+ kind: "playout_tick",
1754
+ contextId: pkt.contextId,
1755
+ timestampMs: pkt.timestampMs,
1756
+ playedOutMs: pkt.playedOutMs,
1757
+ ttsActive: !pkt.complete,
1758
+ });
1202
1759
  }
1203
1760
 
1204
1761
  private handleInterruptDetected(pkt: InterruptionDetectedPacket): void {
1762
+ this.cancelInteractionPlayoutTick(pkt.contextId);
1205
1763
  this.interruptedGenerationContextIds.add(pkt.contextId);
1206
1764
  this.failPendingToolCues(pkt.contextId); // G3: the aborted delegate's cue fails (R5)
1207
1765
  this.latencyFiller.cancel(pkt.contextId);
1766
+ // A barged turn still produced first audio, and its TTFA is a real measurement.
1767
+ // Deferring emission to tts.end (so later tool passes are counted) must not make
1768
+ // interrupted turns vanish from the metric: barge-in correlates with slow turns,
1769
+ // so silently dropping them biases the sample toward the fast ones — the worst
1770
+ // turns would disappear from exactly the number meant to detect them.
1771
+ this.flushPendingTurnLatency(pkt.contextId);
1772
+ this.emitAcousticSignal(pkt.contextId, pkt.timestampMs, "interruption", {
1773
+ source: pkt.source,
1774
+ });
1208
1775
  this.turnTimings.delete(pkt.contextId);
1209
1776
  this.firstLlmDeltaReceived.delete(pkt.contextId);
1210
1777
  this.ttsTextBuffers.delete(pkt.contextId);
@@ -1251,9 +1818,11 @@ export class VoiceAgentSession {
1251
1818
  * the LLM, and drops late deltas/audio for the stale context.
1252
1819
  */
1253
1820
  private cancelStaleGeneration(contextId: string, timestampMs: number): void {
1821
+ this.cancelInteractionPlayoutTick(contextId);
1254
1822
  this.interruptedGenerationContextIds.add(contextId);
1255
1823
  this.failPendingToolCues(contextId); // G3: a superseded turn's pending cue fails
1256
1824
  this.generatingContextIds.delete(contextId);
1825
+ this.pendingTurnLatency.delete(contextId);
1257
1826
  this.latencyFiller.cancel(contextId);
1258
1827
  this.turnTimings.delete(contextId);
1259
1828
  this.firstLlmDeltaReceived.delete(contextId);
@@ -1267,6 +1836,12 @@ export class VoiceAgentSession {
1267
1836
  }
1268
1837
 
1269
1838
  private handleComponentError(pkt: VoiceErrorPacket): void {
1839
+ this.markInfrastructureBreach(pkt.contextId);
1840
+ this.metricsExporter.observeCounter?.("error.stage", 1, {
1841
+ stage: pkt.component,
1842
+ category: pkt.category,
1843
+ layer: "infrastructure",
1844
+ });
1270
1845
  this.emit("error", {
1271
1846
  tsMs: pkt.timestampMs,
1272
1847
  stage: `${pkt.component}.error`,
@@ -1306,6 +1881,7 @@ export class VoiceAgentSession {
1306
1881
  // leave the caller in unexplained silence: if the reasoning layer failed the turn,
1307
1882
  // speak a graceful fallback (G4 — Deepgram guide "never fail silently").
1308
1883
  this.maybeSpeakErrorFallback(pkt);
1884
+ this.emitTurnLocalization(pkt.contextId);
1309
1885
  }
1310
1886
 
1311
1887
  private maybeSpeakErrorFallback(pkt: VoiceErrorPacket): void {
@@ -1334,11 +1910,34 @@ export class VoiceAgentSession {
1334
1910
  }
1335
1911
 
1336
1912
  private handleInjectMessage(pkt: InjectMessagePacket): void {
1913
+ if (pkt.mode === "context") {
1914
+ const bridge = this.plugins.get("bridge") as (VoicePlugin & {
1915
+ injectContext?: (text: string) => void;
1916
+ }) | undefined;
1917
+ if (bridge?.injectContext) {
1918
+ bridge.injectContext(pkt.text);
1919
+ } else {
1920
+ console.warn("VoiceAgentSession: context injection requested but the bridge does not support injectContext");
1921
+ }
1922
+ return;
1923
+ }
1924
+
1337
1925
  // Inject as synthetic LLM output — goes through normal TTS path
1338
1926
  this.bus.push(Route.Main, make.llmDelta(pkt.contextId, Date.now(), pkt.text));
1339
1927
  this.bus.push(Route.Main, make.llmDone(pkt.contextId, Date.now(), pkt.text));
1340
1928
  }
1341
1929
 
1930
+ private handleSttReconfigure(pkt: SttReconfigurePacket): void {
1931
+ const stt = this.plugins.get("stt");
1932
+ if (stt?.sttReconfigure) {
1933
+ stt.sttReconfigure.reconfigure(pkt.partial);
1934
+ return;
1935
+ }
1936
+ console.warn(
1937
+ "VoiceAgentSession: stt.reconfigure requested but the STT plugin does not support sttReconfigure",
1938
+ );
1939
+ }
1940
+
1342
1941
  private handleDisconnect(pkt: DisconnectRequestedPacket): void {
1343
1942
  this.emit("closed", { tsMs: pkt.timestampMs, reason: pkt.reason });
1344
1943
  this.debugPush({
@@ -1372,6 +1971,19 @@ export class VoiceAgentSession {
1372
1971
  });
1373
1972
  }
1374
1973
 
1974
+ if (
1975
+ this.activeInteractionPolicy === this.injectedInteractionPolicy &&
1976
+ isLifecycleInteractionPolicy(this.activeInteractionPolicy)
1977
+ ) {
1978
+ const policy = this.activeInteractionPolicy;
1979
+ steps.push({
1980
+ name: "interaction_policy",
1981
+ stage: InitStage.EOS,
1982
+ run: () => policy.initialize(this.config.interactionPolicyConfig ?? {}),
1983
+ cleanup: () => policy.close(),
1984
+ });
1985
+ }
1986
+
1375
1987
  const orderedPluginSteps = steps.sort(
1376
1988
  (a, b) => stageOrder(a.stage) - stageOrder(b.stage),
1377
1989
  );
@@ -1401,6 +2013,20 @@ export class VoiceAgentSession {
1401
2013
  }
1402
2014
 
1403
2015
  private applyEndpointingOwnerInvariant(): void {
2016
+ if (this.activeInteractionPolicy === this.injectedInteractionPolicy) {
2017
+ for (const [name, plugin] of this.plugins) {
2018
+ const capability = plugin.endpointingCapability;
2019
+ if (!capability || capability.owner !== "provider_stt") continue;
2020
+ if (!capability.disableConfig) {
2021
+ throw new Error(`interactionPolicy requires ${name} to expose endpointingCapability.disableConfig`);
2022
+ }
2023
+ this.config.plugins[name] = {
2024
+ ...(this.config.plugins[name] ?? {}),
2025
+ ...capability.disableConfig,
2026
+ };
2027
+ }
2028
+ return;
2029
+ }
1404
2030
  if (this.endpointingOwner === "timer") return;
1405
2031
  const owner: EndpointingOwner = this.endpointingOwner;
1406
2032
  const finalizers = [...this.plugins.entries()]
@@ -1426,6 +2052,9 @@ export class VoiceAgentSession {
1426
2052
  private shouldInitializePlugin(plugin: VoicePlugin): boolean {
1427
2053
  const capability = plugin.endpointingCapability;
1428
2054
  if (!capability) return true;
2055
+ if (this.activeInteractionPolicy === this.injectedInteractionPolicy) {
2056
+ return capability.owner === "provider_stt";
2057
+ }
1429
2058
  if (this.endpointingOwner === "timer") return false;
1430
2059
  if (capability.owner === "smart_turn") return capability.owner === this.endpointingOwner;
1431
2060
  return true;