@kuralle-syrinx/core 4.0.0 → 4.2.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.
@@ -41,6 +41,8 @@ import type {
41
41
  SpeechToTextAudioPacket,
42
42
  SttResultPacket,
43
43
  SttInterimPacket,
44
+ SttPartialPacket,
45
+ TurnChangePacket,
44
46
  VadAudioPacket,
45
47
  VadSpeechStartedPacket,
46
48
  VadSpeechActivityPacket,
@@ -64,6 +66,11 @@ import { PrimarySpeakerGate } from "./primary-speaker-gate.js";
64
66
  import { takeCompleteVoiceText, isCompleteVoiceText, appendVoiceText } from "./voice-text.js";
65
67
  import { TtsPlayoutClock } from "./tts-playout-clock.js";
66
68
  import { TurnArbiter, isBackchannel } from "./turn-arbiter.js";
69
+ import { InteractionCoordinator } from "./interaction-coordinator.js";
70
+ import { isLifecycleInteractionPolicy, type InteractionPolicy, type WordTiming } from "./interaction-policy.js";
71
+ import { pcm16BytesToSamples } from "./audio/pcm.js";
72
+ import { DeferInteractionPolicy } from "./policies/defer.js";
73
+ import { RuleBasedInteractionPolicy } from "./policies/rule-based.js";
67
74
  import * as make from "./packet-factories.js";
68
75
  import { pluginStage, stageOrder, isAudioStage } from "./init-stage-order.js";
69
76
  import {
@@ -162,6 +169,21 @@ export interface VoiceAgentSessionConfig {
162
169
  * provider STT ownership; Smart Turn sessions must opt in explicitly.
163
170
  */
164
171
  endpointingOwner?: "provider_stt" | "smart_turn" | "timer";
172
+ /** The front model owns full-duplex interaction (turn-taking + barge-in). When true, the session's
173
+ * InteractionPolicy runs observe-only (DeferInteractionPolicy) — Syrinx does not drive its own
174
+ * turn/interrupt decisions; the front's native decisions stand. Default: false (Syrinx drives).
175
+ * A realtime factory sets this from RealtimeAdapter.caps.supportsFullDuplex. */
176
+ fullDuplex?: boolean;
177
+ /** When true, Syrinx suppresses policy-timed backchannel cue packets (front/provider owns them). */
178
+ emitsBackchannel?: boolean;
179
+ /**
180
+ * Optional interaction policy injected by the caller (learned controllers, Smart Turn policy, etc.).
181
+ * When omitted, the session uses RuleBasedInteractionPolicy. When `fullDuplex` is true, the
182
+ * coordinator runs observe-only via DeferInteractionPolicy regardless of this setting.
183
+ */
184
+ interactionPolicy?: InteractionPolicy;
185
+ /** Config passed to `interactionPolicy.initialize` when the injected policy is lifecycle-capable. */
186
+ interactionPolicyConfig?: Record<string, unknown>;
165
187
  readonly metricsExporter?: MetricsExporter;
166
188
  readonly scheduler?: Scheduler;
167
189
  readonly observability?: {
@@ -190,6 +212,24 @@ export interface VoiceAgentSessionEvents {
190
212
  * these as `tool_call_*` wire messages — the standard "thinking" cue.
191
213
  */
192
214
  tool_call_cue: (event: { tsMs: number; turnId: string; phase: "started" | "delayed" | "complete" | "failed"; toolId: string; toolName: string; afterMs?: number }) => void;
215
+ /**
216
+ * Per-turn latency decomposition, emitted once at the turn's first TTS audio.
217
+ * `ttfaMs` is anchored to the real end of user speech (VAD speech-end, falling
218
+ * back to the endpoint decision) — `fillerUsed` flags turns where a latency filler spoke
219
+ * first, and `backchannelUsed` flags turns where a wait-gap cue played before the answer.
220
+ * Decomposition: eouDelayMs (speech end → endpoint) + llmTtftMs (endpoint →
221
+ * first LLM delta) + ttsTtfbMs (first TTS text dispatched → first audio).
222
+ */
223
+ turn_latency: (event: {
224
+ tsMs: number;
225
+ turnId: string;
226
+ ttfaMs: number;
227
+ eouDelayMs?: number;
228
+ llmTtftMs?: number;
229
+ ttsTtfbMs?: number;
230
+ fillerUsed: boolean;
231
+ backchannelUsed: boolean;
232
+ }) => void;
193
233
  agent_first_audio: (event: { tsMs: number; turnId: string }) => void;
194
234
  agent_finished: (event: { tsMs: number; turnId: string } & Record<string, unknown>) => void;
195
235
  error: (event: { tsMs: number; stage: string; category: string; message: string }) => void;
@@ -204,6 +244,15 @@ interface TtsTextBuffer {
204
244
  emitted: string;
205
245
  }
206
246
 
247
+ interface TurnTiming {
248
+ speechEndedMs?: number;
249
+ eosMs?: number;
250
+ firstLlmDeltaMs?: number;
251
+ firstTtsTextMs?: number;
252
+ fillerUsed?: boolean;
253
+ backchannelUsed?: boolean;
254
+ }
255
+
207
256
  /** Suffix marking a context created to speak an error fallback, so it never recurses. */
208
257
  const FALLBACK_CONTEXT_SUFFIX = ":error-fallback";
209
258
 
@@ -212,6 +261,10 @@ function toolCueTimerKey(contextId: string, toolId: string): string {
212
261
  return `tool_cue:${contextId}:${toolId}`;
213
262
  }
214
263
 
264
+ function interactionPlayoutTimerKey(contextId: string): string {
265
+ return `interaction.playout:${contextId}`;
266
+ }
267
+
215
268
  // =============================================================================
216
269
  // Session Implementation
217
270
  // =============================================================================
@@ -243,7 +296,10 @@ export class VoiceAgentSession {
243
296
  private ttsTextBuffers = new Map<string, TtsTextBuffer>();
244
297
  private readonly minInterruptionMs: number;
245
298
  private readonly primarySpeakerGate: PrimarySpeakerGate;
246
- private readonly turnArbiter!: TurnArbiter;
299
+ private readonly ruleBasedPolicy!: RuleBasedInteractionPolicy;
300
+ private readonly injectedInteractionPolicy: InteractionPolicy | null;
301
+ private readonly activeInteractionPolicy: InteractionPolicy;
302
+ private readonly interaction!: InteractionCoordinator;
247
303
  private readonly latencyFiller: LatencyFillerController;
248
304
  private firstLlmDeltaReceived = new Set<string>();
249
305
  private readonly vaqiMissedResponseMs: number;
@@ -252,15 +308,21 @@ export class VoiceAgentSession {
252
308
  private readonly watchdogs!: VoiceSessionWatchdogs;
253
309
  private readonly observabilityObserver: ObservabilityObserver;
254
310
  private turnUserStoppedAtMs = new Map<string, number>();
311
+ private turnTimings = new Map<string, TurnTiming>();
255
312
  private speakerEnrollmentContextId: string | null = null;
256
313
  private firstTtsAudioFired = new Set<string>();
314
+ private readonly pendingInteractionPlayoutTimers = new Set<string>();
257
315
  private readonly errorFallbackText: string;
258
316
  private fallbackInjectedContexts = new Set<string>();
259
317
  // G3: pending tool calls per context (toolId → toolName) driving the tool_call_cue lifecycle.
260
318
  private readonly delayCueAfterMs: number;
261
319
  private pendingToolCues = new Map<string, Map<string, string>>();
262
320
  private readonly endpointingOwner: "provider_stt" | "smart_turn" | "timer";
321
+ private readonly fullDuplex: boolean;
322
+ private readonly emitsBackchannel: boolean;
323
+ private userSpeaking = false;
263
324
  private lastFinalizedContextId = "";
325
+ private readonly sttPartialWordTimings = new Map<string, readonly WordTiming[]>();
264
326
 
265
327
  constructor(config: VoiceAgentSessionConfig) {
266
328
  const owner = config.endpointingOwner;
@@ -268,6 +330,8 @@ export class VoiceAgentSession {
268
330
  throw new Error(`Unsupported endpointingOwner: ${owner}`);
269
331
  }
270
332
  this.endpointingOwner = owner ?? "provider_stt";
333
+ this.fullDuplex = config.fullDuplex === true;
334
+ this.emitsBackchannel = config.emitsBackchannel === true;
271
335
  this.config = config;
272
336
  this.scheduler = config.scheduler ?? new TimerScheduler();
273
337
  this.ttsPlayout = new TtsPlayoutClock(this.scheduler);
@@ -318,12 +382,29 @@ export class VoiceAgentSession {
318
382
  },
319
383
  });
320
384
 
321
- this.turnArbiter = new TurnArbiter({
385
+ this.injectedInteractionPolicy = config.interactionPolicy ?? null;
386
+ this.ruleBasedPolicy = new RuleBasedInteractionPolicy({
322
387
  bus: this.bus,
323
388
  primarySpeakerGate: this.primarySpeakerGate,
324
389
  ttsPlayout: this.ttsPlayout,
325
390
  minInterruptionMs: this.minInterruptionMs,
326
391
  });
392
+ const coordinatorPolicy: InteractionPolicy = this.fullDuplex
393
+ ? new DeferInteractionPolicy()
394
+ : (this.injectedInteractionPolicy ?? this.ruleBasedPolicy);
395
+ this.activeInteractionPolicy = coordinatorPolicy;
396
+ this.interaction = new InteractionCoordinator({
397
+ bus: this.bus,
398
+ policy: coordinatorPolicy,
399
+ executor: this.ruleBasedPolicy.arbiter,
400
+ scheduler: this.scheduler,
401
+ caps: { emitsBackchannel: this.emitsBackchannel },
402
+ isUserSpeaking: () => this.userSpeaking,
403
+ isTtsActive: () => this.ttsPlayout.activeContexts().length > 0,
404
+ onBackchannelEmitted: (contextId) => {
405
+ this.timingFor(contextId).backchannelUsed = true;
406
+ },
407
+ });
327
408
  this.watchdogs = new VoiceSessionWatchdogs({
328
409
  bus: this.bus,
329
410
  plugins: this.plugins,
@@ -360,6 +441,10 @@ export class VoiceAgentSession {
360
441
  this.modeSwitcher = new ModeSwitcher(this.bus);
361
442
  }
362
443
 
444
+ private get turnArbiter(): TurnArbiter {
445
+ return this.ruleBasedPolicy.arbiter;
446
+ }
447
+
363
448
  // =========================================================================
364
449
  // Public API
365
450
  // =========================================================================
@@ -386,6 +471,7 @@ export class VoiceAgentSession {
386
471
 
387
472
  // 1. Wire all bus handlers
388
473
  this.wireBusHandlers();
474
+ this.interaction.initialize();
389
475
 
390
476
  // 2. Start bus drain loop
391
477
  this.busStartPromise = this.bus.start();
@@ -417,16 +503,23 @@ export class VoiceAgentSession {
417
503
 
418
504
  // 1. Stop idle timeout
419
505
  this.idleTimeout.dispose();
506
+ this.interaction.dispose();
420
507
  this.watchdogs.dispose();
421
508
  this.observabilityObserver.dispose();
422
509
  this.ttsPlayout.clear();
510
+ for (const contextId of this.pendingInteractionPlayoutTimers) {
511
+ this.scheduler.cancel(interactionPlayoutTimerKey(contextId));
512
+ }
513
+ this.pendingInteractionPlayoutTimers.clear();
423
514
  this.turnArbiter.clear();
424
515
  this.turnUserStoppedAtMs.clear();
516
+ this.turnTimings.clear();
425
517
  this.firstTtsAudioFired.clear();
426
518
  this.fallbackInjectedContexts.clear();
427
519
  this.ttsTextBuffers.clear();
428
520
  this.interruptedGenerationContextIds.clear();
429
521
  this.firstLlmDeltaReceived.clear();
522
+ this.sttPartialWordTimings.clear();
430
523
  for (const [contextId, pending] of this.pendingToolCues) {
431
524
  for (const toolId of pending.keys()) this.scheduler.cancel(toolCueTimerKey(contextId, toolId));
432
525
  }
@@ -511,6 +604,7 @@ export class VoiceAgentSession {
511
604
  // STT results
512
605
  this.bus.on("stt.audio", this.handleSttAudio.bind(this));
513
606
  this.bus.on("stt.interim", this.handleSttInterim.bind(this));
607
+ this.bus.on("stt.partial", this.handleSttPartial.bind(this));
514
608
  this.bus.on("stt.result", this.handleSttResult.bind(this));
515
609
 
516
610
  // VAD
@@ -520,8 +614,11 @@ export class VoiceAgentSession {
520
614
  this.bus.on("vad.audio", this.handleVadAudioForSpeakerGate.bind(this));
521
615
 
522
616
  // EOS
523
- this.bus.on("turn.change", () => {
617
+ this.bus.on("turn.change", (pkt: TurnChangePacket) => {
524
618
  this.lastFinalizedContextId = "";
619
+ if (pkt.previousContextId) {
620
+ this.sttPartialWordTimings.delete(pkt.previousContextId);
621
+ }
525
622
  });
526
623
  this.bus.on("eos.turn_complete", this.handleTurnComplete.bind(this));
527
624
  this.bus.on("eos.interim", this.handleEosInterim.bind(this));
@@ -608,9 +705,22 @@ export class VoiceAgentSession {
608
705
  timestampMs: pkt.timestampMs,
609
706
  });
610
707
 
708
+ this.observeAudioFrame(pkt);
709
+
611
710
  this.watchdogs.scheduleInputCadenceWatchdog(pkt.contextId);
612
711
  }
613
712
 
713
+ private observeAudioFrame(pkt: UserAudioReceivedPacket): void {
714
+ if (pkt.audio.byteLength < 2 || pkt.audio.byteLength % 2 !== 0) return;
715
+ this.interaction.observe({
716
+ kind: "audio_frame",
717
+ contextId: pkt.contextId,
718
+ timestampMs: pkt.timestampMs,
719
+ audio: pcm16BytesToSamples(pkt.audio),
720
+ sampleRateHz: pkt.sampleRateHz ?? 16_000,
721
+ });
722
+ }
723
+
614
724
  private handleSttAudio(pkt: SpeechToTextAudioPacket): void {
615
725
  this.watchdogs.scheduleSttForceFinalize(pkt.contextId);
616
726
  }
@@ -620,9 +730,14 @@ export class VoiceAgentSession {
620
730
  this.bus.push(Route.Main, make.eosTurnComplete(pkt.contextId, pkt.timestampMs, pkt.text, []));
621
731
  }
622
732
 
733
+ private handleSttPartial(pkt: SttPartialPacket): void {
734
+ if (pkt.wordTimings) {
735
+ this.sttPartialWordTimings.set(pkt.contextId, pkt.wordTimings);
736
+ }
737
+ }
738
+
623
739
  private handleSttInterim(pkt: SttInterimPacket): void {
624
- this.turnArbiter.noteInterimEvidence(pkt.text);
625
- this.maybeBargeInFromProviderStt(pkt.contextId, pkt.text, pkt.timestampMs);
740
+ this.observeSttForBargeIn(pkt.contextId, pkt.timestampMs, pkt.text, "stt_partial");
626
741
  this.currentTurnId = pkt.contextId;
627
742
  this.emit("user_input_partial", {
628
743
  tsMs: pkt.timestampMs,
@@ -639,8 +754,7 @@ export class VoiceAgentSession {
639
754
 
640
755
  private handleSttResult(pkt: SttResultPacket): void {
641
756
  this.watchdogs.clearSttForceFinalizeIfContext(pkt.contextId);
642
- this.turnArbiter.noteInterimEvidence(pkt.text, pkt.confidence);
643
- this.maybeBargeInFromProviderStt(pkt.contextId, pkt.text, pkt.timestampMs);
757
+ this.observeSttForBargeIn(pkt.contextId, pkt.timestampMs, pkt.text, "stt_final", pkt.confidence);
644
758
  this.currentTurnId = pkt.contextId;
645
759
  this.emit("user_input_final", {
646
760
  tsMs: pkt.timestampMs,
@@ -661,7 +775,7 @@ export class VoiceAgentSession {
661
775
  }
662
776
 
663
777
  private handleVadAudioForSpeakerGate(pkt: VadAudioPacket): void {
664
- if (this.turnArbiter.observeBargeInAudio(pkt)) return;
778
+ if (this.interaction.observeBargeInAudio(pkt)) return;
665
779
 
666
780
  if (this.shouldEnrollPrimarySpeaker(pkt.contextId)) {
667
781
  this.primarySpeakerGate.enrollUserTurnChunk(pkt.audio);
@@ -680,16 +794,43 @@ export class VoiceAgentSession {
680
794
  // Provider transcripts arriving while TTS playout is active are the speech
681
795
  // evidence instead (echo of our own playout is mitigated by client AEC plus
682
796
  // the arbiter's backchannel / low-confidence suppression).
683
- private maybeBargeInFromProviderStt(contextId: string, text: string, timestampMs: number): void {
684
- if (this.endpointingOwner !== "provider_stt") return;
685
- if (!text.trim()) return;
686
- const interruptedContextId = this.latestActiveTtsContextId();
687
- if (!interruptedContextId) return;
688
- this.turnArbiter.onProviderSttEvidence(contextId, timestampMs, interruptedContextId);
797
+ private observeSttForBargeIn(
798
+ contextId: string,
799
+ timestampMs: number,
800
+ text: string,
801
+ kind: "stt_partial" | "stt_final",
802
+ confidence?: number,
803
+ ): void {
804
+ const interruptedContextId =
805
+ this.endpointingOwner === "provider_stt" && text.trim()
806
+ ? this.latestActiveTtsContextId() ?? undefined
807
+ : undefined;
808
+ const wordTimings = this.sttPartialWordTimings.get(contextId);
809
+ if (kind === "stt_partial") {
810
+ this.interaction.observe({
811
+ kind: "stt_partial",
812
+ contextId,
813
+ timestampMs,
814
+ text,
815
+ interruptedContextId,
816
+ ...(wordTimings ? { wordTimings } : {}),
817
+ });
818
+ return;
819
+ }
820
+ this.interaction.observe({
821
+ kind: "stt_final",
822
+ contextId,
823
+ timestampMs,
824
+ text,
825
+ confidence,
826
+ interruptedContextId,
827
+ ...(wordTimings ? { wordTimings } : {}),
828
+ });
689
829
  }
690
830
 
691
831
  private handleVadSpeechStarted(pkt: VadSpeechStartedPacket): void {
692
832
  this.lastFinalizedContextId = "";
833
+ this.userSpeaking = true;
693
834
 
694
835
  if (this.latencyFiller.isFillerOnly(this.currentTurnId)) {
695
836
  this.cancelLatencyFillerTurn(this.currentTurnId, pkt.timestampMs);
@@ -712,17 +853,27 @@ export class VoiceAgentSession {
712
853
  const interruptedContextId = this.latestActiveTtsContextId();
713
854
  if (!interruptedContextId) {
714
855
  this.speakerEnrollmentContextId = pkt.contextId;
715
- return;
716
856
  }
717
857
 
718
- this.turnArbiter.onSpeechStarted(pkt, interruptedContextId);
858
+ this.interaction.observe({
859
+ kind: "vad_speech_started",
860
+ contextId: pkt.contextId,
861
+ timestampMs: pkt.timestampMs,
862
+ confidence: pkt.confidence,
863
+ ...(interruptedContextId ? { interruptedContextId } : {}),
864
+ });
719
865
  }
720
866
 
721
867
  private handleVadSpeechActivity(pkt: VadSpeechActivityPacket): void {
722
- this.turnArbiter.onSpeechActivity(pkt);
868
+ this.interaction.observe({
869
+ kind: "vad_speech_activity",
870
+ contextId: pkt.contextId,
871
+ timestampMs: pkt.timestampMs,
872
+ });
723
873
  }
724
874
 
725
875
  private handleVadSpeechEnded(pkt: VadSpeechEndedPacket): void {
876
+ this.userSpeaking = false;
726
877
  this.emit("user_stopped_speaking", {
727
878
  tsMs: pkt.timestampMs,
728
879
  turnId: pkt.contextId,
@@ -738,12 +889,51 @@ export class VoiceAgentSession {
738
889
  this.speakerEnrollmentContextId = null;
739
890
  }
740
891
 
741
- this.turnArbiter.onSpeechEnded(pkt, Boolean(this.latestActiveTtsContextId()));
892
+ this.interaction.observe({
893
+ kind: "vad_speech_ended",
894
+ contextId: pkt.contextId,
895
+ timestampMs: pkt.timestampMs,
896
+ hasActiveTts: Boolean(this.latestActiveTtsContextId()),
897
+ });
742
898
 
743
899
  this.turnUserStoppedAtMs.set(pkt.contextId, pkt.timestampMs);
900
+ this.timingFor(pkt.contextId).speechEndedMs = pkt.timestampMs;
744
901
  this.watchdogs.startVaqiMissedResponseTimer(pkt.contextId, pkt.timestampMs);
745
902
  }
746
903
 
904
+ private timingFor(contextId: string): TurnTiming {
905
+ let timing = this.turnTimings.get(contextId);
906
+ if (!timing) {
907
+ timing = {};
908
+ this.turnTimings.set(contextId, timing);
909
+ }
910
+ return timing;
911
+ }
912
+
913
+ private emitTurnLatency(contextId: string, firstAudioMs: number): void {
914
+ const timing = this.turnTimings.get(contextId);
915
+ this.turnTimings.delete(contextId);
916
+ if (!timing) return;
917
+ const anchorMs = timing.speechEndedMs ?? timing.eosMs;
918
+ if (anchorMs === undefined) return; // text-injected or fallback turn — not a voice TTFA
919
+ this.emit("turn_latency", {
920
+ tsMs: firstAudioMs,
921
+ turnId: contextId,
922
+ ttfaMs: firstAudioMs - anchorMs,
923
+ ...(timing.speechEndedMs !== undefined && timing.eosMs !== undefined
924
+ ? { eouDelayMs: timing.eosMs - timing.speechEndedMs }
925
+ : {}),
926
+ ...(timing.eosMs !== undefined && timing.firstLlmDeltaMs !== undefined
927
+ ? { llmTtftMs: timing.firstLlmDeltaMs - timing.eosMs }
928
+ : {}),
929
+ ...(timing.firstTtsTextMs !== undefined
930
+ ? { ttsTtfbMs: firstAudioMs - timing.firstTtsTextMs }
931
+ : {}),
932
+ fillerUsed: timing.fillerUsed === true,
933
+ backchannelUsed: timing.backchannelUsed === true,
934
+ });
935
+ }
936
+
747
937
  private handleTurnComplete(pkt: EndOfSpeechPacket): void {
748
938
  if (this.lastFinalizedContextId === pkt.contextId) {
749
939
  this.bus.push(Route.Background, make.metric(pkt.contextId, "eos.duplicate_dropped", "1"));
@@ -764,6 +954,7 @@ export class VoiceAgentSession {
764
954
  }
765
955
 
766
956
  this.lastFinalizedContextId = pkt.contextId;
957
+ this.interaction.reset(pkt.contextId);
767
958
 
768
959
  // Re-arm per-turn guard state for the next turn. Transports with a stable
769
960
  // per-call contextId (telephony callSid) reuse one id across turns, so these
@@ -807,8 +998,13 @@ export class VoiceAgentSession {
807
998
  // disconnect prompt later in the call.
808
999
  this.bus.push(Route.Main, make.stopIdleTimeout(pkt.contextId, Date.now(), true));
809
1000
 
1001
+ const timing = this.timingFor(pkt.contextId);
1002
+ timing.eosMs = pkt.timestampMs;
1003
+
810
1004
  const fillerText = this.latencyFiller.start(pkt.contextId, pkt.text, pkt.timestampMs);
811
1005
  if (fillerText) {
1006
+ timing.fillerUsed = true;
1007
+ timing.firstTtsTextMs ??= pkt.timestampMs;
812
1008
  this.bus.push(Route.Main, make.ttsText(pkt.contextId, Date.now(), fillerText));
813
1009
  this.bus.push(Route.Background, make.metric(pkt.contextId, "filler.started", fillerText));
814
1010
  }
@@ -840,6 +1036,8 @@ export class VoiceAgentSession {
840
1036
  let deltaText = pkt.text;
841
1037
  if (!this.firstLlmDeltaReceived.has(pkt.contextId)) {
842
1038
  this.firstLlmDeltaReceived.add(pkt.contextId);
1039
+ const timing = this.turnTimings.get(pkt.contextId);
1040
+ if (timing) timing.firstLlmDeltaMs ??= pkt.timestampMs;
843
1041
  if (this.latencyFiller.isActive(pkt.contextId)) {
844
1042
  deltaText = this.latencyFiller.spliceLlmDelta(pkt.contextId, deltaText);
845
1043
  this.bus.push(Route.Background, make.metric(pkt.contextId, "filler.spliced", "1"));
@@ -858,7 +1056,7 @@ export class VoiceAgentSession {
858
1056
  timestampMs: pkt.timestampMs,
859
1057
  });
860
1058
 
861
- this.bufferTtsText(pkt.contextId, deltaText);
1059
+ this.bufferTtsText(pkt.contextId, deltaText, pkt.timestampMs);
862
1060
  }
863
1061
 
864
1062
  private handleLlmDone(pkt: LlmResponseDonePacket): void {
@@ -869,7 +1067,7 @@ export class VoiceAgentSession {
869
1067
  return;
870
1068
  }
871
1069
 
872
- const spokenText = this.flushTtsText(pkt.contextId);
1070
+ const spokenText = this.flushTtsText(pkt.contextId, pkt.timestampMs);
873
1071
  this.emit("agent_finished", {
874
1072
  tsMs: pkt.timestampMs,
875
1073
  turnId: pkt.contextId,
@@ -887,11 +1085,13 @@ export class VoiceAgentSession {
887
1085
  this.bus.push(Route.Main, make.ttsDone(pkt.contextId, Date.now(), spokenText));
888
1086
  }
889
1087
 
890
- private bufferTtsText(contextId: string, text: string): void {
1088
+ private bufferTtsText(contextId: string, text: string, tsMs?: number): void {
891
1089
  const buffer = this.ttsTextBuffers.get(contextId) ?? { pending: "", emitted: "" };
892
1090
  buffer.pending += text;
893
1091
  const complete = takeCompleteVoiceText(buffer.pending);
894
1092
  if (complete.text) {
1093
+ const timing = this.turnTimings.get(contextId);
1094
+ if (timing) timing.firstTtsTextMs ??= tsMs ?? Date.now();
895
1095
  this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), complete.text));
896
1096
  buffer.emitted = appendVoiceText(buffer.emitted, complete.text);
897
1097
  }
@@ -899,11 +1099,13 @@ export class VoiceAgentSession {
899
1099
  this.ttsTextBuffers.set(contextId, buffer);
900
1100
  }
901
1101
 
902
- private flushTtsText(contextId: string): string {
1102
+ private flushTtsText(contextId: string, tsMs?: number): string {
903
1103
  const buffer = this.ttsTextBuffers.get(contextId);
904
1104
  if (!buffer) return "";
905
1105
  const tail = buffer.pending.trim();
906
1106
  if (tail) {
1107
+ const timing = this.turnTimings.get(contextId);
1108
+ if (timing) timing.firstTtsTextMs ??= tsMs ?? Date.now();
907
1109
  this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), tail));
908
1110
  buffer.emitted = appendVoiceText(buffer.emitted, tail);
909
1111
  buffer.pending = "";
@@ -965,14 +1167,22 @@ export class VoiceAgentSession {
965
1167
  toolName: string,
966
1168
  afterMs?: number,
967
1169
  ): void {
1170
+ const tsMs = Date.now();
968
1171
  this.emit("tool_call_cue", {
969
- tsMs: Date.now(),
1172
+ tsMs,
970
1173
  turnId: contextId,
971
1174
  phase,
972
1175
  toolId,
973
1176
  toolName,
974
1177
  ...(afterMs !== undefined ? { afterMs } : {}),
975
1178
  });
1179
+ this.interaction.observe({
1180
+ kind: "delegate_state",
1181
+ contextId,
1182
+ timestampMs: tsMs,
1183
+ delegateInFlight: phase === "started" || phase === "delayed",
1184
+ toolCallPhase: phase,
1185
+ });
976
1186
  }
977
1187
 
978
1188
  /** G3: resolve one pending tool call with a terminal cue phase. */
@@ -1077,6 +1287,7 @@ export class VoiceAgentSession {
1077
1287
  );
1078
1288
  this.turnUserStoppedAtMs.delete(pkt.contextId);
1079
1289
  }
1290
+ this.emitTurnLatency(pkt.contextId, pkt.timestampMs);
1080
1291
  }
1081
1292
 
1082
1293
  this.primarySpeakerGate.observeAssistantPlayout(pkt.audio);
@@ -1089,6 +1300,14 @@ export class VoiceAgentSession {
1089
1300
  const audioDurationMs = estimatePcm16Duration(pkt.audio, sampleRateHz);
1090
1301
  const now = Date.now();
1091
1302
  this.ttsPlayout.noteAudio(pkt.contextId, audioDurationMs, now);
1303
+ this.interaction.observe({
1304
+ kind: "playout_tick",
1305
+ contextId: pkt.contextId,
1306
+ timestampMs: pkt.timestampMs,
1307
+ ttsActive: true,
1308
+ audio: pcm16BytesToSamples(pkt.audio),
1309
+ sampleRateHz,
1310
+ });
1092
1311
 
1093
1312
  // Anchor the idle timer to when playout actually *ends* (P2), not to chunk
1094
1313
  // arrival. TTS streams faster than realtime, so extending by each chunk's
@@ -1115,7 +1334,11 @@ export class VoiceAgentSession {
1115
1334
  // Generation finished, but the streamed audio is still playing out. Keep the
1116
1335
  // context interruptible until its playout estimate elapses, then release it.
1117
1336
  this.generatingContextIds.delete(pkt.contextId);
1118
- this.ttsPlayout.scheduleRelease(pkt.contextId, Date.now());
1337
+ const now = Date.now();
1338
+ this.ttsPlayout.scheduleRelease(pkt.contextId, now);
1339
+ const playoutEndMs = this.ttsPlayout.playoutEnd(pkt.contextId);
1340
+ const remainingMs = playoutEndMs === undefined ? 0 : Math.max(0, playoutEndMs - now);
1341
+ this.scheduleInteractionPlayoutTick(pkt.contextId, remainingMs);
1119
1342
  this.watchdogs.clearTtsStallTimerFor(pkt.contextId);
1120
1343
  this.debugPush({
1121
1344
  component: "tts",
@@ -1125,14 +1348,43 @@ export class VoiceAgentSession {
1125
1348
  });
1126
1349
  }
1127
1350
 
1351
+ private scheduleInteractionPlayoutTick(contextId: string, delayMs: number): void {
1352
+ this.pendingInteractionPlayoutTimers.add(contextId);
1353
+ this.scheduler.schedule(interactionPlayoutTimerKey(contextId), delayMs, () => {
1354
+ this.pendingInteractionPlayoutTimers.delete(contextId);
1355
+ if (this.ttsPlayout.isActive(contextId)) return;
1356
+ this.interaction.observe({
1357
+ kind: "playout_tick",
1358
+ contextId,
1359
+ timestampMs: Date.now(),
1360
+ ttsActive: false,
1361
+ });
1362
+ });
1363
+ }
1364
+
1365
+ private cancelInteractionPlayoutTick(contextId: string): void {
1366
+ if (!this.pendingInteractionPlayoutTimers.delete(contextId)) return;
1367
+ this.scheduler.cancel(interactionPlayoutTimerKey(contextId));
1368
+ }
1369
+
1128
1370
  private handleTtsPlayoutProgress(pkt: TextToSpeechPlayoutProgressPacket): void {
1371
+ this.cancelInteractionPlayoutTick(pkt.contextId);
1129
1372
  this.ttsPlayout.noteProgress(pkt.contextId, pkt.complete, pkt.playedOutMs);
1373
+ this.interaction.observe({
1374
+ kind: "playout_tick",
1375
+ contextId: pkt.contextId,
1376
+ timestampMs: pkt.timestampMs,
1377
+ playedOutMs: pkt.playedOutMs,
1378
+ ttsActive: !pkt.complete,
1379
+ });
1130
1380
  }
1131
1381
 
1132
1382
  private handleInterruptDetected(pkt: InterruptionDetectedPacket): void {
1383
+ this.cancelInteractionPlayoutTick(pkt.contextId);
1133
1384
  this.interruptedGenerationContextIds.add(pkt.contextId);
1134
1385
  this.failPendingToolCues(pkt.contextId); // G3: the aborted delegate's cue fails (R5)
1135
1386
  this.latencyFiller.cancel(pkt.contextId);
1387
+ this.turnTimings.delete(pkt.contextId);
1136
1388
  this.firstLlmDeltaReceived.delete(pkt.contextId);
1137
1389
  this.ttsTextBuffers.delete(pkt.contextId);
1138
1390
  this.ttsPlayout.release(pkt.contextId);
@@ -1178,10 +1430,12 @@ export class VoiceAgentSession {
1178
1430
  * the LLM, and drops late deltas/audio for the stale context.
1179
1431
  */
1180
1432
  private cancelStaleGeneration(contextId: string, timestampMs: number): void {
1433
+ this.cancelInteractionPlayoutTick(contextId);
1181
1434
  this.interruptedGenerationContextIds.add(contextId);
1182
1435
  this.failPendingToolCues(contextId); // G3: a superseded turn's pending cue fails
1183
1436
  this.generatingContextIds.delete(contextId);
1184
1437
  this.latencyFiller.cancel(contextId);
1438
+ this.turnTimings.delete(contextId);
1185
1439
  this.firstLlmDeltaReceived.delete(contextId);
1186
1440
  this.ttsTextBuffers.delete(contextId);
1187
1441
  this.ttsPlayout.release(contextId);
@@ -1298,6 +1552,19 @@ export class VoiceAgentSession {
1298
1552
  });
1299
1553
  }
1300
1554
 
1555
+ if (
1556
+ this.activeInteractionPolicy === this.injectedInteractionPolicy &&
1557
+ isLifecycleInteractionPolicy(this.activeInteractionPolicy)
1558
+ ) {
1559
+ const policy = this.activeInteractionPolicy;
1560
+ steps.push({
1561
+ name: "interaction_policy",
1562
+ stage: InitStage.EOS,
1563
+ run: () => policy.initialize(this.config.interactionPolicyConfig ?? {}),
1564
+ cleanup: () => policy.close(),
1565
+ });
1566
+ }
1567
+
1301
1568
  const orderedPluginSteps = steps.sort(
1302
1569
  (a, b) => stageOrder(a.stage) - stageOrder(b.stage),
1303
1570
  );
@@ -1327,6 +1594,20 @@ export class VoiceAgentSession {
1327
1594
  }
1328
1595
 
1329
1596
  private applyEndpointingOwnerInvariant(): void {
1597
+ if (this.activeInteractionPolicy === this.injectedInteractionPolicy) {
1598
+ for (const [name, plugin] of this.plugins) {
1599
+ const capability = plugin.endpointingCapability;
1600
+ if (!capability || capability.owner !== "provider_stt") continue;
1601
+ if (!capability.disableConfig) {
1602
+ throw new Error(`interactionPolicy requires ${name} to expose endpointingCapability.disableConfig`);
1603
+ }
1604
+ this.config.plugins[name] = {
1605
+ ...(this.config.plugins[name] ?? {}),
1606
+ ...capability.disableConfig,
1607
+ };
1608
+ }
1609
+ return;
1610
+ }
1330
1611
  if (this.endpointingOwner === "timer") return;
1331
1612
  const owner: EndpointingOwner = this.endpointingOwner;
1332
1613
  const finalizers = [...this.plugins.entries()]
@@ -1352,6 +1633,9 @@ export class VoiceAgentSession {
1352
1633
  private shouldInitializePlugin(plugin: VoicePlugin): boolean {
1353
1634
  const capability = plugin.endpointingCapability;
1354
1635
  if (!capability) return true;
1636
+ if (this.activeInteractionPolicy === this.injectedInteractionPolicy) {
1637
+ return capability.owner === "provider_stt";
1638
+ }
1355
1639
  if (this.endpointingOwner === "timer") return false;
1356
1640
  if (capability.owner === "smart_turn") return capability.owner === this.endpointingOwner;
1357
1641
  return true;