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