@kuralle-syrinx/core 4.0.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +21 -2
- package/src/confidence-to-wait.test.ts +21 -0
- package/src/confidence-to-wait.ts +30 -0
- package/src/hedge-throwing-backend.test.ts +46 -0
- package/src/incremental-unit.ts +19 -0
- package/src/index.ts +41 -0
- package/src/interaction-coordinator.test.ts +425 -0
- package/src/interaction-coordinator.ts +240 -0
- package/src/interaction-policy.ts +104 -0
- package/src/iu-ledger.test.ts +276 -0
- package/src/iu-ledger.ts +110 -0
- package/src/packet-factories.test.ts +20 -1
- package/src/packet-factories.ts +27 -0
- package/src/packets.ts +35 -3
- package/src/policies/defer.test.ts +86 -0
- package/src/policies/defer.ts +15 -0
- package/src/policies/rule-based.test.ts +269 -0
- package/src/policies/rule-based.ts +140 -0
- package/src/reasoner-hedge.test.ts +361 -0
- package/src/reasoner-hedge.ts +216 -0
- package/src/reasoner-route.test.ts +248 -0
- package/src/reasoner-route.ts +113 -0
- package/src/route-throwing-spec.test.ts +44 -0
- package/src/turn-arbiter.ts +11 -2
- package/src/voice-agent-session.test.ts +557 -2
- package/src/voice-agent-session.ts +308 -24
package/src/iu-ledger.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Syrinx Kernel v2 — Incremental-Unit ledger (IU substrate, RFC incremental-unit-substrate)
|
|
4
|
+
|
|
5
|
+
import type { IncrementalUnit, IncrementalUnitId, IuState } from "./incremental-unit.js";
|
|
6
|
+
|
|
7
|
+
export interface IuLedger {
|
|
8
|
+
add(iu: IncrementalUnit): void;
|
|
9
|
+
commit(id: IncrementalUnitId, prefix?: { chars?: number; ms?: number }): void;
|
|
10
|
+
revoke(id: IncrementalUnitId): void;
|
|
11
|
+
get(id: IncrementalUnitId): IncrementalUnit | undefined;
|
|
12
|
+
latest(contextId: string, kind: IncrementalUnit["kind"]): IncrementalUnit | undefined;
|
|
13
|
+
clear(contextId: string): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type IuLedgerAnomaly =
|
|
17
|
+
| { readonly kind: "terminal_op"; readonly op: "commit" | "revoke"; readonly id: IncrementalUnitId; readonly state: IuState }
|
|
18
|
+
| { readonly kind: "unknown_iu"; readonly op: "commit" | "revoke"; readonly id: IncrementalUnitId };
|
|
19
|
+
|
|
20
|
+
export class InMemoryIuLedger implements IuLedger {
|
|
21
|
+
private readonly byCtx = new Map<string, Map<string, IncrementalUnit>>();
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
private readonly onEvent: (a: IuLedgerAnomaly) => void = () => {},
|
|
25
|
+
private readonly maxContexts: number = 256,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
add(iu: IncrementalUnit): void {
|
|
29
|
+
const ctx = iu.id.contextId;
|
|
30
|
+
if (!this.byCtx.has(ctx) && this.byCtx.size >= this.maxContexts) {
|
|
31
|
+
const oldest = this.byCtx.keys().next().value;
|
|
32
|
+
if (oldest !== undefined) this.byCtx.delete(oldest);
|
|
33
|
+
}
|
|
34
|
+
this.ctxMap(ctx).set(iu.id.iuId, iu);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
commit(id: IncrementalUnitId, prefix?: { chars?: number; ms?: number }): void {
|
|
38
|
+
const ctxMap = this.byCtx.get(id.contextId);
|
|
39
|
+
if (!ctxMap) {
|
|
40
|
+
this.onEvent({ kind: "unknown_iu", op: "commit", id });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const iu = ctxMap.get(id.iuId);
|
|
44
|
+
if (!iu) {
|
|
45
|
+
this.onEvent({ kind: "unknown_iu", op: "commit", id });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (iu.state !== "hypothesized") {
|
|
49
|
+
this.onEvent({ kind: "terminal_op", op: "commit", id, state: iu.state });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
iu.state = "committed";
|
|
53
|
+
if (prefix) {
|
|
54
|
+
iu.committedPrefix = prefix;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
revoke(id: IncrementalUnitId): void {
|
|
59
|
+
const ctxMap = this.byCtx.get(id.contextId);
|
|
60
|
+
if (!ctxMap) {
|
|
61
|
+
this.onEvent({ kind: "unknown_iu", op: "revoke", id });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const iu = ctxMap.get(id.iuId);
|
|
65
|
+
if (!iu) {
|
|
66
|
+
this.onEvent({ kind: "unknown_iu", op: "revoke", id });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (iu.state !== "hypothesized") {
|
|
70
|
+
this.onEvent({ kind: "terminal_op", op: "revoke", id, state: iu.state });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
iu.state = "revoked";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get(id: IncrementalUnitId): IncrementalUnit | undefined {
|
|
77
|
+
const ctxMap = this.byCtx.get(id.contextId);
|
|
78
|
+
if (!ctxMap) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
return ctxMap.get(id.iuId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
latest(contextId: string, kind: IncrementalUnit["kind"]): IncrementalUnit | undefined {
|
|
85
|
+
const ctxMap = this.byCtx.get(contextId);
|
|
86
|
+
if (!ctxMap) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
let found: IncrementalUnit | undefined;
|
|
90
|
+
for (const iu of ctxMap.values()) {
|
|
91
|
+
if (iu.kind === kind) {
|
|
92
|
+
found = iu;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return found;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
clear(contextId: string): void {
|
|
99
|
+
this.byCtx.delete(contextId);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private ctxMap(contextId: string): Map<string, IncrementalUnit> {
|
|
103
|
+
let map = this.byCtx.get(contextId);
|
|
104
|
+
if (!map) {
|
|
105
|
+
map = new Map();
|
|
106
|
+
this.byCtx.set(contextId, map);
|
|
107
|
+
}
|
|
108
|
+
return map;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -2,7 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
4
|
|
|
5
|
-
import { reasoningResume, reasoningSuspended } from "./packet-factories.js";
|
|
5
|
+
import { reasoningResume, reasoningSuspended, sttPartial } from "./packet-factories.js";
|
|
6
|
+
|
|
7
|
+
describe("sttPartial", () => {
|
|
8
|
+
it("returns a stt.partial packet with optional wordTimings", () => {
|
|
9
|
+
const timings = [{ word: "hello", startMs: 100, endMs: 250, confidence: 0.9 }];
|
|
10
|
+
expect(sttPartial("ctx-1", 1000, "hello world", timings)).toEqual({
|
|
11
|
+
kind: "stt.partial",
|
|
12
|
+
contextId: "ctx-1",
|
|
13
|
+
timestampMs: 1000,
|
|
14
|
+
text: "hello world",
|
|
15
|
+
wordTimings: timings,
|
|
16
|
+
});
|
|
17
|
+
expect(sttPartial("ctx-1", 1000, "hello")).toEqual({
|
|
18
|
+
kind: "stt.partial",
|
|
19
|
+
contextId: "ctx-1",
|
|
20
|
+
timestampMs: 1000,
|
|
21
|
+
text: "hello",
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
});
|
|
6
25
|
|
|
7
26
|
describe("reasoningSuspended", () => {
|
|
8
27
|
it("returns a reasoning.suspended packet with the expected shape", () => {
|
package/src/packet-factories.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// definition and call sites need no `as` assertion — illegal packet shapes are
|
|
8
8
|
// unrepresentable at construction instead of asserted-valid by a cast (CR-05).
|
|
9
9
|
|
|
10
|
+
import type { WordTiming } from "./interaction-policy.js";
|
|
10
11
|
import { ErrorCategory } from "./packets.js";
|
|
11
12
|
import type {
|
|
12
13
|
ConversationMetricPacket,
|
|
@@ -21,7 +22,9 @@ import type {
|
|
|
21
22
|
SpeechToTextAudioPacket,
|
|
22
23
|
EndOfSpeechAudioPacket,
|
|
23
24
|
EndOfSpeechPacket,
|
|
25
|
+
SttPartialPacket,
|
|
24
26
|
SttResultPacket,
|
|
27
|
+
FinalizeSttPacket,
|
|
25
28
|
UserInputPacket,
|
|
26
29
|
TextToSpeechTextPacket,
|
|
27
30
|
TextToSpeechDonePacket,
|
|
@@ -39,6 +42,7 @@ import type {
|
|
|
39
42
|
StartIdleTimeoutPacket,
|
|
40
43
|
StopIdleTimeoutPacket,
|
|
41
44
|
ModeSwitchRequestedPacket,
|
|
45
|
+
InteractionBackchannelPacket,
|
|
42
46
|
} from "./packets.js";
|
|
43
47
|
|
|
44
48
|
const DTMF_DIGITS = new Set<DtmfDigit>(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"]);
|
|
@@ -115,6 +119,17 @@ export function sttAudio(contextId: string, timestampMs: number, audio: Uint8Arr
|
|
|
115
119
|
return { kind: "stt.audio", contextId, timestampMs, audio };
|
|
116
120
|
}
|
|
117
121
|
|
|
122
|
+
export function sttPartial(
|
|
123
|
+
contextId: string,
|
|
124
|
+
timestampMs: number,
|
|
125
|
+
text: string,
|
|
126
|
+
wordTimings?: readonly WordTiming[],
|
|
127
|
+
): SttPartialPacket {
|
|
128
|
+
return wordTimings
|
|
129
|
+
? { kind: "stt.partial", contextId, timestampMs, text, wordTimings }
|
|
130
|
+
: { kind: "stt.partial", contextId, timestampMs, text };
|
|
131
|
+
}
|
|
132
|
+
|
|
118
133
|
export function eosAudio(contextId: string, timestampMs: number, audio: Uint8Array): EndOfSpeechAudioPacket {
|
|
119
134
|
return { kind: "eos.audio", contextId, timestampMs, audio };
|
|
120
135
|
}
|
|
@@ -128,6 +143,10 @@ export function eosTurnComplete(
|
|
|
128
143
|
return { kind: "eos.turn_complete", contextId, timestampMs, text, transcripts };
|
|
129
144
|
}
|
|
130
145
|
|
|
146
|
+
export function finalizeStt(contextId: string, timestampMs: number): FinalizeSttPacket {
|
|
147
|
+
return { kind: "stt.finalize", contextId, timestampMs };
|
|
148
|
+
}
|
|
149
|
+
|
|
131
150
|
export function userInput(
|
|
132
151
|
contextId: string,
|
|
133
152
|
timestampMs: number,
|
|
@@ -241,3 +260,11 @@ export function modeSwitchRequested(
|
|
|
241
260
|
): ModeSwitchRequestedPacket {
|
|
242
261
|
return { kind: "mode.switch_requested", contextId, timestampMs, mode };
|
|
243
262
|
}
|
|
263
|
+
|
|
264
|
+
export function interactionBackchannel(
|
|
265
|
+
contextId: string,
|
|
266
|
+
timestampMs: number,
|
|
267
|
+
cue: string,
|
|
268
|
+
): InteractionBackchannelPacket {
|
|
269
|
+
return { kind: "interaction.backchannel", contextId, timestampMs, cue };
|
|
270
|
+
}
|
package/src/packets.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// Errors: SttError, TtsError, LlmError
|
|
10
10
|
// Lifecycle: InitStepCompleted, InitFailed, InitCompleted
|
|
11
11
|
|
|
12
|
+
import type { WordTiming } from "./interaction-policy.js";
|
|
13
|
+
|
|
12
14
|
// =============================================================================
|
|
13
15
|
// Base Types
|
|
14
16
|
// =============================================================================
|
|
@@ -44,7 +46,7 @@ export enum ErrorCategory {
|
|
|
44
46
|
|
|
45
47
|
export interface VoiceErrorPacket extends VoicePacket {
|
|
46
48
|
/** Which component emitted the error. */
|
|
47
|
-
readonly component: "stt" | "tts" | "vad" | "eos" | "denoiser" | "llm" | "bridge" | "pipeline";
|
|
49
|
+
readonly component: "stt" | "tts" | "vad" | "eos" | "denoiser" | "llm" | "bridge" | "pipeline" | "iu_ledger";
|
|
48
50
|
/** Machine-readable error category. */
|
|
49
51
|
readonly category: ErrorCategory;
|
|
50
52
|
/** Original error. May contain provider-specific details. */
|
|
@@ -120,8 +122,10 @@ export interface AudioFormat {
|
|
|
120
122
|
|
|
121
123
|
export interface UserAudioReceivedPacket extends VoicePacket {
|
|
122
124
|
readonly kind: "user.audio_received";
|
|
123
|
-
/** Raw PCM audio (16-bit, mono
|
|
125
|
+
/** Raw PCM audio (16-bit, mono). Rate given by `sampleRateHz` (defaults to 16kHz). */
|
|
124
126
|
readonly audio: Uint8Array;
|
|
127
|
+
/** Sample rate of `audio` in Hz. Omitted means 16000 (the legacy default). */
|
|
128
|
+
readonly sampleRateHz?: number;
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
export interface UserTextReceivedPacket extends VoicePacket {
|
|
@@ -176,6 +180,12 @@ export interface SttInterimPacket extends VoicePacket {
|
|
|
176
180
|
readonly text: string;
|
|
177
181
|
}
|
|
178
182
|
|
|
183
|
+
export interface SttPartialPacket extends VoicePacket {
|
|
184
|
+
readonly kind: "stt.partial";
|
|
185
|
+
readonly text: string;
|
|
186
|
+
readonly wordTimings?: readonly WordTiming[];
|
|
187
|
+
}
|
|
188
|
+
|
|
179
189
|
export interface SttResultPacket extends VoicePacket {
|
|
180
190
|
readonly kind: "stt.result";
|
|
181
191
|
readonly text: string;
|
|
@@ -211,6 +221,16 @@ export interface InterimEndOfSpeechPacket extends VoicePacket {
|
|
|
211
221
|
readonly text: string;
|
|
212
222
|
}
|
|
213
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Retraction of a prior `eos.interim` for the same context: the endpoint model
|
|
226
|
+
* signalled a likely turn end, then the user kept speaking (Deepgram Flux
|
|
227
|
+
* `TurnResumed` semantics). Consumers doing speculative work off `eos.interim`
|
|
228
|
+
* must cancel it.
|
|
229
|
+
*/
|
|
230
|
+
export interface EndOfSpeechRetractedPacket extends VoicePacket {
|
|
231
|
+
readonly kind: "eos.retracted";
|
|
232
|
+
}
|
|
233
|
+
|
|
214
234
|
// =============================================================================
|
|
215
235
|
// User Input (processed — feeds LLM)
|
|
216
236
|
// =============================================================================
|
|
@@ -276,7 +296,7 @@ export interface LlmResponseDonePacket extends VoicePacket {
|
|
|
276
296
|
|
|
277
297
|
export interface LlmErrorPacket extends VoicePacket, VoiceErrorPacket {
|
|
278
298
|
readonly kind: "llm.error";
|
|
279
|
-
readonly component: "llm" | "bridge";
|
|
299
|
+
readonly component: "llm" | "bridge" | "iu_ledger";
|
|
280
300
|
}
|
|
281
301
|
|
|
282
302
|
export interface LlmToolCallPacket extends VoicePacket {
|
|
@@ -450,6 +470,16 @@ export interface RecordAssistantAudioTruncatePacket extends VoicePacket {
|
|
|
450
470
|
|
|
451
471
|
export type RecordAssistantAudioPacket = RecordAssistantAudioDataPacket | RecordAssistantAudioTruncatePacket;
|
|
452
472
|
|
|
473
|
+
// =============================================================================
|
|
474
|
+
// Interaction Policy Packets
|
|
475
|
+
// =============================================================================
|
|
476
|
+
|
|
477
|
+
/** Pre-cached backchannel cue id rendered by the outbound mixer (IP-C3). */
|
|
478
|
+
export interface InteractionBackchannelPacket extends VoicePacket {
|
|
479
|
+
readonly kind: "interaction.backchannel";
|
|
480
|
+
readonly cue: string;
|
|
481
|
+
}
|
|
482
|
+
|
|
453
483
|
// =============================================================================
|
|
454
484
|
// Behavior Packets
|
|
455
485
|
// =============================================================================
|
|
@@ -550,12 +580,14 @@ export type InputPacket =
|
|
|
550
580
|
| VadSpeechActivityPacket
|
|
551
581
|
| SpeechToTextAudioPacket
|
|
552
582
|
| SttInterimPacket
|
|
583
|
+
| SttPartialPacket
|
|
553
584
|
| SttResultPacket
|
|
554
585
|
| FinalizeSttPacket
|
|
555
586
|
| SttErrorPacket
|
|
556
587
|
| EndOfSpeechAudioPacket
|
|
557
588
|
| EndOfSpeechPacket
|
|
558
589
|
| InterimEndOfSpeechPacket
|
|
590
|
+
| EndOfSpeechRetractedPacket
|
|
559
591
|
| UserInputPacket;
|
|
560
592
|
|
|
561
593
|
/** All interruption packets (Critical route). */
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
import type { InteractionObservation, InteractionPolicy } from "../interaction-policy.js";
|
|
5
|
+
import { DeferInteractionPolicy } from "./defer.js";
|
|
6
|
+
|
|
7
|
+
const OBSERVATIONS: InteractionObservation[] = [
|
|
8
|
+
{
|
|
9
|
+
kind: "vad_speech_started",
|
|
10
|
+
contextId: "user",
|
|
11
|
+
timestampMs: 1000,
|
|
12
|
+
confidence: 0.99,
|
|
13
|
+
interruptedContextId: "assistant-turn",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
kind: "vad_speech_activity",
|
|
17
|
+
contextId: "user",
|
|
18
|
+
timestampMs: 1300,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
kind: "vad_speech_ended",
|
|
22
|
+
contextId: "user",
|
|
23
|
+
timestampMs: 2000,
|
|
24
|
+
hasActiveTts: true,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
kind: "vad_barge_in_audio",
|
|
28
|
+
contextId: "user",
|
|
29
|
+
timestampMs: 1500,
|
|
30
|
+
audio: new Uint8Array([1, 2, 3]),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
kind: "stt_partial",
|
|
34
|
+
contextId: "user",
|
|
35
|
+
timestampMs: 1600,
|
|
36
|
+
text: "hello",
|
|
37
|
+
confidence: 0.9,
|
|
38
|
+
interruptedContextId: "assistant-turn",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
kind: "stt_final",
|
|
42
|
+
contextId: "user",
|
|
43
|
+
timestampMs: 2000,
|
|
44
|
+
text: "hello there",
|
|
45
|
+
confidence: 0.95,
|
|
46
|
+
interruptedContextId: "assistant-turn",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
kind: "audio_frame",
|
|
50
|
+
contextId: "user",
|
|
51
|
+
timestampMs: 1700,
|
|
52
|
+
audio: new Int16Array([1, 2, 3]),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
kind: "playout_tick",
|
|
56
|
+
contextId: "assistant-turn",
|
|
57
|
+
timestampMs: 1800,
|
|
58
|
+
playedOutMs: 500,
|
|
59
|
+
ttsActive: true,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
kind: "delegate_state",
|
|
63
|
+
contextId: "turn-1",
|
|
64
|
+
timestampMs: 1900,
|
|
65
|
+
delegateInFlight: true,
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
describe("DeferInteractionPolicy", () => {
|
|
70
|
+
it("implements InteractionPolicy", () => {
|
|
71
|
+
const policy: InteractionPolicy = new DeferInteractionPolicy();
|
|
72
|
+
expect(policy).toBeInstanceOf(DeferInteractionPolicy);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("observe returns [] for every observation kind", () => {
|
|
76
|
+
const policy = new DeferInteractionPolicy();
|
|
77
|
+
for (const obs of OBSERVATIONS) {
|
|
78
|
+
expect(policy.observe(obs)).toEqual([]);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("reset is a no-op", () => {
|
|
83
|
+
const policy = new DeferInteractionPolicy();
|
|
84
|
+
expect(() => policy.reset("any-context")).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import type { InteractionDecision, InteractionObservation, InteractionPolicy } from "../interaction-policy.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The front owns full-duplex interaction (turn-taking, barge-in, backchannels). This policy takes no
|
|
7
|
+
* action — it observes only, so the coordinator drives nothing and the front's own decisions (surfaced
|
|
8
|
+
* by its adapter/bridge as interrupt.detected / eos.turn_complete) stand. RFC InteractionPolicy REQ-4.
|
|
9
|
+
*/
|
|
10
|
+
export class DeferInteractionPolicy implements InteractionPolicy {
|
|
11
|
+
observe(_obs: InteractionObservation): readonly InteractionDecision[] {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
reset(_contextId: string): void {}
|
|
15
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
import { PipelineBusImpl } from "../pipeline-bus.js";
|
|
5
|
+
import { RuleBasedInteractionPolicy } from "./rule-based.js";
|
|
6
|
+
import { PrimarySpeakerGate } from "../primary-speaker-gate.js";
|
|
7
|
+
import { TtsPlayoutClock } from "../tts-playout-clock.js";
|
|
8
|
+
|
|
9
|
+
async function createPolicy(minInterruptionMs = 280) {
|
|
10
|
+
const bus = new PipelineBusImpl();
|
|
11
|
+
void bus.start();
|
|
12
|
+
const ttsPlayout = new TtsPlayoutClock();
|
|
13
|
+
const policy = new RuleBasedInteractionPolicy({
|
|
14
|
+
bus,
|
|
15
|
+
primarySpeakerGate: new PrimarySpeakerGate(),
|
|
16
|
+
ttsPlayout,
|
|
17
|
+
minInterruptionMs,
|
|
18
|
+
});
|
|
19
|
+
return { bus, ttsPlayout, policy };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function metricNames(bus: PipelineBusImpl): string[] {
|
|
23
|
+
const names: string[] = [];
|
|
24
|
+
bus.on("metric.conversation", (pkt) => {
|
|
25
|
+
names.push((pkt as unknown as { name: string }).name);
|
|
26
|
+
});
|
|
27
|
+
return names;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function drainBus(): Promise<void> {
|
|
31
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("RuleBasedInteractionPolicy", () => {
|
|
35
|
+
it("parity vector: sustained speech commits interrupt at activity frame", async () => {
|
|
36
|
+
const { ttsPlayout, policy } = await createPolicy(280);
|
|
37
|
+
ttsPlayout.noteAudio("assistant-turn", 100, 1000);
|
|
38
|
+
|
|
39
|
+
const t0 = 2000;
|
|
40
|
+
expect(
|
|
41
|
+
policy.observe({
|
|
42
|
+
kind: "vad_speech_started",
|
|
43
|
+
contextId: "user",
|
|
44
|
+
timestampMs: t0,
|
|
45
|
+
confidence: 0.99,
|
|
46
|
+
interruptedContextId: "assistant-turn",
|
|
47
|
+
}),
|
|
48
|
+
).toEqual([]);
|
|
49
|
+
|
|
50
|
+
expect(
|
|
51
|
+
policy.observe({
|
|
52
|
+
kind: "vad_speech_activity",
|
|
53
|
+
contextId: "user",
|
|
54
|
+
timestampMs: t0 + 300,
|
|
55
|
+
}),
|
|
56
|
+
).toEqual([{ kind: "interrupt", interruptedContextId: "assistant-turn" }]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("suppresses short speech blip without interrupt decision", async () => {
|
|
60
|
+
const { bus, ttsPlayout, policy } = await createPolicy(280);
|
|
61
|
+
const metrics = metricNames(bus);
|
|
62
|
+
ttsPlayout.noteAudio("assistant-turn", 100, 1000);
|
|
63
|
+
|
|
64
|
+
const t0 = 3000;
|
|
65
|
+
policy.observe({
|
|
66
|
+
kind: "vad_speech_started",
|
|
67
|
+
contextId: "user",
|
|
68
|
+
timestampMs: t0,
|
|
69
|
+
confidence: 0.99,
|
|
70
|
+
interruptedContextId: "assistant-turn",
|
|
71
|
+
});
|
|
72
|
+
policy.observe({
|
|
73
|
+
kind: "vad_speech_activity",
|
|
74
|
+
contextId: "user",
|
|
75
|
+
timestampMs: t0 + 90,
|
|
76
|
+
});
|
|
77
|
+
const decisions = policy.observe({
|
|
78
|
+
kind: "vad_speech_ended",
|
|
79
|
+
contextId: "user",
|
|
80
|
+
timestampMs: t0 + 130,
|
|
81
|
+
hasActiveTts: true,
|
|
82
|
+
});
|
|
83
|
+
await drainBus();
|
|
84
|
+
|
|
85
|
+
expect(decisions).toEqual([]);
|
|
86
|
+
expect(metrics).toContain("interrupt.suppressed_short_speech");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("suppresses backchannel interim without interrupt decision", async () => {
|
|
90
|
+
const { bus, ttsPlayout, policy } = await createPolicy(280);
|
|
91
|
+
const metrics = metricNames(bus);
|
|
92
|
+
ttsPlayout.noteAudio("assistant-turn", 100, 1000);
|
|
93
|
+
|
|
94
|
+
policy.observe({
|
|
95
|
+
kind: "vad_speech_started",
|
|
96
|
+
contextId: "user",
|
|
97
|
+
timestampMs: 2000,
|
|
98
|
+
confidence: 0.99,
|
|
99
|
+
interruptedContextId: "assistant-turn",
|
|
100
|
+
});
|
|
101
|
+
policy.observe({
|
|
102
|
+
kind: "stt_partial",
|
|
103
|
+
contextId: "user",
|
|
104
|
+
timestampMs: 2050,
|
|
105
|
+
text: "uh huh",
|
|
106
|
+
});
|
|
107
|
+
const decisions = policy.observe({
|
|
108
|
+
kind: "vad_speech_activity",
|
|
109
|
+
contextId: "user",
|
|
110
|
+
timestampMs: 2300,
|
|
111
|
+
});
|
|
112
|
+
await drainBus();
|
|
113
|
+
|
|
114
|
+
expect(decisions).toEqual([]);
|
|
115
|
+
expect(metrics).toContain("interrupt.suppressed_backchannel");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("IP-C3: emits no backchannel on VAD/user-pause observations", async () => {
|
|
119
|
+
const { policy } = await createPolicy();
|
|
120
|
+
|
|
121
|
+
expect(
|
|
122
|
+
policy.observe({
|
|
123
|
+
kind: "vad_speech_ended",
|
|
124
|
+
contextId: "user",
|
|
125
|
+
timestampMs: 5000,
|
|
126
|
+
hasActiveTts: false,
|
|
127
|
+
}),
|
|
128
|
+
).toEqual([]);
|
|
129
|
+
expect(
|
|
130
|
+
policy.observe({
|
|
131
|
+
kind: "vad_speech_activity",
|
|
132
|
+
contextId: "user",
|
|
133
|
+
timestampMs: 5100,
|
|
134
|
+
}),
|
|
135
|
+
).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("IP-C3: emits exactly one cue on started → delayed and clears on complete", async () => {
|
|
139
|
+
const { policy } = await createPolicy();
|
|
140
|
+
|
|
141
|
+
policy.observe({
|
|
142
|
+
kind: "delegate_state",
|
|
143
|
+
contextId: "turn-1",
|
|
144
|
+
timestampMs: 1000,
|
|
145
|
+
toolCallPhase: "started",
|
|
146
|
+
});
|
|
147
|
+
expect(
|
|
148
|
+
policy.observe({
|
|
149
|
+
kind: "delegate_state",
|
|
150
|
+
contextId: "turn-1",
|
|
151
|
+
timestampMs: 3000,
|
|
152
|
+
toolCallPhase: "delayed",
|
|
153
|
+
}),
|
|
154
|
+
).toEqual([{ kind: "backchannel", cue: "mm_hmm" }]);
|
|
155
|
+
expect(
|
|
156
|
+
policy.observe({
|
|
157
|
+
kind: "delegate_state",
|
|
158
|
+
contextId: "turn-1",
|
|
159
|
+
timestampMs: 3200,
|
|
160
|
+
toolCallPhase: "delayed",
|
|
161
|
+
}),
|
|
162
|
+
).toEqual([]);
|
|
163
|
+
policy.observe({
|
|
164
|
+
kind: "delegate_state",
|
|
165
|
+
contextId: "turn-1",
|
|
166
|
+
timestampMs: 4000,
|
|
167
|
+
toolCallPhase: "complete",
|
|
168
|
+
});
|
|
169
|
+
policy.observe({
|
|
170
|
+
kind: "delegate_state",
|
|
171
|
+
contextId: "turn-1",
|
|
172
|
+
timestampMs: 5000,
|
|
173
|
+
toolCallPhase: "started",
|
|
174
|
+
});
|
|
175
|
+
expect(
|
|
176
|
+
policy.observe({
|
|
177
|
+
kind: "delegate_state",
|
|
178
|
+
contextId: "turn-1",
|
|
179
|
+
timestampMs: 7000,
|
|
180
|
+
toolCallPhase: "delayed",
|
|
181
|
+
}),
|
|
182
|
+
).toEqual([{ kind: "backchannel", cue: "mm_hmm" }]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("IP-C3: suppresses backchannel when TTS is active or the user is speaking", async () => {
|
|
186
|
+
const { ttsPlayout, policy } = await createPolicy();
|
|
187
|
+
ttsPlayout.noteAudio("assistant-turn", 500, 1000);
|
|
188
|
+
|
|
189
|
+
policy.observe({
|
|
190
|
+
kind: "delegate_state",
|
|
191
|
+
contextId: "turn-1",
|
|
192
|
+
timestampMs: 1000,
|
|
193
|
+
toolCallPhase: "started",
|
|
194
|
+
});
|
|
195
|
+
expect(
|
|
196
|
+
policy.observe({
|
|
197
|
+
kind: "delegate_state",
|
|
198
|
+
contextId: "turn-1",
|
|
199
|
+
timestampMs: 3000,
|
|
200
|
+
toolCallPhase: "delayed",
|
|
201
|
+
}),
|
|
202
|
+
).toEqual([]);
|
|
203
|
+
|
|
204
|
+
ttsPlayout.release("assistant-turn");
|
|
205
|
+
policy.observe({
|
|
206
|
+
kind: "delegate_state",
|
|
207
|
+
contextId: "turn-2",
|
|
208
|
+
timestampMs: 4000,
|
|
209
|
+
toolCallPhase: "started",
|
|
210
|
+
});
|
|
211
|
+
policy.observe({
|
|
212
|
+
kind: "vad_speech_started",
|
|
213
|
+
contextId: "user",
|
|
214
|
+
timestampMs: 4100,
|
|
215
|
+
confidence: 0.9,
|
|
216
|
+
interruptedContextId: "",
|
|
217
|
+
});
|
|
218
|
+
expect(
|
|
219
|
+
policy.observe({
|
|
220
|
+
kind: "delegate_state",
|
|
221
|
+
contextId: "turn-2",
|
|
222
|
+
timestampMs: 6000,
|
|
223
|
+
toolCallPhase: "delayed",
|
|
224
|
+
}),
|
|
225
|
+
).toEqual([]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("IP-C3: reset clears delegate-gap backchannel state", async () => {
|
|
229
|
+
const { policy } = await createPolicy();
|
|
230
|
+
|
|
231
|
+
policy.observe({
|
|
232
|
+
kind: "delegate_state",
|
|
233
|
+
contextId: "turn-1",
|
|
234
|
+
timestampMs: 1000,
|
|
235
|
+
toolCallPhase: "started",
|
|
236
|
+
});
|
|
237
|
+
policy.reset("turn-1");
|
|
238
|
+
expect(
|
|
239
|
+
policy.observe({
|
|
240
|
+
kind: "delegate_state",
|
|
241
|
+
contextId: "turn-1",
|
|
242
|
+
timestampMs: 3000,
|
|
243
|
+
toolCallPhase: "delayed",
|
|
244
|
+
}),
|
|
245
|
+
).toEqual([]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("reset clears pending state", async () => {
|
|
249
|
+
const { ttsPlayout, policy } = await createPolicy(280);
|
|
250
|
+
ttsPlayout.noteAudio("assistant-turn", 100, 1000);
|
|
251
|
+
|
|
252
|
+
const t0 = 4000;
|
|
253
|
+
policy.observe({
|
|
254
|
+
kind: "vad_speech_started",
|
|
255
|
+
contextId: "user",
|
|
256
|
+
timestampMs: t0,
|
|
257
|
+
confidence: 0.99,
|
|
258
|
+
interruptedContextId: "assistant-turn",
|
|
259
|
+
});
|
|
260
|
+
policy.reset("user");
|
|
261
|
+
|
|
262
|
+
const decisions = policy.observe({
|
|
263
|
+
kind: "vad_speech_activity",
|
|
264
|
+
contextId: "user",
|
|
265
|
+
timestampMs: t0 + 300,
|
|
266
|
+
});
|
|
267
|
+
expect(decisions).toEqual([]);
|
|
268
|
+
});
|
|
269
|
+
});
|