@kuralle-syrinx/core 3.1.0 → 4.0.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/package.json +1 -1
- package/src/conversation-event.ts +5 -1
- package/src/index.ts +14 -0
- package/src/packets.ts +49 -0
- package/src/pipeline-bus.test.ts +14 -0
- package/src/pipeline-bus.ts +5 -0
- package/src/reasoner-session-store.ts +37 -0
- package/src/tts-playout-clock.ts +14 -0
- package/src/turn-arbiter.ts +1 -1
- package/src/voice-agent-session.test.ts +274 -0
- package/src/voice-agent-session.ts +214 -9
- package/src/voice-text.test.ts +13 -0
- package/src/voice-text.ts +42 -2
package/package.json
CHANGED
|
@@ -49,7 +49,11 @@ export function createConversationEventStream(): [
|
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
const push = (event: ConversationEvent): void => {
|
|
52
|
-
|
|
52
|
+
// Only retain events when a reader is attached. A ReadableStream buffers
|
|
53
|
+
// enqueue() without bound; with no debug consumer (nothing in-tree reads this
|
|
54
|
+
// by default) every packet's event would be retained for the whole call and
|
|
55
|
+
// grow memory unbounded. `locked` is true once getReader() is called.
|
|
56
|
+
if (controller && stream.locked) {
|
|
53
57
|
try {
|
|
54
58
|
controller.enqueue(event);
|
|
55
59
|
} catch {
|
package/src/index.ts
CHANGED
|
@@ -63,6 +63,20 @@ export {
|
|
|
63
63
|
type ReasoningResumePacket,
|
|
64
64
|
} from "./packets.js";
|
|
65
65
|
|
|
66
|
+
// Pipeline packets — delegate (Responder-Thinker) observability
|
|
67
|
+
export {
|
|
68
|
+
type DelegateQueryPacket,
|
|
69
|
+
type DelegateResultPacket,
|
|
70
|
+
type DelegatePacket,
|
|
71
|
+
type RealtimeResumptionHandlePacket,
|
|
72
|
+
} from "./packets.js";
|
|
73
|
+
|
|
74
|
+
// Durable reasoner session state (G4)
|
|
75
|
+
export {
|
|
76
|
+
InMemoryReasonerSessionStore,
|
|
77
|
+
type ReasonerSessionStore,
|
|
78
|
+
} from "./reasoner-session-store.js";
|
|
79
|
+
|
|
66
80
|
// Pipeline packets — TTS
|
|
67
81
|
export {
|
|
68
82
|
type TextToSpeechTextPacket,
|
package/src/packets.ts
CHANGED
|
@@ -300,12 +300,58 @@ export interface ReasoningSuspendedPacket extends VoicePacket {
|
|
|
300
300
|
readonly payload: unknown;
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
+
// =============================================================================
|
|
304
|
+
// Delegate (Responder-Thinker) observability packets (Background route)
|
|
305
|
+
// =============================================================================
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The bridge handed a query to the Reasoner. Emitted the moment the delegate
|
|
309
|
+
* turn starts (RealtimeBridge `runDelegate`) or the cascade turn reaches the
|
|
310
|
+
* reasoner (ReasoningBridge), BEFORE the stream runs. Background route:
|
|
311
|
+
* droppable, never blocks the hot path (RFC bimodel-delegate-seam R4).
|
|
312
|
+
* `toolId`/`toolName` are present on realtime delegate turns (the front-model
|
|
313
|
+
* tool call that triggered the delegate); absent on cascade turns.
|
|
314
|
+
*/
|
|
315
|
+
export interface DelegateQueryPacket extends VoicePacket {
|
|
316
|
+
readonly kind: "delegate.query";
|
|
317
|
+
readonly query: string;
|
|
318
|
+
readonly toolId?: string;
|
|
319
|
+
readonly toolName?: string;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The Reasoner produced the turn's final answer. Self-contained: carries the
|
|
324
|
+
* `query` again so a consumer can log/persist the Q&A pair from this one packet
|
|
325
|
+
* without correlating against the earlier `delegate.query`. `durationMs` spans
|
|
326
|
+
* query → answer; `grounded` is true when the reasoner stream surfaced at least
|
|
327
|
+
* one `tool-result` part (e.g. a retrieval hit) while producing the answer.
|
|
328
|
+
*/
|
|
329
|
+
export interface DelegateResultPacket extends VoicePacket {
|
|
330
|
+
readonly kind: "delegate.result";
|
|
331
|
+
readonly query: string;
|
|
332
|
+
readonly answer: string;
|
|
333
|
+
readonly durationMs: number;
|
|
334
|
+
readonly grounded: boolean;
|
|
335
|
+
readonly toolId?: string;
|
|
336
|
+
readonly toolName?: string;
|
|
337
|
+
}
|
|
338
|
+
|
|
303
339
|
export interface ReasoningResumePacket extends VoicePacket {
|
|
304
340
|
readonly kind: "reasoning.resume";
|
|
305
341
|
readonly runId: string;
|
|
306
342
|
readonly data: unknown;
|
|
307
343
|
}
|
|
308
344
|
|
|
345
|
+
/**
|
|
346
|
+
* G4: a realtime provider with native session resume (Gemini Live) issued a new
|
|
347
|
+
* resumption handle. Background route; a durable host (e.g. cf-agents) persists
|
|
348
|
+
* the latest handle and passes it back on reconnect instead of replaying history.
|
|
349
|
+
*/
|
|
350
|
+
export interface RealtimeResumptionHandlePacket extends VoicePacket {
|
|
351
|
+
readonly kind: "realtime.resumption_handle";
|
|
352
|
+
readonly handle: string;
|
|
353
|
+
}
|
|
354
|
+
|
|
309
355
|
// =============================================================================
|
|
310
356
|
// Output Pipeline Packets (LLM text → TTS audio)
|
|
311
357
|
// =============================================================================
|
|
@@ -551,3 +597,6 @@ export type AnyErrorPacket =
|
|
|
551
597
|
|
|
552
598
|
/** Observability packets (Background route). */
|
|
553
599
|
export type ObservabilityPacket = ConversationMetricPacket | TurnBoundaryEventPacket;
|
|
600
|
+
|
|
601
|
+
/** Delegate (Responder-Thinker) lifecycle packets (Background route). */
|
|
602
|
+
export type DelegatePacket = DelegateQueryPacket | DelegateResultPacket;
|
package/src/pipeline-bus.test.ts
CHANGED
|
@@ -171,6 +171,20 @@ describe("PipelineBusImpl", () => {
|
|
|
171
171
|
packet: { kind: "critical.event", contextId: "critical-1" },
|
|
172
172
|
});
|
|
173
173
|
});
|
|
174
|
+
|
|
175
|
+
it("does not retain packets when no reader is attached (drop-on-unread)", async () => {
|
|
176
|
+
const bus = createBus();
|
|
177
|
+
// No getReader() → stream unlocked → nothing retained (else a call with no
|
|
178
|
+
// recorder would buffer every audio packet and OOM).
|
|
179
|
+
for (let i = 0; i < 1000; i++) bus.push(Route.Main, pkt("main.event", `n-${i}`));
|
|
180
|
+
|
|
181
|
+
// A reader that attaches later sees only packets pushed AFTER it attaches.
|
|
182
|
+
const reader = bus.allPackets.getReader();
|
|
183
|
+
bus.push(Route.Main, pkt("main.event", "after-reader"));
|
|
184
|
+
const next = await reader.read();
|
|
185
|
+
reader.releaseLock();
|
|
186
|
+
expect(next.value).toMatchObject({ packet: { contextId: "after-reader" } });
|
|
187
|
+
});
|
|
174
188
|
});
|
|
175
189
|
|
|
176
190
|
describe("error handling", () => {
|
package/src/pipeline-bus.ts
CHANGED
|
@@ -239,6 +239,11 @@ export class PipelineBusImpl implements PipelineBus {
|
|
|
239
239
|
private publishAllPackets(route: Route, packet: VoicePacket): void {
|
|
240
240
|
this.onPacket?.(route, packet);
|
|
241
241
|
if (!this.allPacketsController) return;
|
|
242
|
+
// Only retain packets when a reader is actually attached. A ReadableStream
|
|
243
|
+
// buffers enqueue() without bound until read, so with no consumer (the default
|
|
244
|
+
// deployment — recorder is optional) this would retain every audio buffer of
|
|
245
|
+
// the whole call and OOM. `locked` is true once getReader() is called.
|
|
246
|
+
if (!this.allPackets.locked) return;
|
|
242
247
|
try {
|
|
243
248
|
this.allPacketsController.enqueue({ route, packet });
|
|
244
249
|
} catch {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Syrinx Kernel v2 — durable reasoner session state (G4, RFC bimodel-delegate-seam)
|
|
4
|
+
//
|
|
5
|
+
// The bridge owns conversation history (reasoner.ts §4.5); this seam makes that
|
|
6
|
+
// history durable so a reasoner resumes with the same context after a host
|
|
7
|
+
// eviction/reconnect instead of restarting amnesiac. Concrete backends: the
|
|
8
|
+
// in-memory impl below (Node/tests), a DO-SQLite impl in @kuralle-syrinx/cf-agents.
|
|
9
|
+
|
|
10
|
+
import type { ReasonerMessage } from "./reasoner.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Durable per-session reasoner conversation history. `save` persists the full
|
|
14
|
+
* (already bounded) history snapshot — snapshot semantics, not append, because
|
|
15
|
+
* barge-in truncation rewrites earlier messages to the heard prefix (G25).
|
|
16
|
+
*/
|
|
17
|
+
export interface ReasonerSessionStore {
|
|
18
|
+
load(sessionId: string): Promise<readonly ReasonerMessage[]> | readonly ReasonerMessage[];
|
|
19
|
+
save(sessionId: string, messages: readonly ReasonerMessage[]): Promise<void> | void;
|
|
20
|
+
clear(sessionId: string): Promise<void> | void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class InMemoryReasonerSessionStore implements ReasonerSessionStore {
|
|
24
|
+
private readonly sessions = new Map<string, readonly ReasonerMessage[]>();
|
|
25
|
+
|
|
26
|
+
load(sessionId: string): readonly ReasonerMessage[] {
|
|
27
|
+
return this.sessions.get(sessionId) ?? [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
save(sessionId: string, messages: readonly ReasonerMessage[]): void {
|
|
31
|
+
this.sessions.set(sessionId, [...messages]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
clear(sessionId: string): void {
|
|
35
|
+
this.sessions.delete(sessionId);
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/tts-playout-clock.ts
CHANGED
|
@@ -89,6 +89,20 @@ export class TtsPlayoutClock {
|
|
|
89
89
|
return this.active.has(contextId);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/** All still-active contexts, insertion order (oldest first). */
|
|
93
|
+
activeContexts(): string[] {
|
|
94
|
+
return [...this.active];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Wall-clock estimate of when this context's audio finishes playing out, or
|
|
99
|
+
* undefined if unknown. Anchors the idle timer to real playout end rather than
|
|
100
|
+
* chunk-arrival (TTS streams faster than realtime).
|
|
101
|
+
*/
|
|
102
|
+
playoutEnd(contextId: string): number | undefined {
|
|
103
|
+
return this.playoutEndMs.get(contextId);
|
|
104
|
+
}
|
|
105
|
+
|
|
92
106
|
/** The most-recently-added still-active context (insertion order), or "" if none. */
|
|
93
107
|
latestActive(): string {
|
|
94
108
|
let latest = "";
|
package/src/turn-arbiter.ts
CHANGED
|
@@ -321,6 +321,198 @@ describe("VoiceAgentSession", () => {
|
|
|
321
321
|
await closeSession(session);
|
|
322
322
|
});
|
|
323
323
|
|
|
324
|
+
it("G2/WBS-1: surfaces delegate.query/delegate.result packets as delegate_query/delegate_result session events", async () => {
|
|
325
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
326
|
+
const queries: Array<{ turnId: string; query: string; toolName?: string }> = [];
|
|
327
|
+
const results: Array<{ turnId: string; query: string; answer: string; durationMs: number; grounded: boolean }> = [];
|
|
328
|
+
session.on("delegate_query", (event) => queries.push(event));
|
|
329
|
+
session.on("delegate_result", (event) => results.push(event));
|
|
330
|
+
await session.start();
|
|
331
|
+
|
|
332
|
+
session.bus.push(Route.Background, {
|
|
333
|
+
kind: "delegate.query",
|
|
334
|
+
contextId: "turn-1",
|
|
335
|
+
timestampMs: Date.now(),
|
|
336
|
+
query: "When is the deadline?",
|
|
337
|
+
toolId: "call_1",
|
|
338
|
+
toolName: "consult_knowledge",
|
|
339
|
+
});
|
|
340
|
+
session.bus.push(Route.Background, {
|
|
341
|
+
kind: "delegate.result",
|
|
342
|
+
contextId: "turn-1",
|
|
343
|
+
timestampMs: Date.now(),
|
|
344
|
+
query: "When is the deadline?",
|
|
345
|
+
answer: "March 31.",
|
|
346
|
+
durationMs: 42,
|
|
347
|
+
grounded: true,
|
|
348
|
+
toolId: "call_1",
|
|
349
|
+
toolName: "consult_knowledge",
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
353
|
+
|
|
354
|
+
expect(queries).toEqual([
|
|
355
|
+
{ tsMs: expect.any(Number), turnId: "turn-1", query: "When is the deadline?", toolId: "call_1", toolName: "consult_knowledge" },
|
|
356
|
+
]);
|
|
357
|
+
expect(results).toEqual([
|
|
358
|
+
{
|
|
359
|
+
tsMs: expect.any(Number),
|
|
360
|
+
turnId: "turn-1",
|
|
361
|
+
query: "When is the deadline?",
|
|
362
|
+
answer: "March 31.",
|
|
363
|
+
durationMs: 42,
|
|
364
|
+
grounded: true,
|
|
365
|
+
toolId: "call_1",
|
|
366
|
+
toolName: "consult_knowledge",
|
|
367
|
+
},
|
|
368
|
+
]);
|
|
369
|
+
|
|
370
|
+
await closeSession(session);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("G3/WBS-3: tool-call lifecycle cues fire started → delayed → complete", async () => {
|
|
374
|
+
const session = new VoiceAgentSession({ plugins: {}, delayCueAfterMs: 30 });
|
|
375
|
+
const cues: Array<{ phase: string; turnId: string; toolId: string; toolName: string; afterMs?: number }> = [];
|
|
376
|
+
session.on("tool_call_cue", (event) => cues.push(event));
|
|
377
|
+
await session.start();
|
|
378
|
+
|
|
379
|
+
session.bus.push(Route.Main, {
|
|
380
|
+
kind: "llm.tool_call",
|
|
381
|
+
contextId: "turn-1",
|
|
382
|
+
timestampMs: Date.now(),
|
|
383
|
+
toolId: "t1",
|
|
384
|
+
toolName: "consult_knowledge",
|
|
385
|
+
toolArgs: { query: "fees" },
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
await new Promise((resolve) => setTimeout(resolve, 70));
|
|
389
|
+
|
|
390
|
+
session.bus.push(Route.Main, {
|
|
391
|
+
kind: "llm.tool_result",
|
|
392
|
+
contextId: "turn-1",
|
|
393
|
+
timestampMs: Date.now(),
|
|
394
|
+
toolId: "t1",
|
|
395
|
+
toolName: "consult_knowledge",
|
|
396
|
+
result: "answer",
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
400
|
+
|
|
401
|
+
expect(cues.map((cue) => cue.phase)).toEqual(["started", "delayed", "complete"]);
|
|
402
|
+
expect(cues[0]).toMatchObject({ turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge" });
|
|
403
|
+
expect(cues[1]!.afterMs).toBe(30);
|
|
404
|
+
|
|
405
|
+
await closeSession(session);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("G3/WBS-3: no delayed cue when the result beats the timer; timer 0 disables it", async () => {
|
|
409
|
+
const session = new VoiceAgentSession({ plugins: {}, delayCueAfterMs: 5000 });
|
|
410
|
+
const cues: Array<{ phase: string }> = [];
|
|
411
|
+
session.on("tool_call_cue", (event) => cues.push(event));
|
|
412
|
+
await session.start();
|
|
413
|
+
|
|
414
|
+
session.bus.push(Route.Main, {
|
|
415
|
+
kind: "llm.tool_call",
|
|
416
|
+
contextId: "turn-1",
|
|
417
|
+
timestampMs: Date.now(),
|
|
418
|
+
toolId: "t1",
|
|
419
|
+
toolName: "consult_knowledge",
|
|
420
|
+
toolArgs: {},
|
|
421
|
+
});
|
|
422
|
+
session.bus.push(Route.Main, {
|
|
423
|
+
kind: "llm.tool_result",
|
|
424
|
+
contextId: "turn-1",
|
|
425
|
+
timestampMs: Date.now(),
|
|
426
|
+
toolId: "t1",
|
|
427
|
+
toolName: "consult_knowledge",
|
|
428
|
+
result: "fast answer",
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
432
|
+
expect(cues.map((cue) => cue.phase)).toEqual(["started", "complete"]);
|
|
433
|
+
|
|
434
|
+
await closeSession(session);
|
|
435
|
+
|
|
436
|
+
const disabled = new VoiceAgentSession({ plugins: {}, delayCueAfterMs: 0 });
|
|
437
|
+
const disabledCues: Array<{ phase: string }> = [];
|
|
438
|
+
disabled.on("tool_call_cue", (event) => disabledCues.push(event));
|
|
439
|
+
await disabled.start();
|
|
440
|
+
disabled.bus.push(Route.Main, {
|
|
441
|
+
kind: "llm.tool_call",
|
|
442
|
+
contextId: "turn-2",
|
|
443
|
+
timestampMs: Date.now(),
|
|
444
|
+
toolId: "t2",
|
|
445
|
+
toolName: "consult_knowledge",
|
|
446
|
+
toolArgs: {},
|
|
447
|
+
});
|
|
448
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
449
|
+
expect(disabledCues.map((cue) => cue.phase)).toEqual(["started"]);
|
|
450
|
+
await closeSession(disabled);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("G3/WBS-3: llm.error while a tool call is pending fires the failed cue", async () => {
|
|
454
|
+
const session = new VoiceAgentSession({ plugins: {}, delayCueAfterMs: 0 });
|
|
455
|
+
const cues: Array<{ phase: string; toolId: string }> = [];
|
|
456
|
+
session.on("tool_call_cue", (event) => cues.push(event));
|
|
457
|
+
await session.start();
|
|
458
|
+
|
|
459
|
+
session.bus.push(Route.Main, {
|
|
460
|
+
kind: "llm.tool_call",
|
|
461
|
+
contextId: "turn-1",
|
|
462
|
+
timestampMs: Date.now(),
|
|
463
|
+
toolId: "t1",
|
|
464
|
+
toolName: "consult_knowledge",
|
|
465
|
+
toolArgs: {},
|
|
466
|
+
});
|
|
467
|
+
await new Promise((resolve) => setTimeout(resolve, 10)); // let Main drain before the Critical error
|
|
468
|
+
session.bus.push(Route.Critical, {
|
|
469
|
+
kind: "llm.error",
|
|
470
|
+
contextId: "turn-1",
|
|
471
|
+
timestampMs: Date.now(),
|
|
472
|
+
component: "bridge",
|
|
473
|
+
category: ErrorCategory.NetworkTimeout,
|
|
474
|
+
cause: new Error("delegate failed"),
|
|
475
|
+
isRecoverable: true,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
479
|
+
expect(cues.map((cue) => cue.phase)).toEqual(["started", "failed"]);
|
|
480
|
+
expect(cues[1]!.toolId).toBe("t1");
|
|
481
|
+
|
|
482
|
+
await closeSession(session);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("G3/WBS-3 (R5): barge-in fails the pending cue and the interrupt path is unaffected", async () => {
|
|
486
|
+
const session = new VoiceAgentSession({ plugins: {}, delayCueAfterMs: 5000, minInterruptionMs: 0 });
|
|
487
|
+
const cues: Array<{ phase: string }> = [];
|
|
488
|
+
const interrupts: string[] = [];
|
|
489
|
+
session.on("tool_call_cue", (event) => cues.push(event));
|
|
490
|
+
session.bus.on("interrupt.tts", (pkt) => { interrupts.push((pkt as { contextId: string }).contextId); });
|
|
491
|
+
await session.start();
|
|
492
|
+
|
|
493
|
+
session.bus.push(Route.Main, {
|
|
494
|
+
kind: "llm.tool_call",
|
|
495
|
+
contextId: "turn-1",
|
|
496
|
+
timestampMs: Date.now(),
|
|
497
|
+
toolId: "t1",
|
|
498
|
+
toolName: "consult_knowledge",
|
|
499
|
+
toolArgs: {},
|
|
500
|
+
});
|
|
501
|
+
await new Promise((resolve) => setTimeout(resolve, 10)); // let Main drain before the Critical interrupt
|
|
502
|
+
session.bus.push(Route.Critical, {
|
|
503
|
+
kind: "interrupt.detected",
|
|
504
|
+
contextId: "turn-1",
|
|
505
|
+
timestampMs: Date.now(),
|
|
506
|
+
source: "client",
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
510
|
+
expect(cues.map((cue) => cue.phase)).toEqual(["started", "failed"]);
|
|
511
|
+
expect(interrupts).toContain("turn-1"); // barge-in cancel still flowed
|
|
512
|
+
|
|
513
|
+
await closeSession(session);
|
|
514
|
+
});
|
|
515
|
+
|
|
324
516
|
it("emits normalized debug events for bus packets", async () => {
|
|
325
517
|
const session = new VoiceAgentSession({ plugins: {} });
|
|
326
518
|
const reader = session.debugEvents.getReader();
|
|
@@ -2485,3 +2677,85 @@ describe("VoiceAgentSession — handler errors must not kill the call", () => {
|
|
|
2485
2677
|
await closeSession(session);
|
|
2486
2678
|
});
|
|
2487
2679
|
});
|
|
2680
|
+
|
|
2681
|
+
describe("VoiceAgentSession supersede + thinking-phase barge-in", () => {
|
|
2682
|
+
it("cancels a still-playing prior turn's TTS when a new turn completes (L1)", async () => {
|
|
2683
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
2684
|
+
const interruptTts: InterruptTtsPacket[] = [];
|
|
2685
|
+
await session.start();
|
|
2686
|
+
session.bus.on("interrupt.tts", (pkt) => { interruptTts.push(pkt as InterruptTtsPacket); });
|
|
2687
|
+
|
|
2688
|
+
// Turn 1 is generating + has streamed audio (still playing out).
|
|
2689
|
+
session.bus.push(Route.Main, {
|
|
2690
|
+
kind: "eos.turn_complete", contextId: "turn-1", timestampMs: Date.now(), text: "one", transcripts: [],
|
|
2691
|
+
} satisfies EndOfSpeechPacket);
|
|
2692
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
2693
|
+
session.bus.push(Route.Main, {
|
|
2694
|
+
kind: "tts.audio", contextId: "turn-1", timestampMs: Date.now(),
|
|
2695
|
+
audio: new Uint8Array(16000), sampleRateHz: 16000, // ~0.5s of audio still playing
|
|
2696
|
+
} satisfies TextToSpeechAudioPacket);
|
|
2697
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
2698
|
+
|
|
2699
|
+
// Turn 2 completes while turn 1 is still playing → turn 1 must be cancelled.
|
|
2700
|
+
session.bus.push(Route.Main, {
|
|
2701
|
+
kind: "eos.turn_complete", contextId: "turn-2", timestampMs: Date.now(), text: "two", transcripts: [],
|
|
2702
|
+
} satisfies EndOfSpeechPacket);
|
|
2703
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2704
|
+
|
|
2705
|
+
expect(interruptTts.some((p) => p.contextId === "turn-1")).toBe(true);
|
|
2706
|
+
expect(interruptTts.some((p) => p.contextId === "turn-2")).toBe(false);
|
|
2707
|
+
|
|
2708
|
+
await closeSession(session);
|
|
2709
|
+
});
|
|
2710
|
+
|
|
2711
|
+
it("honors a client interrupt during the reasoner TTFT gap, before any audio (B3)", async () => {
|
|
2712
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
2713
|
+
const interruptLlm: InterruptLlmPacket[] = [];
|
|
2714
|
+
await session.start();
|
|
2715
|
+
session.bus.on("interrupt.llm", (pkt) => { interruptLlm.push(pkt as InterruptLlmPacket); });
|
|
2716
|
+
|
|
2717
|
+
// Turn completes; generation is in-flight but NO tts.audio has played yet.
|
|
2718
|
+
session.bus.push(Route.Main, {
|
|
2719
|
+
kind: "eos.turn_complete", contextId: "turn-think", timestampMs: Date.now(), text: "q", transcripts: [],
|
|
2720
|
+
} satisfies EndOfSpeechPacket);
|
|
2721
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
2722
|
+
|
|
2723
|
+
session.requestClientInterrupt("turn-think");
|
|
2724
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2725
|
+
|
|
2726
|
+
expect(interruptLlm.some((p) => p.contextId === "turn-think")).toBe(true);
|
|
2727
|
+
|
|
2728
|
+
await closeSession(session);
|
|
2729
|
+
});
|
|
2730
|
+
|
|
2731
|
+
it("drops a backchannel during the assistant's turn: no cancel, no second response (B4)", async () => {
|
|
2732
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
2733
|
+
const interruptTts: InterruptTtsPacket[] = [];
|
|
2734
|
+
const userInputs: UserInputPacket[] = [];
|
|
2735
|
+
await session.start();
|
|
2736
|
+
session.bus.on("interrupt.tts", (pkt) => { interruptTts.push(pkt as InterruptTtsPacket); });
|
|
2737
|
+
session.bus.on("user.input", (pkt) => { userInputs.push(pkt as UserInputPacket); });
|
|
2738
|
+
|
|
2739
|
+
// Assistant is speaking turn-1 (audio actively playing out).
|
|
2740
|
+
session.bus.push(Route.Main, {
|
|
2741
|
+
kind: "eos.turn_complete", contextId: "turn-1", timestampMs: Date.now(), text: "here is the answer", transcripts: [],
|
|
2742
|
+
} satisfies EndOfSpeechPacket);
|
|
2743
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
2744
|
+
session.bus.push(Route.Main, {
|
|
2745
|
+
kind: "tts.audio", contextId: "turn-1", timestampMs: Date.now(), audio: new Uint8Array(16000), sampleRateHz: 16000,
|
|
2746
|
+
} satisfies TextToSpeechAudioPacket);
|
|
2747
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
2748
|
+
|
|
2749
|
+
// User says "uh-huh" (a rotated context) mid-answer — a backchannel, not a turn.
|
|
2750
|
+
session.bus.push(Route.Main, {
|
|
2751
|
+
kind: "eos.turn_complete", contextId: "turn-2", timestampMs: Date.now(), text: "uh-huh", transcripts: [],
|
|
2752
|
+
} satisfies EndOfSpeechPacket);
|
|
2753
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2754
|
+
|
|
2755
|
+
// The assistant's turn is NOT cancelled and the backchannel does NOT drive the LLM.
|
|
2756
|
+
expect(interruptTts.some((p) => p.contextId === "turn-1")).toBe(false);
|
|
2757
|
+
expect(userInputs.some((p) => p.contextId === "turn-2")).toBe(false);
|
|
2758
|
+
|
|
2759
|
+
await closeSession(session);
|
|
2760
|
+
});
|
|
2761
|
+
});
|
|
@@ -29,6 +29,8 @@ import type {
|
|
|
29
29
|
UserAudioReceivedPacket,
|
|
30
30
|
UserTextReceivedPacket,
|
|
31
31
|
InterruptionDetectedPacket,
|
|
32
|
+
DelegateQueryPacket,
|
|
33
|
+
DelegateResultPacket,
|
|
32
34
|
LlmDeltaPacket,
|
|
33
35
|
LlmResponseDonePacket,
|
|
34
36
|
LlmToolCallPacket,
|
|
@@ -61,7 +63,7 @@ import { LatencyFillerController } from "./latency-filler.js";
|
|
|
61
63
|
import { PrimarySpeakerGate } from "./primary-speaker-gate.js";
|
|
62
64
|
import { takeCompleteVoiceText, isCompleteVoiceText, appendVoiceText } from "./voice-text.js";
|
|
63
65
|
import { TtsPlayoutClock } from "./tts-playout-clock.js";
|
|
64
|
-
import { TurnArbiter } from "./turn-arbiter.js";
|
|
66
|
+
import { TurnArbiter, isBackchannel } from "./turn-arbiter.js";
|
|
65
67
|
import * as make from "./packet-factories.js";
|
|
66
68
|
import { pluginStage, stageOrder, isAudioStage } from "./init-stage-order.js";
|
|
67
69
|
import {
|
|
@@ -147,6 +149,14 @@ export interface VoiceAgentSessionConfig {
|
|
|
147
149
|
* (TTS/STT-failure fallback needs canned audio / a clarification prompt — out of scope.)
|
|
148
150
|
*/
|
|
149
151
|
errorFallbackText?: string;
|
|
152
|
+
/**
|
|
153
|
+
* G3 (RFC bimodel-delegate-seam): ms a tool call may stay pending before the
|
|
154
|
+
* `tool_call_cue` session event fires its time-triggered `"delayed"` phase — the
|
|
155
|
+
* "still working" cue clients render during a long reasoner wait (cf. Vapi's
|
|
156
|
+
* `request-response-delayed` + `timingMilliseconds`). 0 disables the delayed
|
|
157
|
+
* phase; started/complete/failed always fire. Default: 2000.
|
|
158
|
+
*/
|
|
159
|
+
delayCueAfterMs?: number;
|
|
150
160
|
/**
|
|
151
161
|
* Which component owns turn boundary (EOS) for this session. Defaults to
|
|
152
162
|
* provider STT ownership; Smart Turn sessions must opt in explicitly.
|
|
@@ -170,6 +180,16 @@ export interface VoiceAgentSessionEvents {
|
|
|
170
180
|
agent_text_delta: (event: { tsMs: number; turnId: string; delta: string }) => void;
|
|
171
181
|
agent_tool_call: (event: { tsMs: number; turnId: string; id: string; name: string; args: Record<string, unknown> }) => void;
|
|
172
182
|
agent_tool_result: (event: { tsMs: number; turnId: string; id: string; result: string; durationMs: number }) => void;
|
|
183
|
+
delegate_query: (event: { tsMs: number; turnId: string; query: string; toolId?: string; toolName?: string }) => void;
|
|
184
|
+
delegate_result: (event: { tsMs: number; turnId: string; query: string; answer: string; durationMs: number; grounded: boolean; toolId?: string; toolName?: string }) => void;
|
|
185
|
+
/**
|
|
186
|
+
* G3: typed preamble/filler lifecycle for a pending tool call (Vapi-shaped:
|
|
187
|
+
* started / delayed / complete / failed). `delayed` is time-triggered by
|
|
188
|
+
* `delayCueAfterMs` while the call is still pending; `failed` fires on an LLM/bridge
|
|
189
|
+
* error, a barge-in, or a superseding turn while pending (R5). Transports surface
|
|
190
|
+
* these as `tool_call_*` wire messages — the standard "thinking" cue.
|
|
191
|
+
*/
|
|
192
|
+
tool_call_cue: (event: { tsMs: number; turnId: string; phase: "started" | "delayed" | "complete" | "failed"; toolId: string; toolName: string; afterMs?: number }) => void;
|
|
173
193
|
agent_first_audio: (event: { tsMs: number; turnId: string }) => void;
|
|
174
194
|
agent_finished: (event: { tsMs: number; turnId: string } & Record<string, unknown>) => void;
|
|
175
195
|
error: (event: { tsMs: number; stage: string; category: string; message: string }) => void;
|
|
@@ -187,6 +207,11 @@ interface TtsTextBuffer {
|
|
|
187
207
|
/** Suffix marking a context created to speak an error fallback, so it never recurses. */
|
|
188
208
|
const FALLBACK_CONTEXT_SUFFIX = ":error-fallback";
|
|
189
209
|
|
|
210
|
+
/** Scheduler key for a pending tool call's G3 delayed-cue timer. */
|
|
211
|
+
function toolCueTimerKey(contextId: string, toolId: string): string {
|
|
212
|
+
return `tool_cue:${contextId}:${toolId}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
190
215
|
// =============================================================================
|
|
191
216
|
// Session Implementation
|
|
192
217
|
// =============================================================================
|
|
@@ -211,6 +236,10 @@ export class VoiceAgentSession {
|
|
|
211
236
|
private readonly scheduler: Scheduler;
|
|
212
237
|
private readonly ttsPlayout: TtsPlayoutClock;
|
|
213
238
|
private interruptedGenerationContextIds = new Set<string>();
|
|
239
|
+
// Turns whose generation is in-flight (eos.turn_complete emitted, not yet
|
|
240
|
+
// finished/interrupted). Lets a client "stop" during the reasoner TTFT gap —
|
|
241
|
+
// before any audio plays — still abort the turn (thinking-phase barge-in, B3).
|
|
242
|
+
private generatingContextIds = new Set<string>();
|
|
214
243
|
private ttsTextBuffers = new Map<string, TtsTextBuffer>();
|
|
215
244
|
private readonly minInterruptionMs: number;
|
|
216
245
|
private readonly primarySpeakerGate: PrimarySpeakerGate;
|
|
@@ -227,6 +256,9 @@ export class VoiceAgentSession {
|
|
|
227
256
|
private firstTtsAudioFired = new Set<string>();
|
|
228
257
|
private readonly errorFallbackText: string;
|
|
229
258
|
private fallbackInjectedContexts = new Set<string>();
|
|
259
|
+
// G3: pending tool calls per context (toolId → toolName) driving the tool_call_cue lifecycle.
|
|
260
|
+
private readonly delayCueAfterMs: number;
|
|
261
|
+
private pendingToolCues = new Map<string, Map<string, string>>();
|
|
230
262
|
private readonly endpointingOwner: "provider_stt" | "smart_turn" | "timer";
|
|
231
263
|
private lastFinalizedContextId = "";
|
|
232
264
|
|
|
@@ -241,6 +273,7 @@ export class VoiceAgentSession {
|
|
|
241
273
|
this.ttsPlayout = new TtsPlayoutClock(this.scheduler);
|
|
242
274
|
this.sttForceFinalizeTimeoutMs = config.sttForceFinalizeTimeoutMs ?? 7000;
|
|
243
275
|
this.minInterruptionMs = config.minInterruptionMs ?? 280;
|
|
276
|
+
this.delayCueAfterMs = config.delayCueAfterMs ?? 2000;
|
|
244
277
|
this.primarySpeakerGate = new PrimarySpeakerGate({
|
|
245
278
|
enabled: config.primarySpeakerBargeInEnabled !== false,
|
|
246
279
|
});
|
|
@@ -394,6 +427,10 @@ export class VoiceAgentSession {
|
|
|
394
427
|
this.ttsTextBuffers.clear();
|
|
395
428
|
this.interruptedGenerationContextIds.clear();
|
|
396
429
|
this.firstLlmDeltaReceived.clear();
|
|
430
|
+
for (const [contextId, pending] of this.pendingToolCues) {
|
|
431
|
+
for (const toolId of pending.keys()) this.scheduler.cancel(toolCueTimerKey(contextId, toolId));
|
|
432
|
+
}
|
|
433
|
+
this.pendingToolCues.clear();
|
|
397
434
|
|
|
398
435
|
// 2. Run finalize chain (reverse order)
|
|
399
436
|
await runFinalizeChain(this.initSteps);
|
|
@@ -414,7 +451,16 @@ export class VoiceAgentSession {
|
|
|
414
451
|
}
|
|
415
452
|
|
|
416
453
|
requestClientInterrupt(contextId: string): void {
|
|
417
|
-
|
|
454
|
+
// Playing out → the arbiter owns the barge-in (primary-speaker reset + metrics).
|
|
455
|
+
if (this.ttsPlayout.isActive(contextId)) {
|
|
456
|
+
this.turnArbiter.commitClientInterrupt(contextId);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
// Thinking-phase barge-in (B3): no audio yet, but a generation is in-flight —
|
|
460
|
+
// abort it so "stop" during the reasoner TTFT gap is honored, not dropped.
|
|
461
|
+
if (this.generatingContextIds.has(contextId)) {
|
|
462
|
+
this.bus.push(Route.Critical, make.interruptDetected(contextId, Date.now(), "client"));
|
|
463
|
+
}
|
|
418
464
|
}
|
|
419
465
|
|
|
420
466
|
// =========================================================================
|
|
@@ -486,6 +532,10 @@ export class VoiceAgentSession {
|
|
|
486
532
|
this.bus.on("llm.tool_call", this.handleLlmToolCall.bind(this));
|
|
487
533
|
this.bus.on("llm.tool_result", this.handleLlmToolResult.bind(this));
|
|
488
534
|
|
|
535
|
+
// Delegate (Responder-Thinker) observability — G2, RFC bimodel-delegate-seam
|
|
536
|
+
this.bus.on("delegate.query", this.handleDelegateQuery.bind(this));
|
|
537
|
+
this.bus.on("delegate.result", this.handleDelegateResult.bind(this));
|
|
538
|
+
|
|
489
539
|
// TTS
|
|
490
540
|
this.bus.on("tts.audio", this.handleTtsAudio.bind(this));
|
|
491
541
|
this.bus.on("tts.end", this.handleTtsEnd.bind(this));
|
|
@@ -699,6 +749,20 @@ export class VoiceAgentSession {
|
|
|
699
749
|
this.bus.push(Route.Background, make.metric(pkt.contextId, "eos.duplicate_dropped", "1"));
|
|
700
750
|
return;
|
|
701
751
|
}
|
|
752
|
+
|
|
753
|
+
// A backchannel ("uh-huh", "okay") uttered WHILE the assistant is still speaking
|
|
754
|
+
// is not a turn (B4). Dropping it here does two things: it does NOT cancel the
|
|
755
|
+
// assistant's in-flight answer (the supersede path below would otherwise kill it),
|
|
756
|
+
// and it does NOT spawn a second LLM response to the backchannel (the double-reply
|
|
757
|
+
// bug). A backchannel with no assistant currently speaking IS a real turn — only
|
|
758
|
+
// suppress when another context's TTS is actively playing. (English-only classifier
|
|
759
|
+
// today — locale-aware backchannels are a separate improvement.)
|
|
760
|
+
const otherTtsActive = this.ttsPlayout.activeContexts().some((c) => c !== pkt.contextId);
|
|
761
|
+
if (otherTtsActive && isBackchannel(pkt.text)) {
|
|
762
|
+
this.bus.push(Route.Background, make.metric(pkt.contextId, "turn.backchannel_dropped", pkt.text));
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
702
766
|
this.lastFinalizedContextId = pkt.contextId;
|
|
703
767
|
|
|
704
768
|
// Re-arm per-turn guard state for the next turn. Transports with a stable
|
|
@@ -711,6 +775,16 @@ export class VoiceAgentSession {
|
|
|
711
775
|
this.interruptedGenerationContextIds.delete(pkt.contextId);
|
|
712
776
|
this.fallbackInjectedContexts.delete(pkt.contextId);
|
|
713
777
|
|
|
778
|
+
// Supersede (L1): a new turn must cancel any still-active prior-turn TTS or
|
|
779
|
+
// generation. Without this, a false-EOS (early endpoint on a mid-sentence
|
|
780
|
+
// pause) starts turn N, the user resumes, turn N's already-emitted audio
|
|
781
|
+
// keeps synthesizing, and it plays over the user while turn N+1 is answered.
|
|
782
|
+
// The bridge supersedes the *LLM*; only the session can stop the *TTS*.
|
|
783
|
+
for (const activeCtx of this.ttsPlayout.activeContexts()) {
|
|
784
|
+
if (activeCtx !== pkt.contextId) this.cancelStaleGeneration(activeCtx, pkt.timestampMs);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
this.generatingContextIds.add(pkt.contextId);
|
|
714
788
|
this.currentTurnId = pkt.contextId;
|
|
715
789
|
this.idleTimeout.setContextId(pkt.contextId);
|
|
716
790
|
|
|
@@ -727,8 +801,11 @@ export class VoiceAgentSession {
|
|
|
727
801
|
timestampMs: pkt.timestampMs,
|
|
728
802
|
});
|
|
729
803
|
|
|
730
|
-
// Stop idle timeout while LLM is
|
|
731
|
-
|
|
804
|
+
// Stop idle timeout while the LLM processes. The user just spoke — that is
|
|
805
|
+
// genuine engagement, so reset the idle *escalation* count (P2): a user who
|
|
806
|
+
// answers the first "are you there?" must not be escalated straight to the
|
|
807
|
+
// disconnect prompt later in the call.
|
|
808
|
+
this.bus.push(Route.Main, make.stopIdleTimeout(pkt.contextId, Date.now(), true));
|
|
732
809
|
|
|
733
810
|
const fillerText = this.latencyFiller.start(pkt.contextId, pkt.text, pkt.timestampMs);
|
|
734
811
|
if (fillerText) {
|
|
@@ -785,6 +862,7 @@ export class VoiceAgentSession {
|
|
|
785
862
|
}
|
|
786
863
|
|
|
787
864
|
private handleLlmDone(pkt: LlmResponseDonePacket): void {
|
|
865
|
+
this.generatingContextIds.delete(pkt.contextId);
|
|
788
866
|
if (this.interruptedGenerationContextIds.has(pkt.contextId)) {
|
|
789
867
|
this.ttsTextBuffers.delete(pkt.contextId);
|
|
790
868
|
this.bus.push(Route.Background, make.metric(pkt.contextId, "llm.done_ignored_after_interrupt", "1"));
|
|
@@ -865,9 +943,101 @@ export class VoiceAgentSession {
|
|
|
865
943
|
},
|
|
866
944
|
timestampMs: pkt.timestampMs,
|
|
867
945
|
});
|
|
946
|
+
|
|
947
|
+
// G3: arm the typed preamble/filler lifecycle for this pending tool call.
|
|
948
|
+
const pending = this.pendingToolCues.get(pkt.contextId) ?? new Map<string, string>();
|
|
949
|
+
pending.set(pkt.toolId, pkt.toolName);
|
|
950
|
+
this.pendingToolCues.set(pkt.contextId, pending);
|
|
951
|
+
this.emitToolCallCue(pkt.contextId, "started", pkt.toolId, pkt.toolName);
|
|
952
|
+
if (this.delayCueAfterMs > 0) {
|
|
953
|
+
const afterMs = this.delayCueAfterMs;
|
|
954
|
+
this.scheduler.schedule(toolCueTimerKey(pkt.contextId, pkt.toolId), afterMs, () => {
|
|
955
|
+
if (!this.pendingToolCues.get(pkt.contextId)?.has(pkt.toolId)) return;
|
|
956
|
+
this.emitToolCallCue(pkt.contextId, "delayed", pkt.toolId, pkt.toolName, afterMs);
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
private emitToolCallCue(
|
|
962
|
+
contextId: string,
|
|
963
|
+
phase: "started" | "delayed" | "complete" | "failed",
|
|
964
|
+
toolId: string,
|
|
965
|
+
toolName: string,
|
|
966
|
+
afterMs?: number,
|
|
967
|
+
): void {
|
|
968
|
+
this.emit("tool_call_cue", {
|
|
969
|
+
tsMs: Date.now(),
|
|
970
|
+
turnId: contextId,
|
|
971
|
+
phase,
|
|
972
|
+
toolId,
|
|
973
|
+
toolName,
|
|
974
|
+
...(afterMs !== undefined ? { afterMs } : {}),
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** G3: resolve one pending tool call with a terminal cue phase. */
|
|
979
|
+
private resolveToolCue(contextId: string, toolId: string, phase: "complete" | "failed"): void {
|
|
980
|
+
const pending = this.pendingToolCues.get(contextId);
|
|
981
|
+
const toolName = pending?.get(toolId);
|
|
982
|
+
if (toolName === undefined) return;
|
|
983
|
+
pending!.delete(toolId);
|
|
984
|
+
if (pending!.size === 0) this.pendingToolCues.delete(contextId);
|
|
985
|
+
this.scheduler.cancel(toolCueTimerKey(contextId, toolId));
|
|
986
|
+
this.emitToolCallCue(contextId, phase, toolId, toolName);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** G3: fail every pending tool call for a context (error / barge-in / supersede). */
|
|
990
|
+
private failPendingToolCues(contextId: string): void {
|
|
991
|
+
const pending = this.pendingToolCues.get(contextId);
|
|
992
|
+
if (!pending) return;
|
|
993
|
+
for (const toolId of [...pending.keys()]) this.resolveToolCue(contextId, toolId, "failed");
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
private handleDelegateQuery(pkt: DelegateQueryPacket): void {
|
|
997
|
+
this.emit("delegate_query", {
|
|
998
|
+
tsMs: pkt.timestampMs,
|
|
999
|
+
turnId: pkt.contextId,
|
|
1000
|
+
query: pkt.query,
|
|
1001
|
+
toolId: pkt.toolId,
|
|
1002
|
+
toolName: pkt.toolName,
|
|
1003
|
+
});
|
|
1004
|
+
this.debugPush({
|
|
1005
|
+
component: "delegate",
|
|
1006
|
+
type: "query",
|
|
1007
|
+
data: {
|
|
1008
|
+
context_id: pkt.contextId,
|
|
1009
|
+
query: pkt.query,
|
|
1010
|
+
...(pkt.toolName ? { tool_name: pkt.toolName } : {}),
|
|
1011
|
+
},
|
|
1012
|
+
timestampMs: pkt.timestampMs,
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
private handleDelegateResult(pkt: DelegateResultPacket): void {
|
|
1017
|
+
this.emit("delegate_result", {
|
|
1018
|
+
tsMs: pkt.timestampMs,
|
|
1019
|
+
turnId: pkt.contextId,
|
|
1020
|
+
query: pkt.query,
|
|
1021
|
+
answer: pkt.answer,
|
|
1022
|
+
durationMs: pkt.durationMs,
|
|
1023
|
+
grounded: pkt.grounded,
|
|
1024
|
+
toolId: pkt.toolId,
|
|
1025
|
+
toolName: pkt.toolName,
|
|
1026
|
+
});
|
|
1027
|
+
this.debugPush({
|
|
1028
|
+
component: "delegate",
|
|
1029
|
+
type: "result",
|
|
1030
|
+
data: {
|
|
1031
|
+
context_id: pkt.contextId,
|
|
1032
|
+
duration_ms: String(pkt.durationMs),
|
|
1033
|
+
grounded: String(pkt.grounded),
|
|
1034
|
+
},
|
|
1035
|
+
timestampMs: pkt.timestampMs,
|
|
1036
|
+
});
|
|
868
1037
|
}
|
|
869
1038
|
|
|
870
1039
|
private handleLlmToolResult(pkt: LlmToolResultPacket): void {
|
|
1040
|
+
this.resolveToolCue(pkt.contextId, pkt.toolId, "complete");
|
|
871
1041
|
this.emit("agent_tool_result", {
|
|
872
1042
|
tsMs: pkt.timestampMs,
|
|
873
1043
|
turnId: pkt.contextId,
|
|
@@ -913,14 +1083,20 @@ export class VoiceAgentSession {
|
|
|
913
1083
|
// Audio just arrived — (re)arm the stall watchdog for this turn's TTS output.
|
|
914
1084
|
this.watchdogs.armTtsStallTimer(pkt.contextId);
|
|
915
1085
|
|
|
916
|
-
//
|
|
1086
|
+
// Mark active and advance this context's playout cursor by the chunk's
|
|
1087
|
+
// realtime duration.
|
|
917
1088
|
const sampleRateHz = requireTtsAudioSampleRate(pkt.sampleRateHz);
|
|
918
1089
|
const audioDurationMs = estimatePcm16Duration(pkt.audio, sampleRateHz);
|
|
919
|
-
|
|
1090
|
+
const now = Date.now();
|
|
1091
|
+
this.ttsPlayout.noteAudio(pkt.contextId, audioDurationMs, now);
|
|
920
1092
|
|
|
921
|
-
//
|
|
922
|
-
// realtime
|
|
923
|
-
|
|
1093
|
+
// Anchor the idle timer to when playout actually *ends* (P2), not to chunk
|
|
1094
|
+
// arrival. TTS streams faster than realtime, so extending by each chunk's
|
|
1095
|
+
// duration from arrival lets the timer fire mid-speech on a long answer.
|
|
1096
|
+
// playoutEnd() is cumulative across chunks, so this re-arms to durationMs
|
|
1097
|
+
// after the audio delivered so far finishes playing.
|
|
1098
|
+
const playoutEndMs = this.ttsPlayout.playoutEnd(pkt.contextId);
|
|
1099
|
+
this.idleTimeout.extend(playoutEndMs !== undefined ? Math.max(0, playoutEndMs - now) : audioDurationMs);
|
|
924
1100
|
|
|
925
1101
|
this.debugPush({
|
|
926
1102
|
component: "tts",
|
|
@@ -938,6 +1114,7 @@ export class VoiceAgentSession {
|
|
|
938
1114
|
private handleTtsEnd(pkt: TextToSpeechEndPacket): void {
|
|
939
1115
|
// Generation finished, but the streamed audio is still playing out. Keep the
|
|
940
1116
|
// context interruptible until its playout estimate elapses, then release it.
|
|
1117
|
+
this.generatingContextIds.delete(pkt.contextId);
|
|
941
1118
|
this.ttsPlayout.scheduleRelease(pkt.contextId, Date.now());
|
|
942
1119
|
this.watchdogs.clearTtsStallTimerFor(pkt.contextId);
|
|
943
1120
|
this.debugPush({
|
|
@@ -954,6 +1131,7 @@ export class VoiceAgentSession {
|
|
|
954
1131
|
|
|
955
1132
|
private handleInterruptDetected(pkt: InterruptionDetectedPacket): void {
|
|
956
1133
|
this.interruptedGenerationContextIds.add(pkt.contextId);
|
|
1134
|
+
this.failPendingToolCues(pkt.contextId); // G3: the aborted delegate's cue fails (R5)
|
|
957
1135
|
this.latencyFiller.cancel(pkt.contextId);
|
|
958
1136
|
this.firstLlmDeltaReceived.delete(pkt.contextId);
|
|
959
1137
|
this.ttsTextBuffers.delete(pkt.contextId);
|
|
@@ -990,6 +1168,28 @@ export class VoiceAgentSession {
|
|
|
990
1168
|
// segments into the next turn when a client reuses the same contextId
|
|
991
1169
|
// (the provider STT plugins listen for interrupt.stt; previously unfired).
|
|
992
1170
|
this.bus.push(Route.Critical, make.interruptStt(pkt.contextId, pkt.timestampMs));
|
|
1171
|
+
this.generatingContextIds.delete(pkt.contextId);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Cancel a stale prior-turn generation/playout when a new turn supersedes it
|
|
1176
|
+
* (L1). Mirrors the interrupt teardown but without the barge-in metrics — this
|
|
1177
|
+
* is a turn boundary, not a user interruption. Stops leftover TTS audio, aborts
|
|
1178
|
+
* the LLM, and drops late deltas/audio for the stale context.
|
|
1179
|
+
*/
|
|
1180
|
+
private cancelStaleGeneration(contextId: string, timestampMs: number): void {
|
|
1181
|
+
this.interruptedGenerationContextIds.add(contextId);
|
|
1182
|
+
this.failPendingToolCues(contextId); // G3: a superseded turn's pending cue fails
|
|
1183
|
+
this.generatingContextIds.delete(contextId);
|
|
1184
|
+
this.latencyFiller.cancel(contextId);
|
|
1185
|
+
this.firstLlmDeltaReceived.delete(contextId);
|
|
1186
|
+
this.ttsTextBuffers.delete(contextId);
|
|
1187
|
+
this.ttsPlayout.release(contextId);
|
|
1188
|
+
this.watchdogs.clearTtsStallTimerFor(contextId);
|
|
1189
|
+
this.bus.push(Route.Critical, make.recordAssistantTruncate(contextId, Date.now()));
|
|
1190
|
+
this.bus.push(Route.Critical, make.interruptTts(contextId, timestampMs));
|
|
1191
|
+
this.bus.push(Route.Critical, make.interruptLlm(contextId, timestampMs));
|
|
1192
|
+
this.bus.push(Route.Background, make.metric(contextId, "supersede.cancelled_stale_generation", "1"));
|
|
993
1193
|
}
|
|
994
1194
|
|
|
995
1195
|
private handleComponentError(pkt: VoiceErrorPacket): void {
|
|
@@ -1011,7 +1211,12 @@ export class VoiceAgentSession {
|
|
|
1011
1211
|
timestampMs: pkt.timestampMs,
|
|
1012
1212
|
});
|
|
1013
1213
|
|
|
1214
|
+
// G3: an LLM/bridge error while a tool call is pending means no result is coming.
|
|
1215
|
+
if (pkt.component === "llm" || pkt.component === "bridge") {
|
|
1216
|
+
this.failPendingToolCues(pkt.contextId);
|
|
1217
|
+
}
|
|
1014
1218
|
this.latencyFiller.clear(pkt.contextId);
|
|
1219
|
+
this.generatingContextIds.delete(pkt.contextId);
|
|
1015
1220
|
// The packet's own recoverability verdict is authoritative when present: the
|
|
1016
1221
|
// bus marks handler exceptions (pipeline.error) recoverable by design — one
|
|
1017
1222
|
// misbehaving handler must degrade the turn, not kill the whole call.
|
package/src/voice-text.test.ts
CHANGED
|
@@ -25,6 +25,19 @@ describe("isCompleteVoiceText", () => {
|
|
|
25
25
|
expect(isCompleteVoiceText("مرحبا؟")).toBe(true); // Arabic question mark
|
|
26
26
|
expect(isCompleteVoiceText("नमस्ते।")).toBe(true); // Devanagari danda
|
|
27
27
|
});
|
|
28
|
+
|
|
29
|
+
it("does not treat abbreviation or decimal dots as sentence ends", () => {
|
|
30
|
+
// These would otherwise be voiced with a falling intonation and split from
|
|
31
|
+
// their continuation ("Dr." | "Smith", "twelve." | "fifty").
|
|
32
|
+
expect(isCompleteVoiceText("Dr.")).toBe(false);
|
|
33
|
+
expect(isCompleteVoiceText("e.g.")).toBe(false);
|
|
34
|
+
expect(isCompleteVoiceText("The total is $12.")).toBe(false);
|
|
35
|
+
expect(isCompleteVoiceText("Meet at 3 p.m.")).toBe(false);
|
|
36
|
+
expect(isCompleteVoiceText("His name is J.")).toBe(false);
|
|
37
|
+
// But a real sentence end after an abbreviation earlier in the text is fine.
|
|
38
|
+
expect(isCompleteVoiceText("Dr. Smith will see you now.")).toBe(true);
|
|
39
|
+
expect(isCompleteVoiceText("The total is $12.50 today.")).toBe(true);
|
|
40
|
+
});
|
|
28
41
|
});
|
|
29
42
|
|
|
30
43
|
describe("takeCompleteVoiceText", () => {
|
package/src/voice-text.ts
CHANGED
|
@@ -33,12 +33,41 @@ export function takeCompleteVoiceText(text: string): { text: string; remaining:
|
|
|
33
33
|
return { text: emitted.trimEnd(), remaining };
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Common abbreviations whose trailing "." is not a sentence end. Lowercased,
|
|
37
|
+
// dots stripped, so "e.g." matches "eg". Kept small and English-centric — the
|
|
38
|
+
// turn-end flush handles anything that legitimately ends here.
|
|
39
|
+
const ABBREVIATIONS = new Set([
|
|
40
|
+
"mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st", "vs", "etc", "eg", "ie",
|
|
41
|
+
"am", "pm", "no", "vol", "inc", "ltd", "co", "gen", "gov", "sen", "rep",
|
|
42
|
+
"apt", "dept", "approx", "est", "min", "max",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A segment ending in "." is not necessarily a finished sentence: it may be a
|
|
47
|
+
* decimal point ("$12." before "50") or an abbreviation dot ("Dr." before a
|
|
48
|
+
* name, "e.g."). Voicing those as sentence ends produces "twelve." (falling
|
|
49
|
+
* intonation) … "fifty", or splits "Dr." from the name. Defer them — if nothing
|
|
50
|
+
* follows, the turn-end flush still speaks the tail.
|
|
51
|
+
*/
|
|
52
|
+
function isFalseTerminalDot(endsWithDot: string): boolean {
|
|
53
|
+
const beforeDot = endsWithDot.slice(0, -1);
|
|
54
|
+
if (/\d$/.test(beforeDot)) return true; // decimal / ordinal: "12." , "$3."
|
|
55
|
+
const word = beforeDot.match(/([A-Za-z][A-Za-z.]*)$/);
|
|
56
|
+
if (!word) return false;
|
|
57
|
+
const normalized = word[1]!.replace(/\./g, "").toLowerCase();
|
|
58
|
+
if (ABBREVIATIONS.has(normalized)) return true;
|
|
59
|
+
if (normalized.length === 1) return true; // single initial: "J."
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
36
63
|
export function isCompleteVoiceText(text: string): boolean {
|
|
37
64
|
const trimmed = text.trim();
|
|
38
65
|
for (let index = trimmed.length - 1; index >= 0; index -= 1) {
|
|
39
66
|
const char = trimmed[index]!;
|
|
40
67
|
if (isClosingPunctuation(char)) continue;
|
|
41
|
-
|
|
68
|
+
if (!isTerminalPunctuation(char)) return false;
|
|
69
|
+
if (char === "." && isFalseTerminalDot(trimmed.slice(0, index + 1))) return false;
|
|
70
|
+
return true;
|
|
42
71
|
}
|
|
43
72
|
return false;
|
|
44
73
|
}
|
|
@@ -68,8 +97,19 @@ function isTerminalPunctuation(char: string): boolean {
|
|
|
68
97
|
char === "॥";
|
|
69
98
|
}
|
|
70
99
|
|
|
100
|
+
// The ICU sentence segmenter is one of the more expensive Intl allocations;
|
|
101
|
+
// building one per LLM delta (50–200/turn) is avoidable CPU/GC churn on the
|
|
102
|
+
// token→TTS latency path. It is stateless, so cache one per process. `undefined`
|
|
103
|
+
// = not yet computed; `null` = unavailable (fall back to the regex splitter).
|
|
104
|
+
let cachedSegmenter: { segment(text: string): Iterable<SentenceSegment> } | null | undefined;
|
|
105
|
+
|
|
106
|
+
function getSentenceSegmenter(): { segment(text: string): Iterable<SentenceSegment> } | null {
|
|
107
|
+
if (cachedSegmenter === undefined) cachedSegmenter = createSentenceSegmenter();
|
|
108
|
+
return cachedSegmenter;
|
|
109
|
+
}
|
|
110
|
+
|
|
71
111
|
function segmentSentences(text: string): string[] {
|
|
72
|
-
const segmenter =
|
|
112
|
+
const segmenter = getSentenceSegmenter();
|
|
73
113
|
if (segmenter) {
|
|
74
114
|
return Array.from(segmenter.segment(text), (part) => part.segment);
|
|
75
115
|
}
|