@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.
- package/README.md +4 -0
- package/package.json +21 -2
- package/src/confidence-to-wait.test.ts +21 -0
- package/src/confidence-to-wait.ts +30 -0
- package/src/hedge-throwing-backend.test.ts +46 -0
- package/src/incremental-unit.ts +19 -0
- package/src/index.ts +40 -0
- package/src/interaction-coordinator.test.ts +425 -0
- package/src/interaction-coordinator.ts +240 -0
- package/src/interaction-policy.ts +104 -0
- package/src/iu-ledger.test.ts +276 -0
- package/src/iu-ledger.ts +110 -0
- package/src/packet-factories.test.ts +20 -1
- package/src/packet-factories.ts +27 -0
- package/src/packets.ts +24 -3
- package/src/policies/defer.test.ts +86 -0
- package/src/policies/defer.ts +15 -0
- package/src/policies/rule-based.test.ts +269 -0
- package/src/policies/rule-based.ts +140 -0
- package/src/reasoner-hedge.test.ts +361 -0
- package/src/reasoner-hedge.ts +216 -0
- package/src/reasoner-route.test.ts +248 -0
- package/src/reasoner-route.ts +113 -0
- package/src/route-throwing-spec.test.ts +44 -0
- package/src/turn-arbiter.ts +11 -2
- package/src/voice-agent-session.test.ts +439 -2
- package/src/voice-agent-session.ts +232 -22
|
@@ -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) —
|
|
197
|
-
*
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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;
|