@kuralle-syrinx/core 4.4.0 → 4.5.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/dist/audio/alaw.d.ts +3 -0
- package/dist/audio/alaw.d.ts.map +1 -0
- package/dist/audio/alaw.js +53 -0
- package/dist/audio/alaw.js.map +1 -0
- package/dist/audio/g722.d.ts +39 -0
- package/dist/audio/g722.d.ts.map +1 -0
- package/dist/audio/g722.js +286 -0
- package/dist/audio/g722.js.map +1 -0
- package/dist/audio/index.d.ts +7 -0
- package/dist/audio/index.d.ts.map +1 -0
- package/dist/audio/index.js +8 -0
- package/dist/audio/index.js.map +1 -0
- package/dist/audio/loudness.d.ts +13 -0
- package/dist/audio/loudness.d.ts.map +1 -0
- package/dist/audio/loudness.js +65 -0
- package/dist/audio/loudness.js.map +1 -0
- package/dist/audio/mulaw.d.ts +3 -0
- package/dist/audio/mulaw.d.ts.map +1 -0
- package/dist/audio/mulaw.js +39 -0
- package/dist/audio/mulaw.js.map +1 -0
- package/dist/audio/pcm.d.ts +5 -0
- package/dist/audio/pcm.d.ts.map +1 -0
- package/dist/audio/pcm.js +40 -0
- package/dist/audio/pcm.js.map +1 -0
- package/dist/audio/resample.d.ts +12 -0
- package/dist/audio/resample.d.ts.map +1 -0
- package/dist/audio/resample.js +157 -0
- package/dist/audio/resample.js.map +1 -0
- package/dist/audio-envelope.d.ts +23 -0
- package/dist/audio-envelope.d.ts.map +1 -0
- package/dist/audio-envelope.js +114 -0
- package/dist/audio-envelope.js.map +1 -0
- package/dist/confidence-to-wait.d.ts +12 -0
- package/dist/confidence-to-wait.d.ts.map +1 -0
- package/dist/confidence-to-wait.js +18 -0
- package/dist/confidence-to-wait.js.map +1 -0
- package/dist/conversation-event.d.ts +19 -0
- package/dist/conversation-event.d.ts.map +1 -0
- package/dist/conversation-event.js +44 -0
- package/dist/conversation-event.js.map +1 -0
- package/dist/error-handler.d.ts +35 -0
- package/dist/error-handler.d.ts.map +1 -0
- package/dist/error-handler.js +136 -0
- package/dist/error-handler.js.map +1 -0
- package/dist/idle-timeout.d.ts +69 -0
- package/dist/idle-timeout.d.ts.map +1 -0
- package/dist/idle-timeout.js +149 -0
- package/dist/idle-timeout.js.map +1 -0
- package/dist/incremental-unit.d.ts +17 -0
- package/dist/incremental-unit.d.ts.map +1 -0
- package/dist/incremental-unit.js +5 -0
- package/dist/incremental-unit.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/init-chain.d.ts +35 -0
- package/dist/init-chain.d.ts.map +1 -0
- package/dist/init-chain.js +102 -0
- package/dist/init-chain.js.map +1 -0
- package/dist/init-stage-order.d.ts +5 -0
- package/dist/init-stage-order.d.ts.map +1 -0
- package/dist/init-stage-order.js +74 -0
- package/dist/init-stage-order.js.map +1 -0
- package/dist/interaction-coordinator.d.ts +45 -0
- package/dist/interaction-coordinator.d.ts.map +1 -0
- package/dist/interaction-coordinator.js +197 -0
- package/dist/interaction-coordinator.js.map +1 -0
- package/dist/interaction-policy.d.ts +103 -0
- package/dist/interaction-policy.d.ts.map +1 -0
- package/dist/interaction-policy.js +6 -0
- package/dist/interaction-policy.js.map +1 -0
- package/dist/iu-ledger.d.ts +39 -0
- package/dist/iu-ledger.d.ts.map +1 -0
- package/dist/iu-ledger.js +90 -0
- package/dist/iu-ledger.js.map +1 -0
- package/dist/latency-filler-fixtures.d.ts +7 -0
- package/dist/latency-filler-fixtures.d.ts.map +1 -0
- package/dist/latency-filler-fixtures.js +10 -0
- package/dist/latency-filler-fixtures.js.map +1 -0
- package/dist/latency-filler.d.ts +29 -0
- package/dist/latency-filler.d.ts.map +1 -0
- package/dist/latency-filler.js +102 -0
- package/dist/latency-filler.js.map +1 -0
- package/dist/mode-switcher.d.ts +24 -0
- package/dist/mode-switcher.d.ts.map +1 -0
- package/dist/mode-switcher.js +83 -0
- package/dist/mode-switcher.js.map +1 -0
- package/dist/observability-observer.d.ts +36 -0
- package/dist/observability-observer.d.ts.map +1 -0
- package/dist/observability-observer.js +141 -0
- package/dist/observability-observer.js.map +1 -0
- package/dist/observability.d.ts +68 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +70 -0
- package/dist/observability.js.map +1 -0
- package/dist/packet-factories.d.ts +58 -0
- package/dist/packet-factories.d.ts.map +1 -0
- package/dist/packet-factories.js +181 -0
- package/dist/packet-factories.js.map +1 -0
- package/dist/packets.d.ts +532 -0
- package/dist/packets.d.ts.map +1 -0
- package/dist/packets.js +51 -0
- package/dist/packets.js.map +1 -0
- package/dist/pipeline-bus.d.ts +92 -0
- package/dist/pipeline-bus.d.ts.map +1 -0
- package/dist/pipeline-bus.js +294 -0
- package/dist/pipeline-bus.js.map +1 -0
- package/dist/plugin-contract.d.ts +73 -0
- package/dist/plugin-contract.d.ts.map +1 -0
- package/dist/plugin-contract.js +30 -0
- package/dist/plugin-contract.js.map +1 -0
- package/dist/policies/defer.d.ts +11 -0
- package/dist/policies/defer.d.ts.map +1 -0
- package/dist/policies/defer.js +13 -0
- package/dist/policies/defer.js.map +1 -0
- package/dist/policies/rule-based.d.ts +35 -0
- package/dist/policies/rule-based.d.ts.map +1 -0
- package/dist/policies/rule-based.js +130 -0
- package/dist/policies/rule-based.js.map +1 -0
- package/dist/pricing.d.ts +34 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +108 -0
- package/dist/pricing.js.map +1 -0
- package/dist/primary-speaker-fixtures.d.ts +12 -0
- package/dist/primary-speaker-fixtures.d.ts.map +1 -0
- package/dist/primary-speaker-fixtures.js +37 -0
- package/dist/primary-speaker-fixtures.js.map +1 -0
- package/dist/primary-speaker-gate.d.ts +42 -0
- package/dist/primary-speaker-gate.d.ts.map +1 -0
- package/dist/primary-speaker-gate.js +178 -0
- package/dist/primary-speaker-gate.js.map +1 -0
- package/dist/provider-fallback.d.ts +28 -0
- package/dist/provider-fallback.d.ts.map +1 -0
- package/dist/provider-fallback.js +72 -0
- package/dist/provider-fallback.js.map +1 -0
- package/dist/reasoner-hedge.d.ts +21 -0
- package/dist/reasoner-hedge.d.ts.map +1 -0
- package/dist/reasoner-hedge.js +170 -0
- package/dist/reasoner-hedge.js.map +1 -0
- package/dist/reasoner-route.d.ts +24 -0
- package/dist/reasoner-route.d.ts.map +1 -0
- package/dist/reasoner-route.js +87 -0
- package/dist/reasoner-route.js.map +1 -0
- package/dist/reasoner-session-store.d.ts +18 -0
- package/dist/reasoner-session-store.d.ts.map +1 -0
- package/dist/reasoner-session-store.js +21 -0
- package/dist/reasoner-session-store.js.map +1 -0
- package/dist/reasoner.d.ts +97 -0
- package/dist/reasoner.d.ts.map +1 -0
- package/dist/reasoner.js +9 -0
- package/dist/reasoner.js.map +1 -0
- package/dist/retry.d.ts +31 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +83 -0
- package/dist/retry.js.map +1 -0
- package/dist/scheduler.d.ts +11 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +20 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/spend-cap.d.ts +27 -0
- package/dist/spend-cap.d.ts.map +1 -0
- package/dist/spend-cap.js +38 -0
- package/dist/spend-cap.js.map +1 -0
- package/dist/tts-playout-clock.d.ts +43 -0
- package/dist/tts-playout-clock.d.ts.map +1 -0
- package/dist/tts-playout-clock.js +125 -0
- package/dist/tts-playout-clock.js.map +1 -0
- package/dist/turn-arbiter.d.ts +41 -0
- package/dist/turn-arbiter.d.ts.map +1 -0
- package/dist/turn-arbiter.js +259 -0
- package/dist/turn-arbiter.js.map +1 -0
- package/dist/voice-agent-session-util.d.ts +60 -0
- package/dist/voice-agent-session-util.d.ts.map +1 -0
- package/dist/voice-agent-session-util.js +204 -0
- package/dist/voice-agent-session-util.js.map +1 -0
- package/dist/voice-agent-session.d.ts +435 -0
- package/dist/voice-agent-session.d.ts.map +1 -0
- package/dist/voice-agent-session.js +1597 -0
- package/dist/voice-agent-session.js.map +1 -0
- package/dist/voice-text.d.ts +46 -0
- package/dist/voice-text.d.ts.map +1 -0
- package/dist/voice-text.js +209 -0
- package/dist/voice-text.js.map +1 -0
- package/package.json +19 -6
- package/src/audio/alaw.test.ts +87 -0
- package/src/audio/audio.test.ts +285 -0
- package/src/audio/g722.test.ts +149 -0
- package/src/audio/loudness.test.ts +58 -0
- package/src/audio-envelope.test.ts +167 -0
- package/src/confidence-to-wait.test.ts +21 -0
- package/src/error-handler.test.ts +56 -0
- package/src/hedge-throwing-backend.test.ts +46 -0
- package/src/interaction-coordinator.test.ts +529 -0
- package/src/iu-ledger.test.ts +276 -0
- package/src/latency-filler.test.ts +62 -0
- package/src/observability-observer.test.ts +248 -0
- package/src/observability.test.ts +94 -0
- package/src/packet-factories.test.ts +126 -0
- package/src/pipeline-bus.g10.test.ts +145 -0
- package/src/pipeline-bus.test.ts +211 -0
- package/src/policies/defer.test.ts +86 -0
- package/src/policies/rule-based.test.ts +334 -0
- package/src/pricing.test.ts +226 -0
- package/src/primary-speaker-gate.test.ts +150 -0
- package/src/provider-fallback.test.ts +87 -0
- package/src/reasoner-hedge.test.ts +361 -0
- package/src/reasoner-route.test.ts +248 -0
- package/src/reasoner.test.ts +69 -0
- package/src/retry.test.ts +83 -0
- package/src/route-throwing-spec.test.ts +44 -0
- package/src/spend-cap.test.ts +114 -0
- package/src/tts-playout-clock.test.ts +125 -0
- package/src/turn-arbiter.characterization.test.ts +477 -0
- package/src/turn-arbiter.test.ts +762 -0
- package/src/voice-agent-session.test.ts +4165 -0
- package/src/voice-text.test.ts +156 -0
|
@@ -0,0 +1,1597 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Syrinx Kernel v2 — Voice Agent Session
|
|
4
|
+
//
|
|
5
|
+
// The central orchestrator. Wires together PipelineBus, plugins, init chain,
|
|
6
|
+
// error handler, idle timeout, mode switcher, and debug event stream.
|
|
7
|
+
//
|
|
8
|
+
// Breaking changes from v0.1:
|
|
9
|
+
// - Plugins accept PipelineBus directly (no callbacks).
|
|
10
|
+
// - Explicit init/finalize chains with stage-level error reporting.
|
|
11
|
+
// - Categorized errors with recoverable flag.
|
|
12
|
+
// - Priority bus with Critical/Main/Background routes.
|
|
13
|
+
// - Unified ConversationEvent debug stream.
|
|
14
|
+
// - Idle timeout with consecutive backoff.
|
|
15
|
+
// - Mode switching (text ↔ audio).
|
|
16
|
+
//
|
|
17
|
+
// Backward compat: on/off event emitter stays for client-side consumers
|
|
18
|
+
// (bridges bus packets to legacy event names like "user_started_speaking").
|
|
19
|
+
import { PipelineBusImpl, Route } from "./pipeline-bus.js";
|
|
20
|
+
import { runInitChain, runFinalizeChain } from "./init-chain.js";
|
|
21
|
+
import { IdleTimeoutManager } from "./idle-timeout.js";
|
|
22
|
+
import { ModeSwitcher } from "./mode-switcher.js";
|
|
23
|
+
import { createConversationEventStream } from "./conversation-event.js";
|
|
24
|
+
import { isRecoverable } from "./error-handler.js";
|
|
25
|
+
import { SessionState, InitStage, ErrorCategory, } from "./packets.js";
|
|
26
|
+
import { LatencyFillerController } from "./latency-filler.js";
|
|
27
|
+
import { PrimarySpeakerGate } from "./primary-speaker-gate.js";
|
|
28
|
+
import { takeCompleteVoiceText, isCompleteVoiceText, appendVoiceText, normalizeForSpeech } from "./voice-text.js";
|
|
29
|
+
import { TtsPlayoutClock } from "./tts-playout-clock.js";
|
|
30
|
+
import { isBackchannel } from "./turn-arbiter.js";
|
|
31
|
+
import { InteractionCoordinator } from "./interaction-coordinator.js";
|
|
32
|
+
import { isLifecycleInteractionPolicy } from "./interaction-policy.js";
|
|
33
|
+
import { pcm16BytesToSamples, pcm16SamplesToBytes } from "./audio/pcm.js";
|
|
34
|
+
import { createLoudnessState, normalizeLoudness, } from "./audio/loudness.js";
|
|
35
|
+
import { DeferInteractionPolicy } from "./policies/defer.js";
|
|
36
|
+
import { RuleBasedInteractionPolicy } from "./policies/rule-based.js";
|
|
37
|
+
import * as make from "./packet-factories.js";
|
|
38
|
+
import { pluginStage, stageOrder, isAudioStage } from "./init-stage-order.js";
|
|
39
|
+
import { estimatePcm16Duration, languageFromTranscripts, requireTtsAudioSampleRate, VoiceSessionWatchdogs, } from "./voice-agent-session-util.js";
|
|
40
|
+
import { noopMetricsExporter } from "./observability.js";
|
|
41
|
+
import { localizeTurn } from "./observability.js";
|
|
42
|
+
import { ObservabilityObserver } from "./observability-observer.js";
|
|
43
|
+
import { TimerScheduler } from "./scheduler.js";
|
|
44
|
+
/** Suffix marking a context created to speak an error fallback, so it never recurses. */
|
|
45
|
+
const FALLBACK_CONTEXT_SUFFIX = ":error-fallback";
|
|
46
|
+
/** Scheduler key for a pending tool call's G3 delayed-cue timer. */
|
|
47
|
+
function toolCueTimerKey(contextId, toolId) {
|
|
48
|
+
return `tool_cue:${contextId}:${toolId}`;
|
|
49
|
+
}
|
|
50
|
+
function interactionPlayoutTimerKey(contextId) {
|
|
51
|
+
return `interaction.playout:${contextId}`;
|
|
52
|
+
}
|
|
53
|
+
function turnLocalizationTimerKey(contextId) {
|
|
54
|
+
return `turn.localization:${contextId}`;
|
|
55
|
+
}
|
|
56
|
+
function isInfrastructureMetric(name) {
|
|
57
|
+
return /(?:^|\.)(?:vad|stt|tts|tool|input|pipeline)\.(?:error|malfunction|timeout|stall|cadence)|(?:^|\.)(?:stage|error)\./i.test(name);
|
|
58
|
+
}
|
|
59
|
+
function isConversationEvaluationMetric(name, value) {
|
|
60
|
+
if (!/(?:^|\.)(?:outcome|eval|evaluation|satisfaction|task_success|observer)(?:\.|$)/i.test(name)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return !/^(?:0|false|none|success|successful|passed|pass|good|healthy|ok)$/i.test(value.trim());
|
|
64
|
+
}
|
|
65
|
+
function isConversationControlName(name) {
|
|
66
|
+
return /^(?:conversation-outcome|outcome|escalation|handoff|flow-transition|node-enter|node-exit|flow-enter|flow-end)$/.test(name);
|
|
67
|
+
}
|
|
68
|
+
// =============================================================================
|
|
69
|
+
// Session Implementation
|
|
70
|
+
// =============================================================================
|
|
71
|
+
export class VoiceAgentSession {
|
|
72
|
+
bus;
|
|
73
|
+
debugEvents;
|
|
74
|
+
config;
|
|
75
|
+
sttForceFinalizeTimeoutMs;
|
|
76
|
+
_state = SessionState.Uninitialized;
|
|
77
|
+
plugins = new Map();
|
|
78
|
+
initSteps = [];
|
|
79
|
+
idleTimeout;
|
|
80
|
+
modeSwitcher;
|
|
81
|
+
debugPush;
|
|
82
|
+
eventListeners = new Map();
|
|
83
|
+
currentTurnId = "";
|
|
84
|
+
busStartPromise = null;
|
|
85
|
+
closePromise = null;
|
|
86
|
+
// Tracks which contexts are still playing out their TTS audio; turn-taking and
|
|
87
|
+
// the stall watchdog key on this. Pure state — see TtsPlayoutClock.
|
|
88
|
+
scheduler;
|
|
89
|
+
ttsPlayout;
|
|
90
|
+
interruptedGenerationContextIds = new Set();
|
|
91
|
+
// Turns whose generation is in-flight (eos.turn_complete emitted, not yet
|
|
92
|
+
// finished/interrupted). Lets a client "stop" during the reasoner TTFT gap —
|
|
93
|
+
// before any audio plays — still abort the turn (thinking-phase barge-in, B3).
|
|
94
|
+
generatingContextIds = new Set();
|
|
95
|
+
ttsTextBuffers = new Map();
|
|
96
|
+
minInterruptionMs;
|
|
97
|
+
primarySpeakerGate;
|
|
98
|
+
ruleBasedPolicy;
|
|
99
|
+
injectedInteractionPolicy;
|
|
100
|
+
activeInteractionPolicy;
|
|
101
|
+
interaction;
|
|
102
|
+
latencyFiller;
|
|
103
|
+
firstLlmDeltaReceived = new Set();
|
|
104
|
+
vaqiMissedResponseMs;
|
|
105
|
+
ttsStallMs;
|
|
106
|
+
outboundLoudnessConfig;
|
|
107
|
+
outboundLoudnessState;
|
|
108
|
+
inputCadenceTimeoutMs;
|
|
109
|
+
watchdogs;
|
|
110
|
+
observabilityObserver;
|
|
111
|
+
metricsExporter;
|
|
112
|
+
observabilityDims;
|
|
113
|
+
turnUserStoppedAtMs = new Map();
|
|
114
|
+
turnTimings = new Map();
|
|
115
|
+
pendingTurnLatency = new Map();
|
|
116
|
+
/** Running usage totals per stage, summed across the session; emitted at close. */
|
|
117
|
+
usageByStage = new Map();
|
|
118
|
+
speakerEnrollmentContextId = null;
|
|
119
|
+
firstTtsAudioFired = new Set();
|
|
120
|
+
pendingInteractionPlayoutTimers = new Set();
|
|
121
|
+
errorFallbackText;
|
|
122
|
+
fallbackInjectedContexts = new Set();
|
|
123
|
+
// G3: pending tool calls per context (toolId → toolName) driving the tool_call_cue lifecycle.
|
|
124
|
+
delayCueAfterMs;
|
|
125
|
+
pendingToolCues = new Map();
|
|
126
|
+
endpointingOwner;
|
|
127
|
+
fullDuplex;
|
|
128
|
+
emitsBackchannel;
|
|
129
|
+
userSpeaking = false;
|
|
130
|
+
lastFinalizedContextId = "";
|
|
131
|
+
sttPartialWordTimings = new Map();
|
|
132
|
+
turnLocalizationStates = new Map();
|
|
133
|
+
emittedTurnLocalizations = new Set();
|
|
134
|
+
constructor(config) {
|
|
135
|
+
const owner = config.endpointingOwner;
|
|
136
|
+
if (owner !== undefined && owner !== "provider_stt" && owner !== "smart_turn" && owner !== "timer") {
|
|
137
|
+
throw new Error(`Unsupported endpointingOwner: ${owner}`);
|
|
138
|
+
}
|
|
139
|
+
this.endpointingOwner = owner ?? "provider_stt";
|
|
140
|
+
this.fullDuplex = config.fullDuplex === true;
|
|
141
|
+
this.emitsBackchannel = config.emitsBackchannel === true;
|
|
142
|
+
this.config = config;
|
|
143
|
+
this.outboundLoudnessConfig = config.outboundLoudness ?? null;
|
|
144
|
+
this.outboundLoudnessState = this.outboundLoudnessConfig ? createLoudnessState() : null;
|
|
145
|
+
this.scheduler = config.scheduler ?? new TimerScheduler();
|
|
146
|
+
this.ttsPlayout = new TtsPlayoutClock(this.scheduler);
|
|
147
|
+
this.sttForceFinalizeTimeoutMs = config.sttForceFinalizeTimeoutMs ?? 7000;
|
|
148
|
+
this.minInterruptionMs = config.minInterruptionMs ?? 280;
|
|
149
|
+
this.delayCueAfterMs = config.delayCueAfterMs ?? 2000;
|
|
150
|
+
this.primarySpeakerGate = new PrimarySpeakerGate({
|
|
151
|
+
enabled: config.primarySpeakerBargeInEnabled !== false,
|
|
152
|
+
onDecision: (decision) => {
|
|
153
|
+
this.emitAcousticSignal(decision.contextId, Date.now(), decision.signal, {
|
|
154
|
+
accepted: decision.accepted,
|
|
155
|
+
frameCount: decision.frameCount,
|
|
156
|
+
primaryHits: decision.primaryHits,
|
|
157
|
+
echoRejectedFrames: decision.echoRejectedFrames,
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
this.latencyFiller = new LatencyFillerController({
|
|
162
|
+
enabled: config.latencyFillerEnabled === true,
|
|
163
|
+
});
|
|
164
|
+
this.vaqiMissedResponseMs = config.vaqiMissedResponseMs ?? 4000;
|
|
165
|
+
this.ttsStallMs = config.ttsStallMs ?? 15000;
|
|
166
|
+
this.inputCadenceTimeoutMs = config.inputCadenceTimeoutMs ?? 0;
|
|
167
|
+
this.errorFallbackText = config.errorFallbackText ?? "Sorry, I'm having trouble right now. Could you try again?";
|
|
168
|
+
// Debug events
|
|
169
|
+
const [stream, push] = createConversationEventStream();
|
|
170
|
+
this.debugEvents = stream;
|
|
171
|
+
this.debugPush = push;
|
|
172
|
+
// PipelineBus
|
|
173
|
+
this.bus = new PipelineBusImpl({
|
|
174
|
+
...config.busConfig,
|
|
175
|
+
onPacket: (route, packet) => {
|
|
176
|
+
this.debugPush({
|
|
177
|
+
component: "bus",
|
|
178
|
+
type: "packet",
|
|
179
|
+
data: {
|
|
180
|
+
context_id: packet.contextId,
|
|
181
|
+
route: Route[route] ?? String(route),
|
|
182
|
+
kind: packet.kind,
|
|
183
|
+
},
|
|
184
|
+
timestampMs: packet.timestampMs,
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
onBackgroundDrop: (dropped) => {
|
|
188
|
+
this.debugPush({
|
|
189
|
+
component: "pipeline",
|
|
190
|
+
type: "background_dropped",
|
|
191
|
+
data: {
|
|
192
|
+
context_id: dropped.contextId,
|
|
193
|
+
kind: dropped.kind,
|
|
194
|
+
},
|
|
195
|
+
timestampMs: Date.now(),
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
this.injectedInteractionPolicy = config.interactionPolicy ?? null;
|
|
200
|
+
this.ruleBasedPolicy = new RuleBasedInteractionPolicy({
|
|
201
|
+
bus: this.bus,
|
|
202
|
+
primarySpeakerGate: this.primarySpeakerGate,
|
|
203
|
+
ttsPlayout: this.ttsPlayout,
|
|
204
|
+
minInterruptionMs: this.minInterruptionMs,
|
|
205
|
+
});
|
|
206
|
+
const coordinatorPolicy = this.fullDuplex
|
|
207
|
+
? new DeferInteractionPolicy()
|
|
208
|
+
: (this.injectedInteractionPolicy ?? this.ruleBasedPolicy);
|
|
209
|
+
this.activeInteractionPolicy = coordinatorPolicy;
|
|
210
|
+
coordinatorPolicy.setAcousticSignalSink?.((signal) => {
|
|
211
|
+
this.emitAcousticSignal(signal.contextId, signal.timestampMs, signal.signal, signal.payload);
|
|
212
|
+
});
|
|
213
|
+
this.interaction = new InteractionCoordinator({
|
|
214
|
+
bus: this.bus,
|
|
215
|
+
policy: coordinatorPolicy,
|
|
216
|
+
executor: this.ruleBasedPolicy.arbiter,
|
|
217
|
+
scheduler: this.scheduler,
|
|
218
|
+
caps: { emitsBackchannel: this.emitsBackchannel },
|
|
219
|
+
isUserSpeaking: () => this.userSpeaking,
|
|
220
|
+
isTtsActive: () => this.ttsPlayout.activeContexts().length > 0,
|
|
221
|
+
onBackchannelEmitted: (contextId) => {
|
|
222
|
+
this.timingFor(contextId).backchannelUsed = true;
|
|
223
|
+
},
|
|
224
|
+
endpointingOwner: this.endpointingOwner,
|
|
225
|
+
wasForceFinalized: (contextId) => this.watchdogs.wasForceFinalized(contextId),
|
|
226
|
+
});
|
|
227
|
+
this.watchdogs = new VoiceSessionWatchdogs({
|
|
228
|
+
bus: this.bus,
|
|
229
|
+
plugins: this.plugins,
|
|
230
|
+
ttsPlayout: this.ttsPlayout,
|
|
231
|
+
sttForceFinalizeTimeoutMs: this.sttForceFinalizeTimeoutMs,
|
|
232
|
+
vaqiMissedResponseMs: this.vaqiMissedResponseMs,
|
|
233
|
+
ttsStallMs: this.ttsStallMs,
|
|
234
|
+
inputCadenceTimeoutMs: this.inputCadenceTimeoutMs,
|
|
235
|
+
getSessionState: () => this._state,
|
|
236
|
+
isGenerationInterrupted: (contextId) => this.interruptedGenerationContextIds.has(contextId),
|
|
237
|
+
onVaqiMissedResponseFired: (contextId) => {
|
|
238
|
+
this.turnUserStoppedAtMs.delete(contextId);
|
|
239
|
+
},
|
|
240
|
+
scheduler: this.scheduler,
|
|
241
|
+
});
|
|
242
|
+
const obs = config.observability;
|
|
243
|
+
this.metricsExporter = config.metricsExporter ?? noopMetricsExporter;
|
|
244
|
+
this.observabilityDims = {
|
|
245
|
+
provider: obs?.provider ?? "unknown",
|
|
246
|
+
model: obs?.model ?? "unknown",
|
|
247
|
+
region: obs?.region ?? "unknown",
|
|
248
|
+
layer: obs?.layer,
|
|
249
|
+
};
|
|
250
|
+
this.observabilityObserver = new ObservabilityObserver({
|
|
251
|
+
bus: this.bus,
|
|
252
|
+
exporter: this.metricsExporter,
|
|
253
|
+
sessionId: obs?.sessionId ?? "",
|
|
254
|
+
dims: this.observabilityDims,
|
|
255
|
+
getContextId: () => this.currentContextId,
|
|
256
|
+
});
|
|
257
|
+
// Idle timeout — starts after bus handlers are wired
|
|
258
|
+
this.idleTimeout = new IdleTimeoutManager(this.bus, config.idleTimeout, this.scheduler);
|
|
259
|
+
// Mode switcher
|
|
260
|
+
this.modeSwitcher = new ModeSwitcher(this.bus);
|
|
261
|
+
}
|
|
262
|
+
get turnArbiter() {
|
|
263
|
+
return this.ruleBasedPolicy.arbiter;
|
|
264
|
+
}
|
|
265
|
+
// =========================================================================
|
|
266
|
+
// Public API
|
|
267
|
+
// =========================================================================
|
|
268
|
+
get state() {
|
|
269
|
+
return this._state;
|
|
270
|
+
}
|
|
271
|
+
get currentContextId() {
|
|
272
|
+
return this.currentTurnId;
|
|
273
|
+
}
|
|
274
|
+
/** Register a plugin. Must be called before start(). */
|
|
275
|
+
registerPlugin(name, plugin) {
|
|
276
|
+
this.plugins.set(name, plugin);
|
|
277
|
+
}
|
|
278
|
+
/** Start the session. Runs init chain, starts bus draining. */
|
|
279
|
+
async start() {
|
|
280
|
+
if (this._state !== SessionState.Uninitialized) {
|
|
281
|
+
throw new Error(`Cannot start session in state ${this._state}`);
|
|
282
|
+
}
|
|
283
|
+
this._state = SessionState.Initializing;
|
|
284
|
+
// 1. Wire all bus handlers
|
|
285
|
+
this.wireBusHandlers();
|
|
286
|
+
this.interaction.initialize();
|
|
287
|
+
// 2. Start bus drain loop
|
|
288
|
+
this.busStartPromise = this.bus.start();
|
|
289
|
+
// 3. Build init chain from registered plugins
|
|
290
|
+
this.buildInitChain();
|
|
291
|
+
// 4. Run init chain
|
|
292
|
+
try {
|
|
293
|
+
await runInitChain(this.bus, this.initSteps);
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
this._state = SessionState.Failed;
|
|
297
|
+
throw err;
|
|
298
|
+
}
|
|
299
|
+
this._state = SessionState.Ready;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Best-effort warm of registered plugins' remote/expensive resources. Call after start() to
|
|
303
|
+
* wake scaled-to-zero endpoints before the first user turn. The host decides when to invoke
|
|
304
|
+
* this — it is not called automatically from start(). Never throws; failed plugin prewarms
|
|
305
|
+
* are swallowed and may emit a prewarm.failed metric on the bus.
|
|
306
|
+
*/
|
|
307
|
+
async prewarm() {
|
|
308
|
+
const entries = [...this.plugins.entries()];
|
|
309
|
+
const results = await Promise.allSettled(entries.map(async ([, plugin]) => {
|
|
310
|
+
await plugin.prewarm?.();
|
|
311
|
+
}));
|
|
312
|
+
for (let i = 0; i < results.length; i++) {
|
|
313
|
+
const result = results[i];
|
|
314
|
+
if (result?.status === "rejected") {
|
|
315
|
+
const [name] = entries[i];
|
|
316
|
+
this.bus.push(Route.Background, make.metric("", "prewarm.failed", name));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** Shut down the session. Runs finalize chain in reverse order. */
|
|
321
|
+
async close() {
|
|
322
|
+
if (this._state === SessionState.Closed)
|
|
323
|
+
return;
|
|
324
|
+
if (this.closePromise)
|
|
325
|
+
return await this.closePromise;
|
|
326
|
+
this.closePromise = this.closeOnce();
|
|
327
|
+
return await this.closePromise;
|
|
328
|
+
}
|
|
329
|
+
async closeOnce() {
|
|
330
|
+
this._state = SessionState.Finalizing;
|
|
331
|
+
// 1. Stop idle timeout
|
|
332
|
+
this.idleTimeout.dispose();
|
|
333
|
+
this.interaction.dispose();
|
|
334
|
+
this.watchdogs.dispose();
|
|
335
|
+
this.observabilityObserver.dispose();
|
|
336
|
+
this.ttsPlayout.clear();
|
|
337
|
+
for (const contextId of this.pendingInteractionPlayoutTimers) {
|
|
338
|
+
this.scheduler.cancel(interactionPlayoutTimerKey(contextId));
|
|
339
|
+
}
|
|
340
|
+
this.pendingInteractionPlayoutTimers.clear();
|
|
341
|
+
this.turnArbiter.clear();
|
|
342
|
+
this.turnUserStoppedAtMs.clear();
|
|
343
|
+
this.turnTimings.clear();
|
|
344
|
+
for (const contextId of this.turnLocalizationStates.keys()) {
|
|
345
|
+
this.scheduler.cancel(turnLocalizationTimerKey(contextId));
|
|
346
|
+
this.emitTurnLocalization(contextId);
|
|
347
|
+
}
|
|
348
|
+
this.turnLocalizationStates.clear();
|
|
349
|
+
this.emittedTurnLocalizations.clear();
|
|
350
|
+
this.pendingTurnLatency.clear();
|
|
351
|
+
this.firstTtsAudioFired.clear();
|
|
352
|
+
this.fallbackInjectedContexts.clear();
|
|
353
|
+
this.ttsTextBuffers.clear();
|
|
354
|
+
this.interruptedGenerationContextIds.clear();
|
|
355
|
+
this.firstLlmDeltaReceived.clear();
|
|
356
|
+
this.sttPartialWordTimings.clear();
|
|
357
|
+
for (const [contextId, pending] of this.pendingToolCues) {
|
|
358
|
+
for (const toolId of pending.keys())
|
|
359
|
+
this.scheduler.cancel(toolCueTimerKey(contextId, toolId));
|
|
360
|
+
}
|
|
361
|
+
this.pendingToolCues.clear();
|
|
362
|
+
// 2. Run finalize chain (reverse order)
|
|
363
|
+
await runFinalizeChain(this.initSteps);
|
|
364
|
+
// 2b. Emit the end-of-session usage manifest before the bus stops — the metering seam.
|
|
365
|
+
this.emitSessionUsage();
|
|
366
|
+
// 3. Stop bus
|
|
367
|
+
this.bus.stop();
|
|
368
|
+
await this.busStartPromise;
|
|
369
|
+
// 4. Emit debug event
|
|
370
|
+
this.emitDebug("session", "disconnected", { reason: "close" });
|
|
371
|
+
this._state = SessionState.Closed;
|
|
372
|
+
}
|
|
373
|
+
/** Switch between text and audio mode. */
|
|
374
|
+
async switchMode(mode) {
|
|
375
|
+
this.bus.push(Route.Main, make.modeSwitchRequested(this.currentTurnId, Date.now(), mode));
|
|
376
|
+
}
|
|
377
|
+
requestClientInterrupt(contextId) {
|
|
378
|
+
// Playing out → the arbiter owns the barge-in (primary-speaker reset + metrics).
|
|
379
|
+
if (this.ttsPlayout.isActive(contextId)) {
|
|
380
|
+
this.turnArbiter.commitClientInterrupt(contextId);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// Thinking-phase barge-in (B3): no audio yet, but a generation is in-flight —
|
|
384
|
+
// abort it so "stop" during the reasoner TTFT gap is honored, not dropped.
|
|
385
|
+
if (this.generatingContextIds.has(contextId)) {
|
|
386
|
+
this.bus.push(Route.Critical, make.interruptDetected(contextId, Date.now(), "client"));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// =========================================================================
|
|
390
|
+
// Legacy Event Emitter (client-side consumers)
|
|
391
|
+
// =========================================================================
|
|
392
|
+
on(event, handler) {
|
|
393
|
+
if (!this.eventListeners.has(event)) {
|
|
394
|
+
this.eventListeners.set(event, new Set());
|
|
395
|
+
}
|
|
396
|
+
this.eventListeners.get(event).add(handler);
|
|
397
|
+
}
|
|
398
|
+
off(event, handler) {
|
|
399
|
+
this.eventListeners.get(event)?.delete(handler);
|
|
400
|
+
}
|
|
401
|
+
emit(event, payload) {
|
|
402
|
+
const listeners = this.eventListeners.get(event);
|
|
403
|
+
if (!listeners)
|
|
404
|
+
return;
|
|
405
|
+
for (const fn of listeners) {
|
|
406
|
+
try {
|
|
407
|
+
fn(payload);
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
// Don't let listener errors crash the session
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// =========================================================================
|
|
415
|
+
// Bus Handler Wiring
|
|
416
|
+
// =========================================================================
|
|
417
|
+
wireBusHandlers() {
|
|
418
|
+
// Input pipeline
|
|
419
|
+
this.bus.on("user.audio_received", this.handleUserAudio.bind(this));
|
|
420
|
+
this.bus.on("user.text_received", this.handleUserText.bind(this));
|
|
421
|
+
// STT results
|
|
422
|
+
this.bus.on("stt.audio", this.handleSttAudio.bind(this));
|
|
423
|
+
this.bus.on("stt.interim", this.handleSttInterim.bind(this));
|
|
424
|
+
this.bus.on("stt.partial", this.handleSttPartial.bind(this));
|
|
425
|
+
this.bus.on("stt.result", this.handleSttResult.bind(this));
|
|
426
|
+
// VAD
|
|
427
|
+
this.bus.on("vad.speech_started", this.handleVadSpeechStarted.bind(this));
|
|
428
|
+
this.bus.on("vad.speech_activity", this.handleVadSpeechActivity.bind(this));
|
|
429
|
+
this.bus.on("vad.speech_ended", this.handleVadSpeechEnded.bind(this));
|
|
430
|
+
this.bus.on("vad.audio", this.handleVadAudioForSpeakerGate.bind(this));
|
|
431
|
+
// EOS
|
|
432
|
+
this.bus.on("turn.change", (pkt) => {
|
|
433
|
+
this.lastFinalizedContextId = "";
|
|
434
|
+
if (pkt.previousContextId) {
|
|
435
|
+
this.sttPartialWordTimings.delete(pkt.previousContextId);
|
|
436
|
+
this.carryTurnTimingAcrossContextChange(pkt.previousContextId, pkt.contextId);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
this.bus.on("eos.turn_complete", this.handleTurnComplete.bind(this));
|
|
440
|
+
this.bus.on("eos.interim", this.handleEosInterim.bind(this));
|
|
441
|
+
// LLM
|
|
442
|
+
this.bus.on("llm.delta", this.handleLlmDelta.bind(this));
|
|
443
|
+
this.bus.on("llm.done", this.handleLlmDone.bind(this));
|
|
444
|
+
this.bus.on("llm.tool_call", this.handleLlmToolCall.bind(this));
|
|
445
|
+
this.bus.on("llm.tool_result", this.handleLlmToolResult.bind(this));
|
|
446
|
+
this.bus.on("metric.conversation", this.handleConversationMetric.bind(this));
|
|
447
|
+
this.bus.on("usage.recorded", (pkt) => this.handleUsageRecorded(pkt));
|
|
448
|
+
// Delegate (Responder-Thinker) observability — G2, RFC bimodel-delegate-seam
|
|
449
|
+
this.bus.on("delegate.query", this.handleDelegateQuery.bind(this));
|
|
450
|
+
this.bus.on("delegate.result", this.handleDelegateResult.bind(this));
|
|
451
|
+
// TTS
|
|
452
|
+
this.bus.on("tts.audio", this.handleTtsAudio.bind(this));
|
|
453
|
+
this.bus.on("tts.end", this.handleTtsEnd.bind(this));
|
|
454
|
+
this.bus.on("tts.playout_progress", this.handleTtsPlayoutProgress.bind(this));
|
|
455
|
+
// Interrupts
|
|
456
|
+
this.bus.on("interrupt.detected", this.handleInterruptDetected.bind(this));
|
|
457
|
+
// Errors
|
|
458
|
+
this.bus.on("stt.error", this.handleComponentError.bind(this));
|
|
459
|
+
this.bus.on("tts.error", this.handleComponentError.bind(this));
|
|
460
|
+
this.bus.on("vad.error", this.handleComponentError.bind(this));
|
|
461
|
+
this.bus.on("llm.error", this.handleComponentError.bind(this));
|
|
462
|
+
this.bus.on("pipeline.error", this.handleComponentError.bind(this));
|
|
463
|
+
// Lifecycle
|
|
464
|
+
this.bus.on("init.failed", this.handleInitFailed.bind(this));
|
|
465
|
+
// Behavior
|
|
466
|
+
this.bus.on("behavior.idle_timeout_start", (pkt) => {
|
|
467
|
+
this.idleTimeout.handleStart(pkt);
|
|
468
|
+
});
|
|
469
|
+
this.bus.on("behavior.idle_timeout_stop", (pkt) => {
|
|
470
|
+
this.idleTimeout.handleStop(pkt);
|
|
471
|
+
});
|
|
472
|
+
// Injected messages — push through LLM path for natural TTS
|
|
473
|
+
this.bus.on("inject.message", this.handleInjectMessage.bind(this));
|
|
474
|
+
// Per-turn STT reconfigure (keyterms / endpointing / EOT thresholds)
|
|
475
|
+
this.bus.on("stt.reconfigure", this.handleSttReconfigure.bind(this));
|
|
476
|
+
// Disconnect
|
|
477
|
+
this.bus.on("session.disconnect", this.handleDisconnect.bind(this));
|
|
478
|
+
// Mode switching
|
|
479
|
+
this.bus.on("mode.switch_requested", async (pkt) => {
|
|
480
|
+
await this.modeSwitcher.handleSwitchRequested(pkt);
|
|
481
|
+
});
|
|
482
|
+
this.observabilityObserver.wire();
|
|
483
|
+
}
|
|
484
|
+
// =========================================================================
|
|
485
|
+
// Handler Implementations
|
|
486
|
+
// =========================================================================
|
|
487
|
+
handleUserAudio(pkt) {
|
|
488
|
+
if (this.shouldEnrollPrimarySpeaker(pkt.contextId)) {
|
|
489
|
+
this.primarySpeakerGate.enrollUserTurnChunk(pkt.audio);
|
|
490
|
+
}
|
|
491
|
+
if (this.endpointingOwner === "provider_stt") {
|
|
492
|
+
this.bus.push(Route.Main, make.recordUserAudio(pkt.contextId, pkt.timestampMs, pkt.audio), make.vadAudio(pkt.contextId, pkt.timestampMs, pkt.audio), make.sttAudio(pkt.contextId, pkt.timestampMs, pkt.audio));
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
this.bus.push(Route.Main, make.recordUserAudio(pkt.contextId, pkt.timestampMs, pkt.audio), make.vadAudio(pkt.contextId, pkt.timestampMs, pkt.audio), make.sttAudio(pkt.contextId, pkt.timestampMs, pkt.audio), make.eosAudio(pkt.contextId, pkt.timestampMs, pkt.audio));
|
|
496
|
+
}
|
|
497
|
+
this.debugPush({
|
|
498
|
+
component: "input",
|
|
499
|
+
type: "audio_received",
|
|
500
|
+
data: { context_id: pkt.contextId, bytes: String(pkt.audio.length) },
|
|
501
|
+
timestampMs: pkt.timestampMs,
|
|
502
|
+
});
|
|
503
|
+
this.observeAudioFrame(pkt);
|
|
504
|
+
this.watchdogs.scheduleInputCadenceWatchdog(pkt.contextId);
|
|
505
|
+
}
|
|
506
|
+
observeAudioFrame(pkt) {
|
|
507
|
+
if (pkt.audio.byteLength < 2 || pkt.audio.byteLength % 2 !== 0)
|
|
508
|
+
return;
|
|
509
|
+
this.interaction.observe({
|
|
510
|
+
kind: "audio_frame",
|
|
511
|
+
contextId: pkt.contextId,
|
|
512
|
+
timestampMs: pkt.timestampMs,
|
|
513
|
+
audio: pcm16BytesToSamples(pkt.audio),
|
|
514
|
+
sampleRateHz: pkt.sampleRateHz ?? 16_000,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
handleSttAudio(pkt) {
|
|
518
|
+
this.watchdogs.scheduleSttForceFinalize(pkt.contextId);
|
|
519
|
+
}
|
|
520
|
+
handleUserText(pkt) {
|
|
521
|
+
// Typed input is an immediate turn end — but nothing endpointed it and no one
|
|
522
|
+
// decided the caller stopped talking, because they typed. Label it as `text` so
|
|
523
|
+
// the timeline never claims a speech endpointer fired on a typed turn.
|
|
524
|
+
this.bus.push(Route.Main, make.eosTurnComplete(pkt.contextId, pkt.timestampMs, pkt.text, [], {
|
|
525
|
+
owner: "text",
|
|
526
|
+
reason: "typed",
|
|
527
|
+
}));
|
|
528
|
+
}
|
|
529
|
+
handleSttPartial(pkt) {
|
|
530
|
+
if (pkt.wordTimings) {
|
|
531
|
+
this.sttPartialWordTimings.set(pkt.contextId, pkt.wordTimings);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
handleSttInterim(pkt) {
|
|
535
|
+
this.observeSttForBargeIn(pkt.contextId, pkt.timestampMs, pkt.text, "stt_partial");
|
|
536
|
+
this.currentTurnId = pkt.contextId;
|
|
537
|
+
this.emit("user_input_partial", {
|
|
538
|
+
tsMs: pkt.timestampMs,
|
|
539
|
+
turnId: pkt.contextId,
|
|
540
|
+
text: pkt.text,
|
|
541
|
+
});
|
|
542
|
+
this.debugPush({
|
|
543
|
+
component: "stt",
|
|
544
|
+
type: "interim",
|
|
545
|
+
data: { context_id: pkt.contextId, text: pkt.text },
|
|
546
|
+
timestampMs: pkt.timestampMs,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
handleSttResult(pkt) {
|
|
550
|
+
this.watchdogs.clearSttForceFinalizeIfContext(pkt.contextId);
|
|
551
|
+
this.observeSttForBargeIn(pkt.contextId, pkt.timestampMs, pkt.text, "stt_final", pkt.confidence);
|
|
552
|
+
this.currentTurnId = pkt.contextId;
|
|
553
|
+
this.emit("user_input_final", {
|
|
554
|
+
tsMs: pkt.timestampMs,
|
|
555
|
+
turnId: pkt.contextId,
|
|
556
|
+
text: pkt.text,
|
|
557
|
+
confidence: pkt.confidence,
|
|
558
|
+
});
|
|
559
|
+
this.debugPush({
|
|
560
|
+
component: "stt",
|
|
561
|
+
type: "final",
|
|
562
|
+
data: {
|
|
563
|
+
context_id: pkt.contextId,
|
|
564
|
+
text: pkt.text,
|
|
565
|
+
confidence: String(pkt.confidence),
|
|
566
|
+
},
|
|
567
|
+
timestampMs: pkt.timestampMs,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
handleVadAudioForSpeakerGate(pkt) {
|
|
571
|
+
if (this.interaction.observeBargeInAudio(pkt))
|
|
572
|
+
return;
|
|
573
|
+
if (this.shouldEnrollPrimarySpeaker(pkt.contextId)) {
|
|
574
|
+
this.primarySpeakerGate.enrollUserTurnChunk(pkt.audio);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
shouldEnrollPrimarySpeaker(contextId) {
|
|
578
|
+
return (!this.latestActiveTtsContextId() &&
|
|
579
|
+
this.speakerEnrollmentContextId === contextId);
|
|
580
|
+
}
|
|
581
|
+
// Deployments that delegate endpointing to the provider STT register no VAD
|
|
582
|
+
// plugin, so vad.speech_started never fires and barge-in would stay dormant.
|
|
583
|
+
// Provider transcripts arriving while TTS playout is active are the speech
|
|
584
|
+
// evidence instead (echo of our own playout is mitigated by client AEC plus
|
|
585
|
+
// the arbiter's backchannel / low-confidence suppression).
|
|
586
|
+
observeSttForBargeIn(contextId, timestampMs, text, kind, confidence) {
|
|
587
|
+
const interruptedContextId = this.endpointingOwner === "provider_stt" && text.trim()
|
|
588
|
+
? this.latestActiveTtsContextId() ?? undefined
|
|
589
|
+
: undefined;
|
|
590
|
+
const wordTimings = this.sttPartialWordTimings.get(contextId);
|
|
591
|
+
if (kind === "stt_partial") {
|
|
592
|
+
this.interaction.observe({
|
|
593
|
+
kind: "stt_partial",
|
|
594
|
+
contextId,
|
|
595
|
+
timestampMs,
|
|
596
|
+
text,
|
|
597
|
+
interruptedContextId,
|
|
598
|
+
...(wordTimings ? { wordTimings } : {}),
|
|
599
|
+
});
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
this.interaction.observe({
|
|
603
|
+
kind: "stt_final",
|
|
604
|
+
contextId,
|
|
605
|
+
timestampMs,
|
|
606
|
+
text,
|
|
607
|
+
confidence,
|
|
608
|
+
interruptedContextId,
|
|
609
|
+
...(wordTimings ? { wordTimings } : {}),
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
handleVadSpeechStarted(pkt) {
|
|
613
|
+
this.lastFinalizedContextId = "";
|
|
614
|
+
this.userSpeaking = true;
|
|
615
|
+
if (this.latencyFiller.isFillerOnly(this.currentTurnId)) {
|
|
616
|
+
this.cancelLatencyFillerTurn(this.currentTurnId, pkt.timestampMs);
|
|
617
|
+
}
|
|
618
|
+
this.emit("user_started_speaking", {
|
|
619
|
+
tsMs: pkt.timestampMs,
|
|
620
|
+
turnId: pkt.contextId,
|
|
621
|
+
});
|
|
622
|
+
this.debugPush({
|
|
623
|
+
component: "vad",
|
|
624
|
+
type: "speech_started",
|
|
625
|
+
data: {
|
|
626
|
+
context_id: pkt.contextId,
|
|
627
|
+
confidence: String(pkt.confidence),
|
|
628
|
+
},
|
|
629
|
+
timestampMs: pkt.timestampMs,
|
|
630
|
+
});
|
|
631
|
+
const interruptedContextId = this.latestActiveTtsContextId();
|
|
632
|
+
if (!interruptedContextId) {
|
|
633
|
+
this.speakerEnrollmentContextId = pkt.contextId;
|
|
634
|
+
}
|
|
635
|
+
this.interaction.observe({
|
|
636
|
+
kind: "vad_speech_started",
|
|
637
|
+
contextId: pkt.contextId,
|
|
638
|
+
timestampMs: pkt.timestampMs,
|
|
639
|
+
confidence: pkt.confidence,
|
|
640
|
+
...(interruptedContextId ? { interruptedContextId } : {}),
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
handleVadSpeechActivity(pkt) {
|
|
644
|
+
this.interaction.observe({
|
|
645
|
+
kind: "vad_speech_activity",
|
|
646
|
+
contextId: pkt.contextId,
|
|
647
|
+
timestampMs: pkt.timestampMs,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
handleVadSpeechEnded(pkt) {
|
|
651
|
+
this.userSpeaking = false;
|
|
652
|
+
this.emit("user_stopped_speaking", {
|
|
653
|
+
tsMs: pkt.timestampMs,
|
|
654
|
+
turnId: pkt.contextId,
|
|
655
|
+
});
|
|
656
|
+
this.debugPush({
|
|
657
|
+
component: "vad",
|
|
658
|
+
type: "speech_ended",
|
|
659
|
+
data: { context_id: pkt.contextId },
|
|
660
|
+
timestampMs: pkt.timestampMs,
|
|
661
|
+
});
|
|
662
|
+
if (this.speakerEnrollmentContextId === pkt.contextId) {
|
|
663
|
+
this.speakerEnrollmentContextId = null;
|
|
664
|
+
}
|
|
665
|
+
this.interaction.observe({
|
|
666
|
+
kind: "vad_speech_ended",
|
|
667
|
+
contextId: pkt.contextId,
|
|
668
|
+
timestampMs: pkt.timestampMs,
|
|
669
|
+
hasActiveTts: Boolean(this.latestActiveTtsContextId()),
|
|
670
|
+
});
|
|
671
|
+
this.turnUserStoppedAtMs.set(pkt.contextId, pkt.timestampMs);
|
|
672
|
+
this.timingFor(pkt.contextId).speechEndedMs = pkt.timestampMs;
|
|
673
|
+
this.watchdogs.startVaqiMissedResponseTimer(pkt.contextId, pkt.timestampMs);
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* A realtime front rotates its contextId when the provider starts responding
|
|
677
|
+
* (`RealtimeBridge.onResponseStarted`), so the user-side anchors (`speechEndedMs`, `eosMs`)
|
|
678
|
+
* are recorded under the PREVIOUS context while `tts.audio` — and therefore the
|
|
679
|
+
* `turn_latency` emit — lands under the new one. Without carrying the record across the
|
|
680
|
+
* rotation, `emitTurnLatency` finds no anchor and silently drops every native turn.
|
|
681
|
+
*
|
|
682
|
+
* Only fills gaps: anything the new context already recorded wins.
|
|
683
|
+
*/
|
|
684
|
+
carryTurnTimingAcrossContextChange(previousContextId, contextId) {
|
|
685
|
+
const previous = this.turnTimings.get(previousContextId);
|
|
686
|
+
if (!previous)
|
|
687
|
+
return;
|
|
688
|
+
this.turnTimings.delete(previousContextId);
|
|
689
|
+
const current = this.turnTimings.get(contextId);
|
|
690
|
+
this.turnTimings.set(contextId, { ...previous, ...current });
|
|
691
|
+
}
|
|
692
|
+
timingFor(contextId) {
|
|
693
|
+
let timing = this.turnTimings.get(contextId);
|
|
694
|
+
if (!timing) {
|
|
695
|
+
timing = {};
|
|
696
|
+
this.turnTimings.set(contextId, timing);
|
|
697
|
+
}
|
|
698
|
+
return timing;
|
|
699
|
+
}
|
|
700
|
+
/** Emit a deferred turn_latency if one is pending, then clear it. Safe to call twice. */
|
|
701
|
+
flushPendingTurnLatency(contextId) {
|
|
702
|
+
const firstAudioMs = this.pendingTurnLatency.get(contextId);
|
|
703
|
+
if (firstAudioMs === undefined)
|
|
704
|
+
return;
|
|
705
|
+
this.pendingTurnLatency.delete(contextId);
|
|
706
|
+
this.emitTurnLatency(contextId, firstAudioMs);
|
|
707
|
+
}
|
|
708
|
+
emitTurnLatency(contextId, firstAudioMs) {
|
|
709
|
+
const timing = this.timingFor(contextId);
|
|
710
|
+
this.turnTimings.delete(contextId);
|
|
711
|
+
const anchorMs = timing.speechEndedMs ?? timing.eosMs;
|
|
712
|
+
if (anchorMs === undefined)
|
|
713
|
+
return; // text-injected or fallback turn — not a voice TTFA
|
|
714
|
+
const anchor = timing.speechEndedMs !== undefined ? "speech_end" : "eos";
|
|
715
|
+
const eouDelayMs = timing.speechEndedMs !== undefined &&
|
|
716
|
+
timing.eosMs !== undefined &&
|
|
717
|
+
timing.eosMs <= firstAudioMs
|
|
718
|
+
? timing.eosMs - timing.speechEndedMs
|
|
719
|
+
: undefined;
|
|
720
|
+
const llmAnchorMs = timing.eosMs ?? timing.speechEndedMs;
|
|
721
|
+
const llmTtftMs = llmAnchorMs !== undefined && timing.firstLlmDeltaMs !== undefined
|
|
722
|
+
? timing.firstLlmDeltaMs - llmAnchorMs
|
|
723
|
+
: undefined;
|
|
724
|
+
const textAggregationMs = timing.firstLlmDeltaMs !== undefined && timing.firstTtsTextMs !== undefined
|
|
725
|
+
? timing.firstTtsTextMs - timing.firstLlmDeltaMs
|
|
726
|
+
: undefined;
|
|
727
|
+
const ttsTtfbMs = timing.firstTtsTextMs !== undefined
|
|
728
|
+
? firstAudioMs - timing.firstTtsTextMs
|
|
729
|
+
: undefined;
|
|
730
|
+
const attributedMs = [eouDelayMs, llmTtftMs, textAggregationMs, ttsTtfbMs]
|
|
731
|
+
.filter((value) => value !== undefined)
|
|
732
|
+
.reduce((sum, value) => sum + value, 0);
|
|
733
|
+
const ttfaMs = firstAudioMs - anchorMs;
|
|
734
|
+
const unattributedMs = ttfaMs - attributedMs;
|
|
735
|
+
if (unattributedMs >= 500)
|
|
736
|
+
this.markInfrastructureBreach(contextId);
|
|
737
|
+
const fillerUsed = timing.fillerUsed === true;
|
|
738
|
+
const backchannelUsed = timing.backchannelUsed === true;
|
|
739
|
+
this.emit("turn_latency", {
|
|
740
|
+
tsMs: firstAudioMs,
|
|
741
|
+
turnId: contextId,
|
|
742
|
+
ttfaMs,
|
|
743
|
+
anchor,
|
|
744
|
+
...(eouDelayMs !== undefined ? { eouDelayMs } : {}),
|
|
745
|
+
...(llmTtftMs !== undefined ? { llmTtftMs } : {}),
|
|
746
|
+
...(textAggregationMs !== undefined ? { textAggregationMs } : {}),
|
|
747
|
+
...(ttsTtfbMs !== undefined ? { ttsTtfbMs } : {}),
|
|
748
|
+
unattributedMs,
|
|
749
|
+
...(timing.llmCallCount !== undefined ? { llmCallCount: timing.llmCallCount } : {}),
|
|
750
|
+
...(timing.llmPassTtftMs !== undefined ? { llmPassTtftMs: [...timing.llmPassTtftMs] } : {}),
|
|
751
|
+
fillerUsed,
|
|
752
|
+
backchannelUsed,
|
|
753
|
+
});
|
|
754
|
+
const tags = {
|
|
755
|
+
...this.observabilityDims,
|
|
756
|
+
layer: "infrastructure",
|
|
757
|
+
cancelled: this.interruptedGenerationContextIds.has(contextId) ? "true" : "false",
|
|
758
|
+
anchor,
|
|
759
|
+
filler_used: String(fillerUsed),
|
|
760
|
+
backchannel_used: String(backchannelUsed),
|
|
761
|
+
};
|
|
762
|
+
const histograms = [
|
|
763
|
+
["turn.ttfa_ms", ttfaMs],
|
|
764
|
+
["turn.eou_delay_ms", eouDelayMs],
|
|
765
|
+
["turn.llm_ttft_ms", llmTtftMs],
|
|
766
|
+
["turn.text_aggregation_ms", textAggregationMs],
|
|
767
|
+
["turn.tts_ttfb_ms", ttsTtfbMs],
|
|
768
|
+
["turn.unattributed_ms", unattributedMs],
|
|
769
|
+
];
|
|
770
|
+
for (const [name, valueMs] of histograms) {
|
|
771
|
+
if (valueMs !== undefined)
|
|
772
|
+
this.metricsExporter.observeHistogram(name, valueMs, tags);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
handleTurnComplete(pkt) {
|
|
776
|
+
if (this.lastFinalizedContextId === pkt.contextId) {
|
|
777
|
+
this.bus.push(Route.Background, make.metric(pkt.contextId, "eos.duplicate_dropped", "1"));
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
// A backchannel ("uh-huh", "okay") uttered WHILE the assistant is still speaking
|
|
781
|
+
// is not a turn (B4). Dropping it here does two things: it does NOT cancel the
|
|
782
|
+
// assistant's in-flight answer (the supersede path below would otherwise kill it),
|
|
783
|
+
// and it does NOT spawn a second LLM response to the backchannel (the double-reply
|
|
784
|
+
// bug). A backchannel with no assistant currently speaking IS a real turn — only
|
|
785
|
+
// suppress when another context's TTS is actively playing. (English-only classifier
|
|
786
|
+
// today — locale-aware backchannels are a separate improvement.)
|
|
787
|
+
const otherTtsActive = this.ttsPlayout.activeContexts().some((c) => c !== pkt.contextId);
|
|
788
|
+
if (otherTtsActive && isBackchannel(pkt.text)) {
|
|
789
|
+
this.bus.push(Route.Background, make.metric(pkt.contextId, "turn.backchannel_dropped", pkt.text));
|
|
790
|
+
this.emitAcousticSignal(pkt.contextId, pkt.timestampMs, "backchannel", { text: pkt.text });
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (this.emittedTurnLocalizations.has(pkt.contextId)) {
|
|
794
|
+
this.emittedTurnLocalizations.delete(pkt.contextId);
|
|
795
|
+
this.turnLocalizationStates.delete(pkt.contextId);
|
|
796
|
+
}
|
|
797
|
+
this.lastFinalizedContextId = pkt.contextId;
|
|
798
|
+
this.interaction.reset(pkt.contextId);
|
|
799
|
+
// The force-finalize flag has now been read by whoever emitted this eos (and
|
|
800
|
+
// captured by the metrics tracker via its metric) — retire it so the per-call
|
|
801
|
+
// Set does not grow unbounded on a stable transport contextId.
|
|
802
|
+
this.watchdogs.clearForceFinalized(pkt.contextId);
|
|
803
|
+
// Re-arm per-turn guard state for the next turn. Transports with a stable
|
|
804
|
+
// per-call contextId (telephony callSid) reuse one id across turns, so these
|
|
805
|
+
// Sets must be cleared at the turn boundary or turn 2+ inherits stale flags:
|
|
806
|
+
// - firstTtsAudioFired: else vaqi.latency_ms is never emitted again
|
|
807
|
+
// - interruptedGenerationContextIds: else turn N+1's LLM/TTS packets are dropped after a prior barge-in
|
|
808
|
+
// - fallbackInjectedContexts: else only one error fallback can ever be spoken per call
|
|
809
|
+
this.firstTtsAudioFired.delete(pkt.contextId);
|
|
810
|
+
this.pendingTurnLatency.delete(pkt.contextId);
|
|
811
|
+
this.interruptedGenerationContextIds.delete(pkt.contextId);
|
|
812
|
+
this.fallbackInjectedContexts.delete(pkt.contextId);
|
|
813
|
+
// Supersede (L1): a new turn must cancel any still-active prior-turn TTS or
|
|
814
|
+
// generation. Without this, a false-EOS (early endpoint on a mid-sentence
|
|
815
|
+
// pause) starts turn N, the user resumes, turn N's already-emitted audio
|
|
816
|
+
// keeps synthesizing, and it plays over the user while turn N+1 is answered.
|
|
817
|
+
// The bridge supersedes the *LLM*; only the session can stop the *TTS*.
|
|
818
|
+
for (const activeCtx of this.ttsPlayout.activeContexts()) {
|
|
819
|
+
if (activeCtx !== pkt.contextId)
|
|
820
|
+
this.cancelStaleGeneration(activeCtx, pkt.timestampMs);
|
|
821
|
+
}
|
|
822
|
+
this.generatingContextIds.add(pkt.contextId);
|
|
823
|
+
this.currentTurnId = pkt.contextId;
|
|
824
|
+
this.idleTimeout.setContextId(pkt.contextId);
|
|
825
|
+
this.emit("user_input_final", {
|
|
826
|
+
tsMs: pkt.timestampMs,
|
|
827
|
+
turnId: pkt.contextId,
|
|
828
|
+
text: pkt.text,
|
|
829
|
+
confidence: 1.0,
|
|
830
|
+
});
|
|
831
|
+
this.debugPush({
|
|
832
|
+
component: "eos",
|
|
833
|
+
type: "turn_complete",
|
|
834
|
+
data: { context_id: pkt.contextId, text: pkt.text },
|
|
835
|
+
timestampMs: pkt.timestampMs,
|
|
836
|
+
});
|
|
837
|
+
// Stop idle timeout while the LLM processes. The user just spoke — that is
|
|
838
|
+
// genuine engagement, so reset the idle *escalation* count (P2): a user who
|
|
839
|
+
// answers the first "are you there?" must not be escalated straight to the
|
|
840
|
+
// disconnect prompt later in the call.
|
|
841
|
+
this.bus.push(Route.Main, make.stopIdleTimeout(pkt.contextId, Date.now(), true));
|
|
842
|
+
const timing = this.timingFor(pkt.contextId);
|
|
843
|
+
timing.eosMs = pkt.timestampMs;
|
|
844
|
+
const fillerText = this.latencyFiller.start(pkt.contextId, pkt.text, pkt.timestampMs);
|
|
845
|
+
if (fillerText) {
|
|
846
|
+
timing.fillerUsed = true;
|
|
847
|
+
timing.firstTtsTextMs ??= pkt.timestampMs;
|
|
848
|
+
this.bus.push(Route.Main, make.ttsText(pkt.contextId, Date.now(), fillerText));
|
|
849
|
+
this.bus.push(Route.Background, make.metric(pkt.contextId, "filler.started", fillerText));
|
|
850
|
+
}
|
|
851
|
+
this.bus.push(Route.Main, make.userInput(pkt.contextId, Date.now(), pkt.text, languageFromTranscripts(pkt.transcripts)));
|
|
852
|
+
}
|
|
853
|
+
handleEosInterim(pkt) {
|
|
854
|
+
this.debugPush({
|
|
855
|
+
component: "eos",
|
|
856
|
+
type: "interim",
|
|
857
|
+
data: { context_id: pkt.contextId, text: pkt.text },
|
|
858
|
+
timestampMs: pkt.timestampMs,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
handleLlmDelta(pkt) {
|
|
862
|
+
if (this.interruptedGenerationContextIds.has(pkt.contextId)) {
|
|
863
|
+
this.bus.push(Route.Background, make.metric(pkt.contextId, "llm.delta_ignored_after_interrupt", String(pkt.text.length)));
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
let deltaText = pkt.text;
|
|
867
|
+
if (!this.firstLlmDeltaReceived.has(pkt.contextId)) {
|
|
868
|
+
this.firstLlmDeltaReceived.add(pkt.contextId);
|
|
869
|
+
const timing = this.turnTimings.get(pkt.contextId);
|
|
870
|
+
if (timing)
|
|
871
|
+
timing.firstLlmDeltaMs ??= pkt.timestampMs;
|
|
872
|
+
if (this.latencyFiller.isActive(pkt.contextId)) {
|
|
873
|
+
deltaText = this.latencyFiller.spliceLlmDelta(pkt.contextId, deltaText);
|
|
874
|
+
this.bus.push(Route.Background, make.metric(pkt.contextId, "filler.spliced", "1"));
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
this.emit("agent_text_delta", {
|
|
878
|
+
tsMs: pkt.timestampMs,
|
|
879
|
+
turnId: pkt.contextId,
|
|
880
|
+
delta: deltaText,
|
|
881
|
+
});
|
|
882
|
+
this.debugPush({
|
|
883
|
+
component: "llm",
|
|
884
|
+
type: "delta",
|
|
885
|
+
data: { context_id: pkt.contextId, text: deltaText },
|
|
886
|
+
timestampMs: pkt.timestampMs,
|
|
887
|
+
});
|
|
888
|
+
this.bufferTtsText(pkt.contextId, deltaText, pkt.timestampMs);
|
|
889
|
+
}
|
|
890
|
+
handleLlmDone(pkt) {
|
|
891
|
+
this.generatingContextIds.delete(pkt.contextId);
|
|
892
|
+
if (this.interruptedGenerationContextIds.has(pkt.contextId)) {
|
|
893
|
+
this.ttsTextBuffers.delete(pkt.contextId);
|
|
894
|
+
this.bus.push(Route.Background, make.metric(pkt.contextId, "llm.done_ignored_after_interrupt", "1"));
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
const spokenText = this.flushTtsText(pkt.contextId, pkt.timestampMs);
|
|
898
|
+
this.emit("agent_finished", {
|
|
899
|
+
tsMs: pkt.timestampMs,
|
|
900
|
+
turnId: pkt.contextId,
|
|
901
|
+
});
|
|
902
|
+
this.debugPush({
|
|
903
|
+
component: "llm",
|
|
904
|
+
type: "done",
|
|
905
|
+
data: { context_id: pkt.contextId },
|
|
906
|
+
timestampMs: pkt.timestampMs,
|
|
907
|
+
});
|
|
908
|
+
// Start idle timeout after agent finishes
|
|
909
|
+
this.bus.push(Route.Main, make.startIdleTimeout(pkt.contextId, Date.now()));
|
|
910
|
+
this.bus.push(Route.Main, make.ttsDone(pkt.contextId, Date.now(), spokenText));
|
|
911
|
+
}
|
|
912
|
+
bufferTtsText(contextId, text, tsMs) {
|
|
913
|
+
const buffer = this.ttsTextBuffers.get(contextId) ?? { pending: "", emitted: "" };
|
|
914
|
+
buffer.pending += text;
|
|
915
|
+
const complete = takeCompleteVoiceText(buffer.pending);
|
|
916
|
+
if (complete.text) {
|
|
917
|
+
const timing = this.turnTimings.get(contextId);
|
|
918
|
+
if (timing)
|
|
919
|
+
timing.firstTtsTextMs ??= tsMs ?? Date.now();
|
|
920
|
+
// Strip markdown before it reaches TTS, and track the SPOKEN form as the heard
|
|
921
|
+
// prefix so barge-in truncation reconciles against what was actually said.
|
|
922
|
+
const spoken = normalizeForSpeech(complete.text);
|
|
923
|
+
this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), spoken));
|
|
924
|
+
buffer.emitted = appendVoiceText(buffer.emitted, spoken);
|
|
925
|
+
}
|
|
926
|
+
buffer.pending = complete.remaining;
|
|
927
|
+
this.ttsTextBuffers.set(contextId, buffer);
|
|
928
|
+
}
|
|
929
|
+
flushTtsText(contextId, tsMs) {
|
|
930
|
+
const buffer = this.ttsTextBuffers.get(contextId);
|
|
931
|
+
if (!buffer)
|
|
932
|
+
return "";
|
|
933
|
+
const tail = normalizeForSpeech(buffer.pending.trim());
|
|
934
|
+
if (tail) {
|
|
935
|
+
const timing = this.turnTimings.get(contextId);
|
|
936
|
+
if (timing)
|
|
937
|
+
timing.firstTtsTextMs ??= tsMs ?? Date.now();
|
|
938
|
+
this.bus.push(Route.Main, make.ttsText(contextId, Date.now(), tail));
|
|
939
|
+
buffer.emitted = appendVoiceText(buffer.emitted, tail);
|
|
940
|
+
buffer.pending = "";
|
|
941
|
+
this.bus.push(Route.Background, make.metric(contextId, isCompleteVoiceText(tail) ? "tts.final_text_flushed" : "tts.final_tail_flushed", tail));
|
|
942
|
+
}
|
|
943
|
+
this.ttsTextBuffers.delete(contextId);
|
|
944
|
+
this.latencyFiller.clear(contextId);
|
|
945
|
+
this.firstLlmDeltaReceived.delete(contextId);
|
|
946
|
+
return buffer.emitted.trim();
|
|
947
|
+
}
|
|
948
|
+
cancelLatencyFillerTurn(contextId, timestampMs) {
|
|
949
|
+
const cancelled = this.latencyFiller.cancel(contextId);
|
|
950
|
+
if (!cancelled)
|
|
951
|
+
return;
|
|
952
|
+
this.bus.push(Route.Background, make.metric(contextId, "filler.cancelled", cancelled.text, timestampMs));
|
|
953
|
+
this.turnArbiter.emitInterruptDetected(contextId);
|
|
954
|
+
}
|
|
955
|
+
handleLlmToolCall(pkt) {
|
|
956
|
+
this.emit("agent_tool_call", {
|
|
957
|
+
tsMs: pkt.timestampMs,
|
|
958
|
+
turnId: pkt.contextId,
|
|
959
|
+
id: pkt.toolId,
|
|
960
|
+
name: pkt.toolName,
|
|
961
|
+
args: pkt.toolArgs,
|
|
962
|
+
});
|
|
963
|
+
this.debugPush({
|
|
964
|
+
component: "tool",
|
|
965
|
+
type: "call_started",
|
|
966
|
+
data: {
|
|
967
|
+
context_id: pkt.contextId,
|
|
968
|
+
tool_id: pkt.toolId,
|
|
969
|
+
tool_name: pkt.toolName,
|
|
970
|
+
},
|
|
971
|
+
timestampMs: pkt.timestampMs,
|
|
972
|
+
});
|
|
973
|
+
// G3: arm the typed preamble/filler lifecycle for this pending tool call.
|
|
974
|
+
const pending = this.pendingToolCues.get(pkt.contextId) ?? new Map();
|
|
975
|
+
pending.set(pkt.toolId, pkt.toolName);
|
|
976
|
+
this.pendingToolCues.set(pkt.contextId, pending);
|
|
977
|
+
this.emitToolCallCue(pkt.contextId, "started", pkt.toolId, pkt.toolName);
|
|
978
|
+
if (this.delayCueAfterMs > 0) {
|
|
979
|
+
const afterMs = this.delayCueAfterMs;
|
|
980
|
+
this.scheduler.schedule(toolCueTimerKey(pkt.contextId, pkt.toolId), afterMs, () => {
|
|
981
|
+
if (!this.pendingToolCues.get(pkt.contextId)?.has(pkt.toolId))
|
|
982
|
+
return;
|
|
983
|
+
this.emitToolCallCue(pkt.contextId, "delayed", pkt.toolId, pkt.toolName, afterMs);
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
emitToolCallCue(contextId, phase, toolId, toolName, afterMs) {
|
|
988
|
+
const tsMs = Date.now();
|
|
989
|
+
this.emit("tool_call_cue", {
|
|
990
|
+
tsMs,
|
|
991
|
+
turnId: contextId,
|
|
992
|
+
phase,
|
|
993
|
+
toolId,
|
|
994
|
+
toolName,
|
|
995
|
+
...(afterMs !== undefined ? { afterMs } : {}),
|
|
996
|
+
});
|
|
997
|
+
this.interaction.observe({
|
|
998
|
+
kind: "delegate_state",
|
|
999
|
+
contextId,
|
|
1000
|
+
timestampMs: tsMs,
|
|
1001
|
+
delegateInFlight: phase === "started" || phase === "delayed",
|
|
1002
|
+
toolCallPhase: phase,
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
/** G3: resolve one pending tool call with a terminal cue phase. */
|
|
1006
|
+
resolveToolCue(contextId, toolId, phase) {
|
|
1007
|
+
const pending = this.pendingToolCues.get(contextId);
|
|
1008
|
+
const toolName = pending?.get(toolId);
|
|
1009
|
+
if (toolName === undefined)
|
|
1010
|
+
return;
|
|
1011
|
+
pending.delete(toolId);
|
|
1012
|
+
if (pending.size === 0)
|
|
1013
|
+
this.pendingToolCues.delete(contextId);
|
|
1014
|
+
this.scheduler.cancel(toolCueTimerKey(contextId, toolId));
|
|
1015
|
+
this.emitToolCallCue(contextId, phase, toolId, toolName);
|
|
1016
|
+
}
|
|
1017
|
+
/** G3: fail every pending tool call for a context (error / barge-in / supersede). */
|
|
1018
|
+
failPendingToolCues(contextId) {
|
|
1019
|
+
const pending = this.pendingToolCues.get(contextId);
|
|
1020
|
+
if (!pending)
|
|
1021
|
+
return;
|
|
1022
|
+
for (const toolId of [...pending.keys()])
|
|
1023
|
+
this.resolveToolCue(contextId, toolId, "failed");
|
|
1024
|
+
}
|
|
1025
|
+
handleDelegateQuery(pkt) {
|
|
1026
|
+
this.emit("delegate_query", {
|
|
1027
|
+
tsMs: pkt.timestampMs,
|
|
1028
|
+
turnId: pkt.contextId,
|
|
1029
|
+
query: pkt.query,
|
|
1030
|
+
toolId: pkt.toolId,
|
|
1031
|
+
toolName: pkt.toolName,
|
|
1032
|
+
});
|
|
1033
|
+
this.debugPush({
|
|
1034
|
+
component: "delegate",
|
|
1035
|
+
type: "query",
|
|
1036
|
+
data: {
|
|
1037
|
+
context_id: pkt.contextId,
|
|
1038
|
+
query: pkt.query,
|
|
1039
|
+
...(pkt.toolName ? { tool_name: pkt.toolName } : {}),
|
|
1040
|
+
},
|
|
1041
|
+
timestampMs: pkt.timestampMs,
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
handleDelegateResult(pkt) {
|
|
1045
|
+
if (pkt.control && isConversationControlName(pkt.control.name)) {
|
|
1046
|
+
this.markConversationFlag(pkt.contextId);
|
|
1047
|
+
}
|
|
1048
|
+
this.emit("delegate_result", {
|
|
1049
|
+
tsMs: pkt.timestampMs,
|
|
1050
|
+
turnId: pkt.contextId,
|
|
1051
|
+
query: pkt.query,
|
|
1052
|
+
answer: pkt.answer,
|
|
1053
|
+
durationMs: pkt.durationMs,
|
|
1054
|
+
grounded: pkt.grounded,
|
|
1055
|
+
toolId: pkt.toolId,
|
|
1056
|
+
toolName: pkt.toolName,
|
|
1057
|
+
...(pkt.control ? { control: pkt.control } : {}),
|
|
1058
|
+
...(pkt.blocked ? { blocked: pkt.blocked } : {}),
|
|
1059
|
+
});
|
|
1060
|
+
this.debugPush({
|
|
1061
|
+
component: "delegate",
|
|
1062
|
+
type: "result",
|
|
1063
|
+
data: {
|
|
1064
|
+
context_id: pkt.contextId,
|
|
1065
|
+
duration_ms: String(pkt.durationMs),
|
|
1066
|
+
grounded: String(pkt.grounded),
|
|
1067
|
+
},
|
|
1068
|
+
timestampMs: pkt.timestampMs,
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
handleLlmToolResult(pkt) {
|
|
1072
|
+
this.resolveToolCue(pkt.contextId, pkt.toolId, "complete");
|
|
1073
|
+
this.emit("agent_tool_result", {
|
|
1074
|
+
tsMs: pkt.timestampMs,
|
|
1075
|
+
turnId: pkt.contextId,
|
|
1076
|
+
id: pkt.toolId,
|
|
1077
|
+
result: pkt.result,
|
|
1078
|
+
durationMs: 0,
|
|
1079
|
+
});
|
|
1080
|
+
this.debugPush({
|
|
1081
|
+
component: "tool",
|
|
1082
|
+
type: "call_completed",
|
|
1083
|
+
data: {
|
|
1084
|
+
context_id: pkt.contextId,
|
|
1085
|
+
tool_id: pkt.toolId,
|
|
1086
|
+
tool_name: pkt.toolName,
|
|
1087
|
+
},
|
|
1088
|
+
timestampMs: pkt.timestampMs,
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
handleConversationMetric(pkt) {
|
|
1092
|
+
if (isInfrastructureMetric(pkt.name))
|
|
1093
|
+
this.markInfrastructureBreach(pkt.contextId);
|
|
1094
|
+
if (isConversationEvaluationMetric(pkt.name, pkt.value))
|
|
1095
|
+
this.markConversationFlag(pkt.contextId);
|
|
1096
|
+
if (pkt.name === "input.cadence_stall_ms") {
|
|
1097
|
+
this.emitAcousticSignal(pkt.contextId, pkt.timestampMs, "cadence", { value: pkt.value });
|
|
1098
|
+
}
|
|
1099
|
+
if (pkt.name === "llm.call_started") {
|
|
1100
|
+
const timing = this.timingFor(pkt.contextId);
|
|
1101
|
+
timing.llmCallCount = (timing.llmCallCount ?? 0) + 1;
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
if (pkt.name === "llm.pass_ttft_ms") {
|
|
1105
|
+
const ttftMs = Number(pkt.value);
|
|
1106
|
+
if (!Number.isFinite(ttftMs))
|
|
1107
|
+
return;
|
|
1108
|
+
const timing = this.timingFor(pkt.contextId);
|
|
1109
|
+
(timing.llmPassTtftMs ??= []).push(ttftMs);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
emitAcousticSignal(contextId, timestampMs, signal, payload) {
|
|
1113
|
+
this.bus.push(Route.Background, make.acousticSignal(contextId, timestampMs, signal, payload));
|
|
1114
|
+
this.metricsExporter.observeCounter?.(`acoustic.${signal}`, 1, {
|
|
1115
|
+
provider: this.observabilityDims.provider,
|
|
1116
|
+
model: this.observabilityDims.model,
|
|
1117
|
+
region: this.observabilityDims.region,
|
|
1118
|
+
layer: "conversation",
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
localizationStateFor(contextId) {
|
|
1122
|
+
const existing = this.turnLocalizationStates.get(contextId);
|
|
1123
|
+
if (existing)
|
|
1124
|
+
return existing;
|
|
1125
|
+
const state = { infrastructureBreached: false, conversationFlagged: false };
|
|
1126
|
+
this.turnLocalizationStates.set(contextId, state);
|
|
1127
|
+
return state;
|
|
1128
|
+
}
|
|
1129
|
+
markInfrastructureBreach(contextId) {
|
|
1130
|
+
this.localizationStateFor(contextId).infrastructureBreached = true;
|
|
1131
|
+
}
|
|
1132
|
+
markConversationFlag(contextId) {
|
|
1133
|
+
this.localizationStateFor(contextId).conversationFlagged = true;
|
|
1134
|
+
}
|
|
1135
|
+
emitTurnLocalization(contextId) {
|
|
1136
|
+
if (this.emittedTurnLocalizations.has(contextId))
|
|
1137
|
+
return;
|
|
1138
|
+
const state = this.localizationStateFor(contextId);
|
|
1139
|
+
this.emittedTurnLocalizations.add(contextId);
|
|
1140
|
+
this.bus.push(Route.Background, make.turnLocalization(contextId, Date.now(), localizeTurn(state), state.infrastructureBreached, state.conversationFlagged));
|
|
1141
|
+
}
|
|
1142
|
+
scheduleTurnLocalization(contextId) {
|
|
1143
|
+
this.scheduler.schedule(turnLocalizationTimerKey(contextId), 0, () => {
|
|
1144
|
+
this.emitTurnLocalization(contextId);
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
static USAGE_FIELDS = [
|
|
1148
|
+
"inputTokens",
|
|
1149
|
+
"outputTokens",
|
|
1150
|
+
"totalTokens",
|
|
1151
|
+
"cachedInputTokens",
|
|
1152
|
+
"reasoningTokens",
|
|
1153
|
+
"audioSeconds",
|
|
1154
|
+
"characters",
|
|
1155
|
+
];
|
|
1156
|
+
handleUsageRecorded(pkt) {
|
|
1157
|
+
// Accumulate into the per-stage running total. A missing field stays missing
|
|
1158
|
+
// (never coerced to 0) so an un-reported dimension is distinguishable from a real 0.
|
|
1159
|
+
const prev = this.usageByStage.get(pkt.stage) ?? { stage: pkt.stage };
|
|
1160
|
+
const next = { ...prev };
|
|
1161
|
+
for (const field of VoiceAgentSession.USAGE_FIELDS) {
|
|
1162
|
+
const add = pkt[field];
|
|
1163
|
+
if (typeof add === "number")
|
|
1164
|
+
next[field] = (prev[field] ?? 0) + add;
|
|
1165
|
+
}
|
|
1166
|
+
this.usageByStage.set(pkt.stage, next);
|
|
1167
|
+
// Export each recorded unit as a counter, tagged low-cardinality only — same
|
|
1168
|
+
// discipline as turn_latency. provider/model are exemplar-safe; contextId is not,
|
|
1169
|
+
// and must never become a metric dimension (LiveKit caps at model/provider).
|
|
1170
|
+
const exporter = this.metricsExporter;
|
|
1171
|
+
if (exporter.observeCounter) {
|
|
1172
|
+
const tags = {
|
|
1173
|
+
stage: pkt.stage,
|
|
1174
|
+
provider: pkt.provider ?? "",
|
|
1175
|
+
model: pkt.model ?? "",
|
|
1176
|
+
layer: "infrastructure",
|
|
1177
|
+
};
|
|
1178
|
+
for (const field of VoiceAgentSession.USAGE_FIELDS) {
|
|
1179
|
+
const value = pkt[field];
|
|
1180
|
+
if (typeof value === "number")
|
|
1181
|
+
exporter.observeCounter(`usage.${field}`, value, tags);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
emitSessionUsage() {
|
|
1186
|
+
if (this.usageByStage.size === 0)
|
|
1187
|
+
return;
|
|
1188
|
+
this.emit("usage", { tsMs: Date.now(), stages: [...this.usageByStage.values()] });
|
|
1189
|
+
}
|
|
1190
|
+
handleTtsAudio(pkt) {
|
|
1191
|
+
if (this.interruptedGenerationContextIds.has(pkt.contextId)) {
|
|
1192
|
+
this.bus.push(Route.Background, make.metric(pkt.contextId, "tts.audio_ignored_after_interrupt", String(pkt.audio.byteLength)));
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
if (!this.firstTtsAudioFired.has(pkt.contextId)) {
|
|
1196
|
+
this.firstTtsAudioFired.add(pkt.contextId);
|
|
1197
|
+
this.watchdogs.clearVaqiIfContext(pkt.contextId);
|
|
1198
|
+
const userStoppedMs = this.turnUserStoppedAtMs.get(pkt.contextId);
|
|
1199
|
+
if (userStoppedMs !== undefined) {
|
|
1200
|
+
this.bus.push(Route.Background, make.metric(pkt.contextId, "vaqi.latency_ms", String(pkt.timestampMs - userStoppedMs)));
|
|
1201
|
+
this.turnUserStoppedAtMs.delete(pkt.contextId);
|
|
1202
|
+
}
|
|
1203
|
+
if (this.generatingContextIds.has(pkt.contextId)) {
|
|
1204
|
+
this.pendingTurnLatency.set(pkt.contextId, pkt.timestampMs);
|
|
1205
|
+
}
|
|
1206
|
+
else {
|
|
1207
|
+
this.emitTurnLatency(pkt.contextId, pkt.timestampMs);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
const audio = this.normalizeOutboundAudio(pkt.audio);
|
|
1211
|
+
// Transport subscribers receive this same packet after the core handler.
|
|
1212
|
+
if (audio !== pkt.audio)
|
|
1213
|
+
Object.assign(pkt, { audio });
|
|
1214
|
+
this.primarySpeakerGate.observeAssistantPlayout(audio);
|
|
1215
|
+
// Audio just arrived — (re)arm the stall watchdog for this turn's TTS output.
|
|
1216
|
+
this.watchdogs.armTtsStallTimer(pkt.contextId);
|
|
1217
|
+
// Mark active and advance this context's playout cursor by the chunk's
|
|
1218
|
+
// realtime duration.
|
|
1219
|
+
const sampleRateHz = requireTtsAudioSampleRate(pkt.sampleRateHz);
|
|
1220
|
+
const audioDurationMs = estimatePcm16Duration(audio, sampleRateHz);
|
|
1221
|
+
const now = Date.now();
|
|
1222
|
+
this.ttsPlayout.noteAudio(pkt.contextId, audioDurationMs, now);
|
|
1223
|
+
this.interaction.observe({
|
|
1224
|
+
kind: "playout_tick",
|
|
1225
|
+
contextId: pkt.contextId,
|
|
1226
|
+
timestampMs: pkt.timestampMs,
|
|
1227
|
+
ttsActive: true,
|
|
1228
|
+
audio: pcm16BytesToSamples(audio),
|
|
1229
|
+
sampleRateHz,
|
|
1230
|
+
});
|
|
1231
|
+
// Anchor the idle timer to when playout actually *ends* (P2), not to chunk
|
|
1232
|
+
// arrival. TTS streams faster than realtime, so extending by each chunk's
|
|
1233
|
+
// duration from arrival lets the timer fire mid-speech on a long answer.
|
|
1234
|
+
// playoutEnd() is cumulative across chunks, so this re-arms to durationMs
|
|
1235
|
+
// after the audio delivered so far finishes playing.
|
|
1236
|
+
const playoutEndMs = this.ttsPlayout.playoutEnd(pkt.contextId);
|
|
1237
|
+
this.idleTimeout.extend(playoutEndMs !== undefined ? Math.max(0, playoutEndMs - now) : audioDurationMs);
|
|
1238
|
+
this.debugPush({
|
|
1239
|
+
component: "tts",
|
|
1240
|
+
type: "audio",
|
|
1241
|
+
data: {
|
|
1242
|
+
context_id: pkt.contextId,
|
|
1243
|
+
bytes: String(audio.length),
|
|
1244
|
+
},
|
|
1245
|
+
timestampMs: pkt.timestampMs,
|
|
1246
|
+
});
|
|
1247
|
+
this.bus.push(Route.Main, make.recordAssistantAudio(pkt.contextId, Date.now(), audio, sampleRateHz));
|
|
1248
|
+
}
|
|
1249
|
+
normalizeOutboundAudio(audio) {
|
|
1250
|
+
if (!this.outboundLoudnessConfig ||
|
|
1251
|
+
!this.outboundLoudnessState ||
|
|
1252
|
+
audio.byteLength % 2 !== 0) {
|
|
1253
|
+
return audio;
|
|
1254
|
+
}
|
|
1255
|
+
const samples = pcm16BytesToSamples(audio);
|
|
1256
|
+
return pcm16SamplesToBytes(normalizeLoudness(samples, this.outboundLoudnessState, this.outboundLoudnessConfig));
|
|
1257
|
+
}
|
|
1258
|
+
handleTtsEnd(pkt) {
|
|
1259
|
+
// Generation finished, but the streamed audio is still playing out. Keep the
|
|
1260
|
+
// context interruptible until its playout estimate elapses, then release it.
|
|
1261
|
+
this.generatingContextIds.delete(pkt.contextId);
|
|
1262
|
+
const firstAudioMs = this.pendingTurnLatency.get(pkt.contextId);
|
|
1263
|
+
if (firstAudioMs !== undefined) {
|
|
1264
|
+
this.pendingTurnLatency.delete(pkt.contextId);
|
|
1265
|
+
this.emitTurnLatency(pkt.contextId, firstAudioMs);
|
|
1266
|
+
}
|
|
1267
|
+
const now = Date.now();
|
|
1268
|
+
this.ttsPlayout.scheduleRelease(pkt.contextId, now);
|
|
1269
|
+
const playoutEndMs = this.ttsPlayout.playoutEnd(pkt.contextId);
|
|
1270
|
+
const remainingMs = playoutEndMs === undefined ? 0 : Math.max(0, playoutEndMs - now);
|
|
1271
|
+
this.scheduleInteractionPlayoutTick(pkt.contextId, remainingMs);
|
|
1272
|
+
this.watchdogs.clearTtsStallTimerFor(pkt.contextId);
|
|
1273
|
+
this.debugPush({
|
|
1274
|
+
component: "tts",
|
|
1275
|
+
type: "end",
|
|
1276
|
+
data: {},
|
|
1277
|
+
timestampMs: Date.now(),
|
|
1278
|
+
});
|
|
1279
|
+
this.scheduleTurnLocalization(pkt.contextId);
|
|
1280
|
+
}
|
|
1281
|
+
scheduleInteractionPlayoutTick(contextId, delayMs) {
|
|
1282
|
+
this.pendingInteractionPlayoutTimers.add(contextId);
|
|
1283
|
+
this.scheduler.schedule(interactionPlayoutTimerKey(contextId), delayMs, () => {
|
|
1284
|
+
this.pendingInteractionPlayoutTimers.delete(contextId);
|
|
1285
|
+
if (this.ttsPlayout.isActive(contextId))
|
|
1286
|
+
return;
|
|
1287
|
+
this.interaction.observe({
|
|
1288
|
+
kind: "playout_tick",
|
|
1289
|
+
contextId,
|
|
1290
|
+
timestampMs: Date.now(),
|
|
1291
|
+
ttsActive: false,
|
|
1292
|
+
});
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
cancelInteractionPlayoutTick(contextId) {
|
|
1296
|
+
if (!this.pendingInteractionPlayoutTimers.delete(contextId))
|
|
1297
|
+
return;
|
|
1298
|
+
this.scheduler.cancel(interactionPlayoutTimerKey(contextId));
|
|
1299
|
+
}
|
|
1300
|
+
handleTtsPlayoutProgress(pkt) {
|
|
1301
|
+
this.cancelInteractionPlayoutTick(pkt.contextId);
|
|
1302
|
+
this.ttsPlayout.noteProgress(pkt.contextId, pkt.complete, pkt.playedOutMs);
|
|
1303
|
+
this.interaction.observe({
|
|
1304
|
+
kind: "playout_tick",
|
|
1305
|
+
contextId: pkt.contextId,
|
|
1306
|
+
timestampMs: pkt.timestampMs,
|
|
1307
|
+
playedOutMs: pkt.playedOutMs,
|
|
1308
|
+
ttsActive: !pkt.complete,
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
handleInterruptDetected(pkt) {
|
|
1312
|
+
this.cancelInteractionPlayoutTick(pkt.contextId);
|
|
1313
|
+
this.interruptedGenerationContextIds.add(pkt.contextId);
|
|
1314
|
+
this.failPendingToolCues(pkt.contextId); // G3: the aborted delegate's cue fails (R5)
|
|
1315
|
+
this.latencyFiller.cancel(pkt.contextId);
|
|
1316
|
+
// A barged turn still produced first audio, and its TTFA is a real measurement.
|
|
1317
|
+
// Deferring emission to tts.end (so later tool passes are counted) must not make
|
|
1318
|
+
// interrupted turns vanish from the metric: barge-in correlates with slow turns,
|
|
1319
|
+
// so silently dropping them biases the sample toward the fast ones — the worst
|
|
1320
|
+
// turns would disappear from exactly the number meant to detect them.
|
|
1321
|
+
this.flushPendingTurnLatency(pkt.contextId);
|
|
1322
|
+
this.emitAcousticSignal(pkt.contextId, pkt.timestampMs, "interruption", {
|
|
1323
|
+
source: pkt.source,
|
|
1324
|
+
});
|
|
1325
|
+
this.turnTimings.delete(pkt.contextId);
|
|
1326
|
+
this.firstLlmDeltaReceived.delete(pkt.contextId);
|
|
1327
|
+
this.ttsTextBuffers.delete(pkt.contextId);
|
|
1328
|
+
this.ttsPlayout.release(pkt.contextId);
|
|
1329
|
+
this.watchdogs.clearTtsStallTimerFor(pkt.contextId);
|
|
1330
|
+
this.debugPush({
|
|
1331
|
+
component: "turn",
|
|
1332
|
+
type: "interrupt_detected",
|
|
1333
|
+
data: {
|
|
1334
|
+
context_id: pkt.contextId,
|
|
1335
|
+
source: pkt.source,
|
|
1336
|
+
},
|
|
1337
|
+
timestampMs: pkt.timestampMs,
|
|
1338
|
+
});
|
|
1339
|
+
this.bus.push(Route.Background, make.metric(pkt.contextId, "interrupt.onset_to_logic_cancel_ms", String(Math.max(0, Date.now() - pkt.timestampMs))));
|
|
1340
|
+
// Stop idle timeout
|
|
1341
|
+
this.bus.push(Route.Critical, make.stopIdleTimeout(pkt.contextId, Date.now(), true));
|
|
1342
|
+
this.bus.push(Route.Critical, make.recordAssistantTruncate(pkt.contextId, Date.now()));
|
|
1343
|
+
// Interrupt TTS, then LLM
|
|
1344
|
+
this.bus.push(Route.Critical, make.interruptTts(pkt.contextId, pkt.timestampMs));
|
|
1345
|
+
this.bus.push(Route.Critical, make.interruptLlm(pkt.contextId, pkt.timestampMs));
|
|
1346
|
+
// Reset STT transcript state too, so a barge-in cannot leak stale finalized
|
|
1347
|
+
// segments into the next turn when a client reuses the same contextId
|
|
1348
|
+
// (the provider STT plugins listen for interrupt.stt; previously unfired).
|
|
1349
|
+
this.bus.push(Route.Critical, make.interruptStt(pkt.contextId, pkt.timestampMs));
|
|
1350
|
+
this.generatingContextIds.delete(pkt.contextId);
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Cancel a stale prior-turn generation/playout when a new turn supersedes it
|
|
1354
|
+
* (L1). Mirrors the interrupt teardown but without the barge-in metrics — this
|
|
1355
|
+
* is a turn boundary, not a user interruption. Stops leftover TTS audio, aborts
|
|
1356
|
+
* the LLM, and drops late deltas/audio for the stale context.
|
|
1357
|
+
*/
|
|
1358
|
+
cancelStaleGeneration(contextId, timestampMs) {
|
|
1359
|
+
this.cancelInteractionPlayoutTick(contextId);
|
|
1360
|
+
this.interruptedGenerationContextIds.add(contextId);
|
|
1361
|
+
this.failPendingToolCues(contextId); // G3: a superseded turn's pending cue fails
|
|
1362
|
+
this.generatingContextIds.delete(contextId);
|
|
1363
|
+
this.pendingTurnLatency.delete(contextId);
|
|
1364
|
+
this.latencyFiller.cancel(contextId);
|
|
1365
|
+
this.turnTimings.delete(contextId);
|
|
1366
|
+
this.firstLlmDeltaReceived.delete(contextId);
|
|
1367
|
+
this.ttsTextBuffers.delete(contextId);
|
|
1368
|
+
this.ttsPlayout.release(contextId);
|
|
1369
|
+
this.watchdogs.clearTtsStallTimerFor(contextId);
|
|
1370
|
+
this.bus.push(Route.Critical, make.recordAssistantTruncate(contextId, Date.now()));
|
|
1371
|
+
this.bus.push(Route.Critical, make.interruptTts(contextId, timestampMs));
|
|
1372
|
+
this.bus.push(Route.Critical, make.interruptLlm(contextId, timestampMs));
|
|
1373
|
+
this.bus.push(Route.Background, make.metric(contextId, "supersede.cancelled_stale_generation", "1"));
|
|
1374
|
+
}
|
|
1375
|
+
handleComponentError(pkt) {
|
|
1376
|
+
this.markInfrastructureBreach(pkt.contextId);
|
|
1377
|
+
this.metricsExporter.observeCounter?.("error.stage", 1, {
|
|
1378
|
+
stage: pkt.component,
|
|
1379
|
+
category: pkt.category,
|
|
1380
|
+
layer: "infrastructure",
|
|
1381
|
+
});
|
|
1382
|
+
this.emit("error", {
|
|
1383
|
+
tsMs: pkt.timestampMs,
|
|
1384
|
+
stage: `${pkt.component}.error`,
|
|
1385
|
+
category: pkt.category,
|
|
1386
|
+
message: pkt.cause.message,
|
|
1387
|
+
});
|
|
1388
|
+
this.debugPush({
|
|
1389
|
+
component: pkt.component,
|
|
1390
|
+
type: "error",
|
|
1391
|
+
data: {
|
|
1392
|
+
category: pkt.category,
|
|
1393
|
+
message: pkt.cause.message,
|
|
1394
|
+
recoverable: String(pkt.isRecoverable),
|
|
1395
|
+
},
|
|
1396
|
+
timestampMs: pkt.timestampMs,
|
|
1397
|
+
});
|
|
1398
|
+
// G3: an LLM/bridge error while a tool call is pending means no result is coming.
|
|
1399
|
+
if (pkt.component === "llm" || pkt.component === "bridge") {
|
|
1400
|
+
this.failPendingToolCues(pkt.contextId);
|
|
1401
|
+
}
|
|
1402
|
+
this.latencyFiller.clear(pkt.contextId);
|
|
1403
|
+
this.generatingContextIds.delete(pkt.contextId);
|
|
1404
|
+
// The packet's own recoverability verdict is authoritative when present: the
|
|
1405
|
+
// bus marks handler exceptions (pipeline.error) recoverable by design — one
|
|
1406
|
+
// misbehaving handler must degrade the turn, not kill the whole call.
|
|
1407
|
+
const recoverable = typeof pkt.isRecoverable === "boolean" ? pkt.isRecoverable : isRecoverable(pkt.category);
|
|
1408
|
+
if (!recoverable) {
|
|
1409
|
+
// Fatal error — close session
|
|
1410
|
+
void this.close().catch(() => {
|
|
1411
|
+
// Best effort
|
|
1412
|
+
});
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
// Recoverable errors are handled by individual component retry logic. But never
|
|
1416
|
+
// leave the caller in unexplained silence: if the reasoning layer failed the turn,
|
|
1417
|
+
// speak a graceful fallback (G4 — Deepgram guide "never fail silently").
|
|
1418
|
+
this.maybeSpeakErrorFallback(pkt);
|
|
1419
|
+
this.emitTurnLocalization(pkt.contextId);
|
|
1420
|
+
}
|
|
1421
|
+
maybeSpeakErrorFallback(pkt) {
|
|
1422
|
+
if (!this.errorFallbackText)
|
|
1423
|
+
return;
|
|
1424
|
+
// Only the reasoning layer: a TTS/STT failure can't reliably use the same TTS path
|
|
1425
|
+
// for a fallback (that needs canned audio / a clarification prompt — out of scope).
|
|
1426
|
+
if (pkt.component !== "llm")
|
|
1427
|
+
return;
|
|
1428
|
+
const contextId = pkt.contextId;
|
|
1429
|
+
if (contextId.endsWith(FALLBACK_CONTEXT_SUFFIX))
|
|
1430
|
+
return; // never fall back for a fallback
|
|
1431
|
+
if (this.fallbackInjectedContexts.has(contextId))
|
|
1432
|
+
return; // at most once per turn
|
|
1433
|
+
this.fallbackInjectedContexts.add(contextId);
|
|
1434
|
+
this.bus.push(Route.Background, make.metric(contextId, "error.fallback_spoken", pkt.component));
|
|
1435
|
+
this.bus.push(Route.Main, make.injectMessage(`${contextId}${FALLBACK_CONTEXT_SUFFIX}`, Date.now(), this.errorFallbackText));
|
|
1436
|
+
}
|
|
1437
|
+
handleInitFailed(pkt) {
|
|
1438
|
+
this.emit("error", {
|
|
1439
|
+
tsMs: pkt.timestampMs,
|
|
1440
|
+
stage: `init.${pkt.stage}`,
|
|
1441
|
+
category: ErrorCategory.InternalFault,
|
|
1442
|
+
message: `Initialization failed: ${pkt.stage}/${pkt.component} — ${pkt.cause.message}`,
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
handleInjectMessage(pkt) {
|
|
1446
|
+
if (pkt.mode === "context") {
|
|
1447
|
+
const bridge = this.plugins.get("bridge");
|
|
1448
|
+
if (bridge?.injectContext) {
|
|
1449
|
+
bridge.injectContext(pkt.text);
|
|
1450
|
+
}
|
|
1451
|
+
else {
|
|
1452
|
+
console.warn("VoiceAgentSession: context injection requested but the bridge does not support injectContext");
|
|
1453
|
+
}
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
// Inject as synthetic LLM output — goes through normal TTS path
|
|
1457
|
+
this.bus.push(Route.Main, make.llmDelta(pkt.contextId, Date.now(), pkt.text));
|
|
1458
|
+
this.bus.push(Route.Main, make.llmDone(pkt.contextId, Date.now(), pkt.text));
|
|
1459
|
+
}
|
|
1460
|
+
handleSttReconfigure(pkt) {
|
|
1461
|
+
const stt = this.plugins.get("stt");
|
|
1462
|
+
if (stt?.sttReconfigure) {
|
|
1463
|
+
stt.sttReconfigure.reconfigure(pkt.partial);
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
console.warn("VoiceAgentSession: stt.reconfigure requested but the STT plugin does not support sttReconfigure");
|
|
1467
|
+
}
|
|
1468
|
+
handleDisconnect(pkt) {
|
|
1469
|
+
this.emit("closed", { tsMs: pkt.timestampMs, reason: pkt.reason });
|
|
1470
|
+
this.debugPush({
|
|
1471
|
+
component: "session",
|
|
1472
|
+
type: "disconnect_requested",
|
|
1473
|
+
data: { reason: pkt.reason },
|
|
1474
|
+
timestampMs: pkt.timestampMs,
|
|
1475
|
+
});
|
|
1476
|
+
void this.close().catch(() => {
|
|
1477
|
+
// Best effort
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
// =========================================================================
|
|
1481
|
+
// Init Chain
|
|
1482
|
+
// =========================================================================
|
|
1483
|
+
buildInitChain() {
|
|
1484
|
+
const steps = [];
|
|
1485
|
+
this.applyEndpointingOwnerInvariant();
|
|
1486
|
+
for (const [name, plugin] of this.plugins) {
|
|
1487
|
+
if (!this.shouldInitializePlugin(plugin))
|
|
1488
|
+
continue;
|
|
1489
|
+
steps.push({
|
|
1490
|
+
name,
|
|
1491
|
+
stage: pluginStage(name),
|
|
1492
|
+
run: () => {
|
|
1493
|
+
return plugin.initialize(this.bus, this.config.plugins[name] ?? {});
|
|
1494
|
+
},
|
|
1495
|
+
cleanup: () => plugin.close(),
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
if (this.activeInteractionPolicy === this.injectedInteractionPolicy &&
|
|
1499
|
+
isLifecycleInteractionPolicy(this.activeInteractionPolicy)) {
|
|
1500
|
+
const policy = this.activeInteractionPolicy;
|
|
1501
|
+
steps.push({
|
|
1502
|
+
name: "interaction_policy",
|
|
1503
|
+
stage: InitStage.EOS,
|
|
1504
|
+
run: () => policy.initialize(this.config.interactionPolicyConfig ?? {}),
|
|
1505
|
+
cleanup: () => policy.close(),
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
const orderedPluginSteps = steps.sort((a, b) => stageOrder(a.stage) - stageOrder(b.stage));
|
|
1509
|
+
this.modeSwitcher.register({
|
|
1510
|
+
textToAudioSteps: orderedPluginSteps,
|
|
1511
|
+
audioToTextCleanups: [...orderedPluginSteps]
|
|
1512
|
+
.reverse()
|
|
1513
|
+
.filter((step) => isAudioStage(step.stage))
|
|
1514
|
+
.map((step) => async () => {
|
|
1515
|
+
await step.cleanup?.();
|
|
1516
|
+
}),
|
|
1517
|
+
});
|
|
1518
|
+
// Behavior is always last (idle timeout, max session)
|
|
1519
|
+
orderedPluginSteps.push({
|
|
1520
|
+
name: "idle_timeout",
|
|
1521
|
+
stage: InitStage.Behavior,
|
|
1522
|
+
run: async () => {
|
|
1523
|
+
this.idleTimeout.start();
|
|
1524
|
+
},
|
|
1525
|
+
cleanup: async () => {
|
|
1526
|
+
this.idleTimeout.dispose();
|
|
1527
|
+
},
|
|
1528
|
+
});
|
|
1529
|
+
this.initSteps = orderedPluginSteps;
|
|
1530
|
+
}
|
|
1531
|
+
applyEndpointingOwnerInvariant() {
|
|
1532
|
+
if (this.activeInteractionPolicy === this.injectedInteractionPolicy) {
|
|
1533
|
+
for (const [name, plugin] of this.plugins) {
|
|
1534
|
+
const capability = plugin.endpointingCapability;
|
|
1535
|
+
if (!capability || capability.owner !== "provider_stt")
|
|
1536
|
+
continue;
|
|
1537
|
+
if (!capability.disableConfig) {
|
|
1538
|
+
throw new Error(`interactionPolicy requires ${name} to expose endpointingCapability.disableConfig`);
|
|
1539
|
+
}
|
|
1540
|
+
this.config.plugins[name] = {
|
|
1541
|
+
...(this.config.plugins[name] ?? {}),
|
|
1542
|
+
...capability.disableConfig,
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
if (this.endpointingOwner === "timer")
|
|
1548
|
+
return;
|
|
1549
|
+
const owner = this.endpointingOwner;
|
|
1550
|
+
const finalizers = [...this.plugins.entries()]
|
|
1551
|
+
.filter(([, plugin]) => plugin.endpointingCapability !== undefined);
|
|
1552
|
+
const enabledFinalizers = finalizers
|
|
1553
|
+
.filter(([, plugin]) => plugin.endpointingCapability?.owner === owner);
|
|
1554
|
+
if (finalizers.length > 0 && enabledFinalizers.length !== 1) {
|
|
1555
|
+
throw new Error(`endpointingOwner=${owner} requires exactly one registered ${owner} EOS finalizer; found ${String(enabledFinalizers.length)}`);
|
|
1556
|
+
}
|
|
1557
|
+
for (const [name, plugin] of finalizers) {
|
|
1558
|
+
if (plugin.endpointingCapability?.owner === owner)
|
|
1559
|
+
continue;
|
|
1560
|
+
const disabled = plugin.endpointingCapability?.disableConfig;
|
|
1561
|
+
if (!disabled)
|
|
1562
|
+
continue;
|
|
1563
|
+
this.config.plugins[name] = {
|
|
1564
|
+
...(this.config.plugins[name] ?? {}),
|
|
1565
|
+
...disabled,
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
shouldInitializePlugin(plugin) {
|
|
1570
|
+
const capability = plugin.endpointingCapability;
|
|
1571
|
+
if (!capability)
|
|
1572
|
+
return true;
|
|
1573
|
+
if (this.activeInteractionPolicy === this.injectedInteractionPolicy) {
|
|
1574
|
+
return capability.owner === "provider_stt";
|
|
1575
|
+
}
|
|
1576
|
+
if (this.endpointingOwner === "timer")
|
|
1577
|
+
return false;
|
|
1578
|
+
if (capability.owner === "smart_turn")
|
|
1579
|
+
return capability.owner === this.endpointingOwner;
|
|
1580
|
+
return true;
|
|
1581
|
+
}
|
|
1582
|
+
// =========================================================================
|
|
1583
|
+
// Helpers
|
|
1584
|
+
// =========================================================================
|
|
1585
|
+
emitDebug(component, type, data) {
|
|
1586
|
+
this.debugPush({
|
|
1587
|
+
component,
|
|
1588
|
+
type,
|
|
1589
|
+
data,
|
|
1590
|
+
timestampMs: Date.now(),
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
latestActiveTtsContextId() {
|
|
1594
|
+
return this.ttsPlayout.latestActive();
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
//# sourceMappingURL=voice-agent-session.js.map
|