@kuralle-syrinx/core 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +21 -2
- package/src/confidence-to-wait.test.ts +21 -0
- package/src/confidence-to-wait.ts +30 -0
- package/src/hedge-throwing-backend.test.ts +46 -0
- package/src/incremental-unit.ts +19 -0
- package/src/index.ts +40 -0
- package/src/interaction-coordinator.test.ts +425 -0
- package/src/interaction-coordinator.ts +240 -0
- package/src/interaction-policy.ts +104 -0
- package/src/iu-ledger.test.ts +276 -0
- package/src/iu-ledger.ts +110 -0
- package/src/packet-factories.test.ts +20 -1
- package/src/packet-factories.ts +27 -0
- package/src/packets.ts +24 -3
- package/src/policies/defer.test.ts +86 -0
- package/src/policies/defer.ts +15 -0
- package/src/policies/rule-based.test.ts +269 -0
- package/src/policies/rule-based.ts +140 -0
- package/src/reasoner-hedge.test.ts +361 -0
- package/src/reasoner-hedge.ts +216 -0
- package/src/reasoner-route.test.ts +248 -0
- package/src/reasoner-route.ts +113 -0
- package/src/route-throwing-spec.test.ts +44 -0
- package/src/turn-arbiter.ts +11 -2
- package/src/voice-agent-session.test.ts +439 -2
- package/src/voice-agent-session.ts +232 -22
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;
|
|
@@ -286,7 +296,7 @@ export interface LlmResponseDonePacket extends VoicePacket {
|
|
|
286
296
|
|
|
287
297
|
export interface LlmErrorPacket extends VoicePacket, VoiceErrorPacket {
|
|
288
298
|
readonly kind: "llm.error";
|
|
289
|
-
readonly component: "llm" | "bridge";
|
|
299
|
+
readonly component: "llm" | "bridge" | "iu_ledger";
|
|
290
300
|
}
|
|
291
301
|
|
|
292
302
|
export interface LlmToolCallPacket extends VoicePacket {
|
|
@@ -460,6 +470,16 @@ export interface RecordAssistantAudioTruncatePacket extends VoicePacket {
|
|
|
460
470
|
|
|
461
471
|
export type RecordAssistantAudioPacket = RecordAssistantAudioDataPacket | RecordAssistantAudioTruncatePacket;
|
|
462
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
|
+
|
|
463
483
|
// =============================================================================
|
|
464
484
|
// Behavior Packets
|
|
465
485
|
// =============================================================================
|
|
@@ -560,6 +580,7 @@ export type InputPacket =
|
|
|
560
580
|
| VadSpeechActivityPacket
|
|
561
581
|
| SpeechToTextAudioPacket
|
|
562
582
|
| SttInterimPacket
|
|
583
|
+
| SttPartialPacket
|
|
563
584
|
| SttResultPacket
|
|
564
585
|
| FinalizeSttPacket
|
|
565
586
|
| SttErrorPacket
|
|
@@ -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
|
+
});
|