@kuralle-syrinx/core 4.1.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?: {
@@ -193,8 +215,8 @@ export interface VoiceAgentSessionEvents {
193
215
  /**
194
216
  * Per-turn latency decomposition, emitted once at the turn's first TTS audio.
195
217
  * `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.
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.
198
220
  * Decomposition: eouDelayMs (speech end → endpoint) + llmTtftMs (endpoint →
199
221
  * first LLM delta) + ttsTtfbMs (first TTS text dispatched → first audio).
200
222
  */
@@ -206,6 +228,7 @@ export interface VoiceAgentSessionEvents {
206
228
  llmTtftMs?: number;
207
229
  ttsTtfbMs?: number;
208
230
  fillerUsed: boolean;
231
+ backchannelUsed: boolean;
209
232
  }) => void;
210
233
  agent_first_audio: (event: { tsMs: number; turnId: string }) => void;
211
234
  agent_finished: (event: { tsMs: number; turnId: string } & Record<string, unknown>) => void;
@@ -227,6 +250,7 @@ interface TurnTiming {
227
250
  firstLlmDeltaMs?: number;
228
251
  firstTtsTextMs?: number;
229
252
  fillerUsed?: boolean;
253
+ backchannelUsed?: boolean;
230
254
  }
231
255
 
232
256
  /** Suffix marking a context created to speak an error fallback, so it never recurses. */
@@ -237,6 +261,10 @@ function toolCueTimerKey(contextId: string, toolId: string): string {
237
261
  return `tool_cue:${contextId}:${toolId}`;
238
262
  }
239
263
 
264
+ function interactionPlayoutTimerKey(contextId: string): string {
265
+ return `interaction.playout:${contextId}`;
266
+ }
267
+
240
268
  // =============================================================================
241
269
  // Session Implementation
242
270
  // =============================================================================
@@ -268,7 +296,10 @@ export class VoiceAgentSession {
268
296
  private ttsTextBuffers = new Map<string, TtsTextBuffer>();
269
297
  private readonly minInterruptionMs: number;
270
298
  private readonly primarySpeakerGate: PrimarySpeakerGate;
271
- 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;
272
303
  private readonly latencyFiller: LatencyFillerController;
273
304
  private firstLlmDeltaReceived = new Set<string>();
274
305
  private readonly vaqiMissedResponseMs: number;
@@ -280,13 +311,18 @@ export class VoiceAgentSession {
280
311
  private turnTimings = new Map<string, TurnTiming>();
281
312
  private speakerEnrollmentContextId: string | null = null;
282
313
  private firstTtsAudioFired = new Set<string>();
314
+ private readonly pendingInteractionPlayoutTimers = new Set<string>();
283
315
  private readonly errorFallbackText: string;
284
316
  private fallbackInjectedContexts = new Set<string>();
285
317
  // G3: pending tool calls per context (toolId → toolName) driving the tool_call_cue lifecycle.
286
318
  private readonly delayCueAfterMs: number;
287
319
  private pendingToolCues = new Map<string, Map<string, string>>();
288
320
  private readonly endpointingOwner: "provider_stt" | "smart_turn" | "timer";
321
+ private readonly fullDuplex: boolean;
322
+ private readonly emitsBackchannel: boolean;
323
+ private userSpeaking = false;
289
324
  private lastFinalizedContextId = "";
325
+ private readonly sttPartialWordTimings = new Map<string, readonly WordTiming[]>();
290
326
 
291
327
  constructor(config: VoiceAgentSessionConfig) {
292
328
  const owner = config.endpointingOwner;
@@ -294,6 +330,8 @@ export class VoiceAgentSession {
294
330
  throw new Error(`Unsupported endpointingOwner: ${owner}`);
295
331
  }
296
332
  this.endpointingOwner = owner ?? "provider_stt";
333
+ this.fullDuplex = config.fullDuplex === true;
334
+ this.emitsBackchannel = config.emitsBackchannel === true;
297
335
  this.config = config;
298
336
  this.scheduler = config.scheduler ?? new TimerScheduler();
299
337
  this.ttsPlayout = new TtsPlayoutClock(this.scheduler);
@@ -344,12 +382,29 @@ export class VoiceAgentSession {
344
382
  },
345
383
  });
346
384
 
347
- this.turnArbiter = new TurnArbiter({
385
+ this.injectedInteractionPolicy = config.interactionPolicy ?? null;
386
+ this.ruleBasedPolicy = new RuleBasedInteractionPolicy({
348
387
  bus: this.bus,
349
388
  primarySpeakerGate: this.primarySpeakerGate,
350
389
  ttsPlayout: this.ttsPlayout,
351
390
  minInterruptionMs: this.minInterruptionMs,
352
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
+ });
353
408
  this.watchdogs = new VoiceSessionWatchdogs({
354
409
  bus: this.bus,
355
410
  plugins: this.plugins,
@@ -386,6 +441,10 @@ export class VoiceAgentSession {
386
441
  this.modeSwitcher = new ModeSwitcher(this.bus);
387
442
  }
388
443
 
444
+ private get turnArbiter(): TurnArbiter {
445
+ return this.ruleBasedPolicy.arbiter;
446
+ }
447
+
389
448
  // =========================================================================
390
449
  // Public API
391
450
  // =========================================================================
@@ -412,6 +471,7 @@ export class VoiceAgentSession {
412
471
 
413
472
  // 1. Wire all bus handlers
414
473
  this.wireBusHandlers();
474
+ this.interaction.initialize();
415
475
 
416
476
  // 2. Start bus drain loop
417
477
  this.busStartPromise = this.bus.start();
@@ -443,9 +503,14 @@ export class VoiceAgentSession {
443
503
 
444
504
  // 1. Stop idle timeout
445
505
  this.idleTimeout.dispose();
506
+ this.interaction.dispose();
446
507
  this.watchdogs.dispose();
447
508
  this.observabilityObserver.dispose();
448
509
  this.ttsPlayout.clear();
510
+ for (const contextId of this.pendingInteractionPlayoutTimers) {
511
+ this.scheduler.cancel(interactionPlayoutTimerKey(contextId));
512
+ }
513
+ this.pendingInteractionPlayoutTimers.clear();
449
514
  this.turnArbiter.clear();
450
515
  this.turnUserStoppedAtMs.clear();
451
516
  this.turnTimings.clear();
@@ -454,6 +519,7 @@ export class VoiceAgentSession {
454
519
  this.ttsTextBuffers.clear();
455
520
  this.interruptedGenerationContextIds.clear();
456
521
  this.firstLlmDeltaReceived.clear();
522
+ this.sttPartialWordTimings.clear();
457
523
  for (const [contextId, pending] of this.pendingToolCues) {
458
524
  for (const toolId of pending.keys()) this.scheduler.cancel(toolCueTimerKey(contextId, toolId));
459
525
  }
@@ -538,6 +604,7 @@ export class VoiceAgentSession {
538
604
  // STT results
539
605
  this.bus.on("stt.audio", this.handleSttAudio.bind(this));
540
606
  this.bus.on("stt.interim", this.handleSttInterim.bind(this));
607
+ this.bus.on("stt.partial", this.handleSttPartial.bind(this));
541
608
  this.bus.on("stt.result", this.handleSttResult.bind(this));
542
609
 
543
610
  // VAD
@@ -547,8 +614,11 @@ export class VoiceAgentSession {
547
614
  this.bus.on("vad.audio", this.handleVadAudioForSpeakerGate.bind(this));
548
615
 
549
616
  // EOS
550
- this.bus.on("turn.change", () => {
617
+ this.bus.on("turn.change", (pkt: TurnChangePacket) => {
551
618
  this.lastFinalizedContextId = "";
619
+ if (pkt.previousContextId) {
620
+ this.sttPartialWordTimings.delete(pkt.previousContextId);
621
+ }
552
622
  });
553
623
  this.bus.on("eos.turn_complete", this.handleTurnComplete.bind(this));
554
624
  this.bus.on("eos.interim", this.handleEosInterim.bind(this));
@@ -635,9 +705,22 @@ export class VoiceAgentSession {
635
705
  timestampMs: pkt.timestampMs,
636
706
  });
637
707
 
708
+ this.observeAudioFrame(pkt);
709
+
638
710
  this.watchdogs.scheduleInputCadenceWatchdog(pkt.contextId);
639
711
  }
640
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
+
641
724
  private handleSttAudio(pkt: SpeechToTextAudioPacket): void {
642
725
  this.watchdogs.scheduleSttForceFinalize(pkt.contextId);
643
726
  }
@@ -647,9 +730,14 @@ export class VoiceAgentSession {
647
730
  this.bus.push(Route.Main, make.eosTurnComplete(pkt.contextId, pkt.timestampMs, pkt.text, []));
648
731
  }
649
732
 
733
+ private handleSttPartial(pkt: SttPartialPacket): void {
734
+ if (pkt.wordTimings) {
735
+ this.sttPartialWordTimings.set(pkt.contextId, pkt.wordTimings);
736
+ }
737
+ }
738
+
650
739
  private handleSttInterim(pkt: SttInterimPacket): void {
651
- this.turnArbiter.noteInterimEvidence(pkt.text);
652
- this.maybeBargeInFromProviderStt(pkt.contextId, pkt.text, pkt.timestampMs);
740
+ this.observeSttForBargeIn(pkt.contextId, pkt.timestampMs, pkt.text, "stt_partial");
653
741
  this.currentTurnId = pkt.contextId;
654
742
  this.emit("user_input_partial", {
655
743
  tsMs: pkt.timestampMs,
@@ -666,8 +754,7 @@ export class VoiceAgentSession {
666
754
 
667
755
  private handleSttResult(pkt: SttResultPacket): void {
668
756
  this.watchdogs.clearSttForceFinalizeIfContext(pkt.contextId);
669
- this.turnArbiter.noteInterimEvidence(pkt.text, pkt.confidence);
670
- this.maybeBargeInFromProviderStt(pkt.contextId, pkt.text, pkt.timestampMs);
757
+ this.observeSttForBargeIn(pkt.contextId, pkt.timestampMs, pkt.text, "stt_final", pkt.confidence);
671
758
  this.currentTurnId = pkt.contextId;
672
759
  this.emit("user_input_final", {
673
760
  tsMs: pkt.timestampMs,
@@ -688,7 +775,7 @@ export class VoiceAgentSession {
688
775
  }
689
776
 
690
777
  private handleVadAudioForSpeakerGate(pkt: VadAudioPacket): void {
691
- if (this.turnArbiter.observeBargeInAudio(pkt)) return;
778
+ if (this.interaction.observeBargeInAudio(pkt)) return;
692
779
 
693
780
  if (this.shouldEnrollPrimarySpeaker(pkt.contextId)) {
694
781
  this.primarySpeakerGate.enrollUserTurnChunk(pkt.audio);
@@ -707,16 +794,43 @@ export class VoiceAgentSession {
707
794
  // Provider transcripts arriving while TTS playout is active are the speech
708
795
  // evidence instead (echo of our own playout is mitigated by client AEC plus
709
796
  // 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);
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
+ });
716
829
  }
717
830
 
718
831
  private handleVadSpeechStarted(pkt: VadSpeechStartedPacket): void {
719
832
  this.lastFinalizedContextId = "";
833
+ this.userSpeaking = true;
720
834
 
721
835
  if (this.latencyFiller.isFillerOnly(this.currentTurnId)) {
722
836
  this.cancelLatencyFillerTurn(this.currentTurnId, pkt.timestampMs);
@@ -739,17 +853,27 @@ export class VoiceAgentSession {
739
853
  const interruptedContextId = this.latestActiveTtsContextId();
740
854
  if (!interruptedContextId) {
741
855
  this.speakerEnrollmentContextId = pkt.contextId;
742
- return;
743
856
  }
744
857
 
745
- 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
+ });
746
865
  }
747
866
 
748
867
  private handleVadSpeechActivity(pkt: VadSpeechActivityPacket): void {
749
- this.turnArbiter.onSpeechActivity(pkt);
868
+ this.interaction.observe({
869
+ kind: "vad_speech_activity",
870
+ contextId: pkt.contextId,
871
+ timestampMs: pkt.timestampMs,
872
+ });
750
873
  }
751
874
 
752
875
  private handleVadSpeechEnded(pkt: VadSpeechEndedPacket): void {
876
+ this.userSpeaking = false;
753
877
  this.emit("user_stopped_speaking", {
754
878
  tsMs: pkt.timestampMs,
755
879
  turnId: pkt.contextId,
@@ -765,7 +889,12 @@ export class VoiceAgentSession {
765
889
  this.speakerEnrollmentContextId = null;
766
890
  }
767
891
 
768
- 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
+ });
769
898
 
770
899
  this.turnUserStoppedAtMs.set(pkt.contextId, pkt.timestampMs);
771
900
  this.timingFor(pkt.contextId).speechEndedMs = pkt.timestampMs;
@@ -801,6 +930,7 @@ export class VoiceAgentSession {
801
930
  ? { ttsTtfbMs: firstAudioMs - timing.firstTtsTextMs }
802
931
  : {}),
803
932
  fillerUsed: timing.fillerUsed === true,
933
+ backchannelUsed: timing.backchannelUsed === true,
804
934
  });
805
935
  }
806
936
 
@@ -824,6 +954,7 @@ export class VoiceAgentSession {
824
954
  }
825
955
 
826
956
  this.lastFinalizedContextId = pkt.contextId;
957
+ this.interaction.reset(pkt.contextId);
827
958
 
828
959
  // Re-arm per-turn guard state for the next turn. Transports with a stable
829
960
  // per-call contextId (telephony callSid) reuse one id across turns, so these
@@ -1036,14 +1167,22 @@ export class VoiceAgentSession {
1036
1167
  toolName: string,
1037
1168
  afterMs?: number,
1038
1169
  ): void {
1170
+ const tsMs = Date.now();
1039
1171
  this.emit("tool_call_cue", {
1040
- tsMs: Date.now(),
1172
+ tsMs,
1041
1173
  turnId: contextId,
1042
1174
  phase,
1043
1175
  toolId,
1044
1176
  toolName,
1045
1177
  ...(afterMs !== undefined ? { afterMs } : {}),
1046
1178
  });
1179
+ this.interaction.observe({
1180
+ kind: "delegate_state",
1181
+ contextId,
1182
+ timestampMs: tsMs,
1183
+ delegateInFlight: phase === "started" || phase === "delayed",
1184
+ toolCallPhase: phase,
1185
+ });
1047
1186
  }
1048
1187
 
1049
1188
  /** G3: resolve one pending tool call with a terminal cue phase. */
@@ -1161,6 +1300,14 @@ export class VoiceAgentSession {
1161
1300
  const audioDurationMs = estimatePcm16Duration(pkt.audio, sampleRateHz);
1162
1301
  const now = Date.now();
1163
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
+ });
1164
1311
 
1165
1312
  // Anchor the idle timer to when playout actually *ends* (P2), not to chunk
1166
1313
  // arrival. TTS streams faster than realtime, so extending by each chunk's
@@ -1187,7 +1334,11 @@ export class VoiceAgentSession {
1187
1334
  // Generation finished, but the streamed audio is still playing out. Keep the
1188
1335
  // context interruptible until its playout estimate elapses, then release it.
1189
1336
  this.generatingContextIds.delete(pkt.contextId);
1190
- 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);
1191
1342
  this.watchdogs.clearTtsStallTimerFor(pkt.contextId);
1192
1343
  this.debugPush({
1193
1344
  component: "tts",
@@ -1197,11 +1348,39 @@ export class VoiceAgentSession {
1197
1348
  });
1198
1349
  }
1199
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
+
1200
1370
  private handleTtsPlayoutProgress(pkt: TextToSpeechPlayoutProgressPacket): void {
1371
+ this.cancelInteractionPlayoutTick(pkt.contextId);
1201
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
+ });
1202
1380
  }
1203
1381
 
1204
1382
  private handleInterruptDetected(pkt: InterruptionDetectedPacket): void {
1383
+ this.cancelInteractionPlayoutTick(pkt.contextId);
1205
1384
  this.interruptedGenerationContextIds.add(pkt.contextId);
1206
1385
  this.failPendingToolCues(pkt.contextId); // G3: the aborted delegate's cue fails (R5)
1207
1386
  this.latencyFiller.cancel(pkt.contextId);
@@ -1251,6 +1430,7 @@ export class VoiceAgentSession {
1251
1430
  * the LLM, and drops late deltas/audio for the stale context.
1252
1431
  */
1253
1432
  private cancelStaleGeneration(contextId: string, timestampMs: number): void {
1433
+ this.cancelInteractionPlayoutTick(contextId);
1254
1434
  this.interruptedGenerationContextIds.add(contextId);
1255
1435
  this.failPendingToolCues(contextId); // G3: a superseded turn's pending cue fails
1256
1436
  this.generatingContextIds.delete(contextId);
@@ -1372,6 +1552,19 @@ export class VoiceAgentSession {
1372
1552
  });
1373
1553
  }
1374
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
+
1375
1568
  const orderedPluginSteps = steps.sort(
1376
1569
  (a, b) => stageOrder(a.stage) - stageOrder(b.stage),
1377
1570
  );
@@ -1401,6 +1594,20 @@ export class VoiceAgentSession {
1401
1594
  }
1402
1595
 
1403
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
+ }
1404
1611
  if (this.endpointingOwner === "timer") return;
1405
1612
  const owner: EndpointingOwner = this.endpointingOwner;
1406
1613
  const finalizers = [...this.plugins.entries()]
@@ -1426,6 +1633,9 @@ export class VoiceAgentSession {
1426
1633
  private shouldInitializePlugin(plugin: VoicePlugin): boolean {
1427
1634
  const capability = plugin.endpointingCapability;
1428
1635
  if (!capability) return true;
1636
+ if (this.activeInteractionPolicy === this.injectedInteractionPolicy) {
1637
+ return capability.owner === "provider_stt";
1638
+ }
1429
1639
  if (this.endpointingOwner === "timer") return false;
1430
1640
  if (capability.owner === "smart_turn") return capability.owner === this.endpointingOwner;
1431
1641
  return true;