@kuralle-syrinx/core 2.1.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.
Files changed (52) hide show
  1. package/README.md +41 -0
  2. package/package.json +22 -0
  3. package/src/audio/audio.test.ts +285 -0
  4. package/src/audio/index.ts +5 -0
  5. package/src/audio/mulaw.ts +42 -0
  6. package/src/audio/pcm.ts +43 -0
  7. package/src/audio/resample.ts +177 -0
  8. package/src/audio-envelope.test.ts +167 -0
  9. package/src/audio-envelope.ts +143 -0
  10. package/src/conversation-event.ts +62 -0
  11. package/src/error-handler.test.ts +56 -0
  12. package/src/error-handler.ts +149 -0
  13. package/src/idle-timeout.ts +210 -0
  14. package/src/index.ts +215 -0
  15. package/src/init-chain.ts +137 -0
  16. package/src/init-stage-order.ts +79 -0
  17. package/src/latency-filler-fixtures.ts +16 -0
  18. package/src/latency-filler.test.ts +62 -0
  19. package/src/latency-filler.ts +125 -0
  20. package/src/mode-switcher.ts +110 -0
  21. package/src/observability-observer.test.ts +245 -0
  22. package/src/observability-observer.ts +195 -0
  23. package/src/observability.test.ts +85 -0
  24. package/src/observability.ts +93 -0
  25. package/src/packet-factories.test.ts +34 -0
  26. package/src/packet-factories.ts +243 -0
  27. package/src/packets.ts +553 -0
  28. package/src/pipeline-bus.g10.test.ts +145 -0
  29. package/src/pipeline-bus.test.ts +197 -0
  30. package/src/pipeline-bus.ts +369 -0
  31. package/src/plugin-contract.ts +79 -0
  32. package/src/primary-speaker-fixtures.ts +45 -0
  33. package/src/primary-speaker-gate.test.ts +150 -0
  34. package/src/primary-speaker-gate.ts +186 -0
  35. package/src/provider-fallback.test.ts +87 -0
  36. package/src/provider-fallback.ts +88 -0
  37. package/src/reasoner.test.ts +69 -0
  38. package/src/reasoner.ts +54 -0
  39. package/src/retry.test.ts +83 -0
  40. package/src/retry.ts +106 -0
  41. package/src/scheduler.ts +28 -0
  42. package/src/tts-playout-clock.test.ts +125 -0
  43. package/src/tts-playout-clock.ts +116 -0
  44. package/src/turn-arbiter.characterization.test.ts +477 -0
  45. package/src/turn-arbiter.test.ts +567 -0
  46. package/src/turn-arbiter.ts +283 -0
  47. package/src/voice-agent-session-util.ts +240 -0
  48. package/src/voice-agent-session.test.ts +2487 -0
  49. package/src/voice-agent-session.ts +1175 -0
  50. package/src/voice-text.test.ts +81 -0
  51. package/src/voice-text.ts +102 -0
  52. package/tsconfig.json +21 -0
@@ -0,0 +1,150 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import {
5
+ PrimarySpeakerGate,
6
+ extractSpeakerFingerprint,
7
+ fingerprintSimilarity,
8
+ } from "./primary-speaker-gate.js";
9
+ import * as primarySpeakerGateModule from "./primary-speaker-gate.js";
10
+ import {
11
+ ASSISTANT_ECHO_TONE_HZ,
12
+ BYSTANDER_SPEAKER_TONE_HZ,
13
+ PRIMARY_SPEAKER_TONE_HZ,
14
+ mixPcm16,
15
+ synthesizeTonePcm16,
16
+ } from "./primary-speaker-fixtures.js";
17
+
18
+ describe("extractSpeakerFingerprint", () => {
19
+ it("separates distinct synthetic speakers by band shape", () => {
20
+ const primary = extractSpeakerFingerprint(
21
+ synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 40 }),
22
+ );
23
+ const bystander = extractSpeakerFingerprint(
24
+ synthesizeTonePcm16({ frequencyHz: BYSTANDER_SPEAKER_TONE_HZ, durationMs: 40 }),
25
+ );
26
+ expect(primary).not.toBeNull();
27
+ expect(bystander).not.toBeNull();
28
+ const self = fingerprintSimilarity(primary!, primary!);
29
+ const cross = fingerprintSimilarity(primary!, bystander!);
30
+ expect(self).toBeGreaterThan(0.95);
31
+ expect(cross).toBeLessThan(0.72);
32
+ });
33
+ });
34
+
35
+ describe("PrimarySpeakerGate", () => {
36
+ it("commits barge-in for primary-only sustained speech", () => {
37
+ const gate = new PrimarySpeakerGate();
38
+ const primary = synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 });
39
+ gate.enrollUserTurnChunk(primary);
40
+ gate.lockProfileFromFirstTurn();
41
+ gate.beginBargeInWindow();
42
+ for (let i = 0; i < 6; i += 1) {
43
+ gate.observeBargeInChunk(primary);
44
+ }
45
+ expect(gate.shouldCommitBargeIn()).toBe(true);
46
+ });
47
+
48
+ it("suppresses bystander sustained speech", () => {
49
+ const gate = new PrimarySpeakerGate();
50
+ gate.enrollUserTurnChunk(
51
+ synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 }),
52
+ );
53
+ gate.lockProfileFromFirstTurn();
54
+ gate.beginBargeInWindow();
55
+ const bystander = synthesizeTonePcm16({ frequencyHz: BYSTANDER_SPEAKER_TONE_HZ, durationMs: 32 });
56
+ for (let i = 0; i < 8; i += 1) {
57
+ gate.observeBargeInChunk(bystander);
58
+ }
59
+ expect(gate.shouldCommitBargeIn()).toBe(false);
60
+ });
61
+
62
+ it("suppresses assistant echo over primary profile", () => {
63
+ const gate = new PrimarySpeakerGate();
64
+ gate.enrollUserTurnChunk(
65
+ synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 }),
66
+ );
67
+ gate.lockProfileFromFirstTurn();
68
+ const echo = synthesizeTonePcm16({
69
+ frequencyHz: ASSISTANT_ECHO_TONE_HZ,
70
+ durationMs: 32,
71
+ amplitude: 0.2,
72
+ });
73
+ gate.observeAssistantPlayout(
74
+ synthesizeTonePcm16({ frequencyHz: ASSISTANT_ECHO_TONE_HZ, durationMs: 32 }),
75
+ );
76
+ gate.beginBargeInWindow();
77
+ for (let i = 0; i < 8; i += 1) {
78
+ gate.observeBargeInChunk(echo);
79
+ }
80
+ expect(gate.shouldCommitBargeIn()).toBe(false);
81
+ });
82
+
83
+ it("falls back to permissive commit when no profile is locked", () => {
84
+ const gate = new PrimarySpeakerGate();
85
+ gate.beginBargeInWindow();
86
+ gate.observeBargeInChunk(
87
+ synthesizeTonePcm16({ frequencyHz: BYSTANDER_SPEAKER_TONE_HZ, durationMs: 32 }),
88
+ );
89
+ expect(gate.hasProfile()).toBe(false);
90
+ expect(gate.shouldCommitBargeIn()).toBe(true);
91
+ });
92
+
93
+ it("commits barge-in when primary similarity beats echo even above threshold", () => {
94
+ const gate = new PrimarySpeakerGate({ similarityThreshold: 0.72, echoDominanceMargin: 0.12 });
95
+ const primary = synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 });
96
+ gate.enrollUserTurnChunk(primary);
97
+ gate.lockProfileFromFirstTurn();
98
+ gate.observeAssistantPlayout(
99
+ synthesizeTonePcm16({ frequencyHz: ASSISTANT_ECHO_TONE_HZ, durationMs: 32 }),
100
+ );
101
+
102
+ const profile = (gate as unknown as { profile: NonNullable<ReturnType<typeof extractSpeakerFingerprint>> }).profile;
103
+ const assistantProfile = (gate as unknown as {
104
+ assistantProfile: NonNullable<ReturnType<typeof extractSpeakerFingerprint>>;
105
+ }).assistantProfile;
106
+
107
+ vi.spyOn(primarySpeakerGateModule, "fingerprintSimilarity").mockImplementation((_frame, reference) => {
108
+ if (reference === profile) return 0.95;
109
+ if (reference === assistantProfile) return 0.73;
110
+ return 0;
111
+ });
112
+
113
+ gate.beginBargeInWindow();
114
+ for (let i = 0; i < 6; i += 1) {
115
+ gate.observeBargeInChunk(primary);
116
+ }
117
+ expect(gate.shouldCommitBargeIn()).toBe(true);
118
+ vi.restoreAllMocks();
119
+ });
120
+
121
+ it("allows mixed audio when primary dominates", () => {
122
+ const gate = new PrimarySpeakerGate();
123
+ const primary = synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 });
124
+ gate.enrollUserTurnChunk(primary);
125
+ gate.lockProfileFromFirstTurn();
126
+ const mixed = mixPcm16(
127
+ [
128
+ synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 }),
129
+ synthesizeTonePcm16({ frequencyHz: BYSTANDER_SPEAKER_TONE_HZ, durationMs: 32, amplitude: 0.08 }),
130
+ ],
131
+ [1, 1],
132
+ );
133
+ gate.beginBargeInWindow();
134
+ for (let i = 0; i < 6; i += 1) {
135
+ gate.observeBargeInChunk(mixed);
136
+ }
137
+ expect(gate.shouldCommitBargeIn()).toBe(true);
138
+ });
139
+
140
+ it("mixes PCM chunks whose byteOffset is odd", () => {
141
+ const primary = synthesizeTonePcm16({ frequencyHz: PRIMARY_SPEAKER_TONE_HZ, durationMs: 32 });
142
+ const backing = new Uint8Array(primary.byteLength + 1);
143
+ backing.set(primary, 1);
144
+ const oddOffsetPrimary = backing.subarray(1);
145
+ expect(oddOffsetPrimary.byteOffset % 2).toBe(1);
146
+
147
+ expect(() => mixPcm16([oddOffsetPrimary], [1])).not.toThrow();
148
+ expect(mixPcm16([oddOffsetPrimary], [1])).toEqual(primary);
149
+ });
150
+ });
@@ -0,0 +1,186 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // VE-02 / G23 — lightweight primary-speaker gate for barge-in (FireRedChat pVAD direction).
4
+ // Locks a spectral fingerprint from the first user turn; gates sustained barge-in on match.
5
+
6
+ import { pcm16BytesToSamples } from "./audio/pcm.js";
7
+
8
+ const SAMPLE_RATE_HZ = 16000;
9
+ const MIN_SAMPLES = 320;
10
+ const BAND_HZ = [150, 300, 600, 1200, 2400, 4800] as const;
11
+
12
+ export interface SpeakerFingerprint {
13
+ readonly bands: readonly number[];
14
+ readonly rms: number;
15
+ readonly zcr: number;
16
+ }
17
+
18
+ export interface PrimarySpeakerGateConfig {
19
+ readonly enabled?: boolean;
20
+ readonly similarityThreshold?: number;
21
+ readonly echoDominanceMargin?: number;
22
+ }
23
+
24
+ export class PrimarySpeakerGate {
25
+ private readonly enabled: boolean;
26
+ private readonly similarityThreshold: number;
27
+ private readonly echoDominanceMargin: number;
28
+ private profile: SpeakerFingerprint | null = null;
29
+ private enrollFrames: SpeakerFingerprint[] = [];
30
+ private bargeInFrames: SpeakerFingerprint[] = [];
31
+ private assistantProfile: SpeakerFingerprint | null = null;
32
+
33
+ constructor(config: PrimarySpeakerGateConfig = {}) {
34
+ this.enabled = config.enabled !== false;
35
+ this.similarityThreshold = config.similarityThreshold ?? 0.72;
36
+ this.echoDominanceMargin = config.echoDominanceMargin ?? 0.12;
37
+ }
38
+
39
+ isEnabled(): boolean {
40
+ return this.enabled;
41
+ }
42
+
43
+ hasProfile(): boolean {
44
+ return this.profile !== null;
45
+ }
46
+
47
+ enrollUserTurnChunk(pcm: Uint8Array): void {
48
+ if (!this.enabled || this.profile !== null) return;
49
+ const frame = extractSpeakerFingerprint(pcm);
50
+ if (frame) this.enrollFrames.push(frame);
51
+ }
52
+
53
+ lockProfileFromFirstTurn(): void {
54
+ if (!this.enabled || this.profile !== null || this.enrollFrames.length === 0) return;
55
+ this.profile = averageFingerprints(this.enrollFrames);
56
+ this.enrollFrames = [];
57
+ }
58
+
59
+ beginBargeInWindow(): void {
60
+ this.bargeInFrames = [];
61
+ }
62
+
63
+ observeBargeInChunk(pcm: Uint8Array): void {
64
+ if (!this.enabled) return;
65
+ const frame = extractSpeakerFingerprint(pcm);
66
+ if (frame) this.bargeInFrames.push(frame);
67
+ }
68
+
69
+ observeAssistantPlayout(pcm: Uint8Array): void {
70
+ if (!this.enabled) return;
71
+ const frame = extractSpeakerFingerprint(pcm);
72
+ if (!frame) return;
73
+ this.assistantProfile = frame;
74
+ }
75
+
76
+ shouldCommitBargeIn(): boolean {
77
+ if (!this.enabled || this.profile === null) return true;
78
+ if (this.bargeInFrames.length === 0) return false;
79
+
80
+ let primaryHits = 0;
81
+ for (const frame of this.bargeInFrames) {
82
+ const primarySim = fingerprintSimilarity(frame, this.profile);
83
+ if (this.assistantProfile !== null) {
84
+ const echoSim = fingerprintSimilarity(frame, this.assistantProfile);
85
+ if (echoSim >= primarySim + this.echoDominanceMargin) continue;
86
+ }
87
+ if (primarySim >= this.similarityThreshold) primaryHits += 1;
88
+ }
89
+
90
+ const required = Math.max(1, Math.ceil(this.bargeInFrames.length * 0.45));
91
+ return primaryHits >= required;
92
+ }
93
+
94
+ resetBargeInWindow(): void {
95
+ this.bargeInFrames = [];
96
+ }
97
+ }
98
+
99
+ export function extractSpeakerFingerprint(pcm: Uint8Array): SpeakerFingerprint | null {
100
+ if (pcm.byteLength < MIN_SAMPLES * 2) return null;
101
+ let samples: Int16Array;
102
+ try {
103
+ samples = pcm16BytesToSamples(pcm);
104
+ } catch {
105
+ return null;
106
+ }
107
+ if (samples.length < MIN_SAMPLES) return null;
108
+
109
+ const window = samples.length > 512 ? samples.subarray(0, 512) : samples;
110
+ const bands = BAND_HZ.map((hz) => goertzelMagnitude(window, hz, SAMPLE_RATE_HZ));
111
+ const maxBand = Math.max(...bands, 1e-9);
112
+ const normalizedBands = bands.map((b) => b / maxBand);
113
+
114
+ let sumSq = 0;
115
+ let crossings = 0;
116
+ let prev = window[0]! >= 0;
117
+ for (let i = 0; i < window.length; i += 1) {
118
+ const s = window[i]!;
119
+ sumSq += s * s;
120
+ const positive = s >= 0;
121
+ if (i > 0 && positive !== prev) crossings += 1;
122
+ prev = positive;
123
+ }
124
+ const rms = Math.sqrt(sumSq / window.length) / 32768;
125
+ const zcr = crossings / window.length;
126
+
127
+ return { bands: normalizedBands, rms, zcr };
128
+ }
129
+
130
+ export function fingerprintSimilarity(a: SpeakerFingerprint, b: SpeakerFingerprint): number {
131
+ let dot = 0;
132
+ let normA = 0;
133
+ let normB = 0;
134
+ for (let i = 0; i < a.bands.length; i += 1) {
135
+ const av = a.bands[i] ?? 0;
136
+ const bv = b.bands[i] ?? 0;
137
+ dot += av * bv;
138
+ normA += av * av;
139
+ normB += bv * bv;
140
+ }
141
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
142
+ if (denom <= 0) return 0;
143
+
144
+ const spectral = dot / denom;
145
+ const rmsDelta = Math.abs(a.rms - b.rms);
146
+ const zcrDelta = Math.abs(a.zcr - b.zcr);
147
+ const timbrePenalty = Math.min(1, rmsDelta * 4 + zcrDelta * 2);
148
+ return Math.max(0, spectral * (1 - timbrePenalty * 0.35));
149
+ }
150
+
151
+ function averageFingerprints(frames: SpeakerFingerprint[]): SpeakerFingerprint {
152
+ const bandCount = frames[0]!.bands.length;
153
+ const bands = new Array<number>(bandCount).fill(0);
154
+ let rms = 0;
155
+ let zcr = 0;
156
+ for (const frame of frames) {
157
+ rms += frame.rms;
158
+ zcr += frame.zcr;
159
+ for (let i = 0; i < bandCount; i += 1) {
160
+ bands[i] = (bands[i] ?? 0) + (frame.bands[i] ?? 0);
161
+ }
162
+ }
163
+ const n = frames.length;
164
+ return {
165
+ bands: bands.map((b) => b / n),
166
+ rms: rms / n,
167
+ zcr: zcr / n,
168
+ };
169
+ }
170
+
171
+ function goertzelMagnitude(samples: Int16Array, targetHz: number, sampleRateHz: number): number {
172
+ const omega = (2 * Math.PI * targetHz) / sampleRateHz;
173
+ const coeff = 2 * Math.cos(omega);
174
+ let s0 = 0;
175
+ let s1 = 0;
176
+ let s2 = 0;
177
+ for (let i = 0; i < samples.length; i += 1) {
178
+ const x = samples[i]! / 32768;
179
+ s0 = x + coeff * s1 - s2;
180
+ s2 = s1;
181
+ s1 = s0;
182
+ }
183
+ const real = s1 - s2 * Math.cos(omega);
184
+ const imag = s2 * Math.sin(omega);
185
+ return Math.sqrt(real * real + imag * imag);
186
+ }
@@ -0,0 +1,87 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { PipelineBusImpl } from "./pipeline-bus.js";
5
+ import { ProviderFallback, type FallbackProvider } from "./provider-fallback.js";
6
+ import type { ConversationMetricPacket } from "./packets.js";
7
+
8
+ function provider(
9
+ id: string,
10
+ send: FallbackProvider<string, string>["send"],
11
+ healthProbe: FallbackProvider<string, string>["healthProbe"] = async () => true,
12
+ ): FallbackProvider<string, string> {
13
+ return { id, send, healthProbe };
14
+ }
15
+
16
+ describe("ProviderFallback", () => {
17
+ it("falls through unavailable providers and emits availability metrics", async () => {
18
+ const bus = new PipelineBusImpl();
19
+ const started = bus.start();
20
+ const metrics: ConversationMetricPacket[] = [];
21
+ bus.on("metric.conversation", (pkt) => {
22
+ metrics.push(pkt as ConversationMetricPacket);
23
+ });
24
+ const first = provider("stt.primary", async () => {
25
+ throw new Error("primary down");
26
+ });
27
+ const second = provider("stt.backup", async (req) => `backup:${req}`);
28
+ const fallback = new ProviderFallback([first, second], {
29
+ bus,
30
+ contextId: "turn-1",
31
+ attemptTimeoutMs: 100,
32
+ recoveryProbeIntervalMs: 1000,
33
+ });
34
+
35
+ await expect(fallback.send("hello")).resolves.toBe("backup:hello");
36
+ await new Promise((resolve) => setTimeout(resolve, 10));
37
+
38
+ expect(metrics).toContainEqual(expect.objectContaining({
39
+ contextId: "turn-1",
40
+ name: "stt.primary.availability_changed",
41
+ value: "unavailable",
42
+ }));
43
+
44
+ fallback.close();
45
+ bus.stop();
46
+ await started;
47
+ });
48
+
49
+ it("runs background recovery probes and returns recovered providers to service", async () => {
50
+ const bus = new PipelineBusImpl();
51
+ const started = bus.start();
52
+ const metrics: ConversationMetricPacket[] = [];
53
+ bus.on("metric.conversation", (pkt) => {
54
+ metrics.push(pkt as ConversationMetricPacket);
55
+ });
56
+ let primaryHealthy = false;
57
+ const first = provider(
58
+ "tts.primary",
59
+ async () => {
60
+ if (!primaryHealthy) throw new Error("primary down");
61
+ return "primary";
62
+ },
63
+ async () => primaryHealthy,
64
+ );
65
+ const second = provider("tts.backup", async () => "backup");
66
+ const fallback = new ProviderFallback([first, second], {
67
+ bus,
68
+ contextId: "turn-2",
69
+ attemptTimeoutMs: 100,
70
+ recoveryProbeIntervalMs: 10,
71
+ });
72
+
73
+ await expect(fallback.send("hello")).resolves.toBe("backup");
74
+ primaryHealthy = true;
75
+ await new Promise((resolve) => setTimeout(resolve, 40));
76
+ await expect(fallback.send("hello")).resolves.toBe("primary");
77
+
78
+ expect(metrics).toContainEqual(expect.objectContaining({
79
+ name: "tts.primary.availability_changed",
80
+ value: "available",
81
+ }));
82
+
83
+ fallback.close();
84
+ bus.stop();
85
+ await started;
86
+ });
87
+ });
@@ -0,0 +1,88 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { Route, type PipelineBus } from "./pipeline-bus.js";
4
+ import * as make from "./packet-factories.js";
5
+ import { TimerScheduler, type Scheduler } from "./scheduler.js";
6
+
7
+ export interface FallbackProvider<TReq, TResp> {
8
+ readonly id: string;
9
+ send(req: TReq, signal: AbortSignal): Promise<TResp>;
10
+ healthProbe(signal: AbortSignal): Promise<boolean>;
11
+ }
12
+
13
+ export interface ProviderFallbackOptions {
14
+ readonly bus: PipelineBus;
15
+ readonly contextId: string;
16
+ readonly attemptTimeoutMs: number;
17
+ readonly recoveryProbeIntervalMs: number;
18
+ readonly scheduler?: Scheduler;
19
+ }
20
+
21
+ export class ProviderFallback<TReq, TResp> {
22
+ private readonly unavailable = new Set<string>();
23
+ private readonly recoveryTimers = new Set<string>();
24
+ private readonly scheduler: Scheduler;
25
+
26
+ constructor(
27
+ private readonly providers: readonly FallbackProvider<TReq, TResp>[],
28
+ private readonly opts: ProviderFallbackOptions,
29
+ ) {
30
+ this.scheduler = opts.scheduler ?? new TimerScheduler();
31
+ }
32
+
33
+ async send(req: TReq): Promise<TResp> {
34
+ let lastError: unknown = null;
35
+ for (const provider of this.providers) {
36
+ if (this.unavailable.has(provider.id)) continue;
37
+ try {
38
+ return await provider.send(req, AbortSignal.timeout(this.opts.attemptTimeoutMs));
39
+ } catch (err) {
40
+ lastError = err;
41
+ this.markUnavailable(provider);
42
+ }
43
+ }
44
+ throw lastError instanceof Error ? lastError : new Error("all fallback providers unavailable");
45
+ }
46
+
47
+ close(): void {
48
+ for (const providerId of this.recoveryTimers) this.scheduler.cancel(recoveryKey(providerId));
49
+ this.recoveryTimers.clear();
50
+ this.unavailable.clear();
51
+ }
52
+
53
+ private markUnavailable(provider: FallbackProvider<TReq, TResp>): void {
54
+ if (!this.unavailable.has(provider.id)) {
55
+ this.unavailable.add(provider.id);
56
+ this.metric(`${provider.id}.availability_changed`, "unavailable");
57
+ }
58
+ this.scheduleRecoveryProbe(provider);
59
+ }
60
+
61
+ private scheduleRecoveryProbe(provider: FallbackProvider<TReq, TResp>): void {
62
+ if (this.recoveryTimers.has(provider.id)) return;
63
+ const runProbe = async (): Promise<void> => {
64
+ this.recoveryTimers.delete(provider.id);
65
+ try {
66
+ if (await provider.healthProbe(AbortSignal.timeout(this.opts.attemptTimeoutMs))) {
67
+ this.unavailable.delete(provider.id);
68
+ this.metric(`${provider.id}.availability_changed`, "available");
69
+ return;
70
+ }
71
+ } catch {
72
+ // Probe failure keeps the provider unavailable and schedules the next probe.
73
+ }
74
+ this.recoveryTimers.add(provider.id);
75
+ this.scheduler.schedule(recoveryKey(provider.id), this.opts.recoveryProbeIntervalMs, () => void runProbe());
76
+ };
77
+ this.recoveryTimers.add(provider.id);
78
+ this.scheduler.schedule(recoveryKey(provider.id), this.opts.recoveryProbeIntervalMs, () => void runProbe());
79
+ }
80
+
81
+ private metric(name: string, value: string): void {
82
+ this.opts.bus.push(Route.Background, make.metric(this.opts.contextId, name, value));
83
+ }
84
+ }
85
+
86
+ function recoveryKey(providerId: string): string {
87
+ return `voice.provider_fallback.recovery:${providerId}`;
88
+ }
@@ -0,0 +1,69 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Compile-guard: pins the ReasoningPart union shape without runtime consumers.
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import type { Reasoner, ReasonerMessage, ReasonerTurn, ReasoningPart } from "./reasoner.js";
8
+
9
+ describe("Reasoner seam types", () => {
10
+ it("reasoner_union_compile_guard", async () => {
11
+ const userMsg = { role: "user", content: "hi" } satisfies ReasonerMessage;
12
+
13
+ const turn: ReasonerTurn = {
14
+ userText: "hi",
15
+ messages: [userMsg],
16
+ signal: new AbortController().signal,
17
+ };
18
+
19
+ const textDeltaPart = { type: "text-delta", text: "hello" } satisfies ReasoningPart;
20
+ const toolCallPart = {
21
+ type: "tool-call",
22
+ toolId: "tool-1",
23
+ toolName: "doThing",
24
+ args: { input: 123 },
25
+ } satisfies ReasoningPart;
26
+ const toolResultPart = {
27
+ type: "tool-result",
28
+ toolId: "tool-1",
29
+ toolName: "doThing",
30
+ result: "ok",
31
+ } satisfies ReasoningPart;
32
+ const suspendedPart = {
33
+ type: "suspended",
34
+ runId: "run-1",
35
+ toolId: "tool-1",
36
+ prompt: "Pause for human-in-the-loop.",
37
+ payload: { step: 3 },
38
+ } satisfies ReasoningPart;
39
+ const errorPart = {
40
+ type: "error",
41
+ cause: new Error("backend exploded"),
42
+ recoverable: true,
43
+ } satisfies ReasoningPart;
44
+ const finishPart = { type: "finish", reason: "stop", text: "done" } satisfies ReasoningPart;
45
+
46
+ const emptyStream = async function*(): AsyncIterable<ReasoningPart> {
47
+ // Intentionally empty: this is purely a type-level compile guard.
48
+ };
49
+
50
+ const reasoner: Reasoner = {
51
+ stream: (_turn: ReasonerTurn): AsyncIterable<ReasoningPart> => emptyStream(),
52
+ };
53
+
54
+ const collected: ReasoningPart[] = [];
55
+ for await (const part of reasoner.stream(turn)) {
56
+ collected.push(part);
57
+ }
58
+
59
+ expect(collected).toEqual([]);
60
+ expect(textDeltaPart).toBeDefined();
61
+ expect(toolCallPart).toBeDefined();
62
+ expect(toolResultPart).toBeDefined();
63
+ expect(suspendedPart).toBeDefined();
64
+ expect(errorPart).toBeDefined();
65
+ expect(finishPart).toBeDefined();
66
+ expect(reasoner).toBeDefined();
67
+ });
68
+ });
69
+
@@ -0,0 +1,54 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — Reasoner seam
4
+ //
5
+ // A reasoning backend reduced to one normalized pull-stream per turn. Frameworks
6
+ // (AI SDK ToolLoopAgent, Mastra Agent, raw streamText) become a Reasoner via
7
+ // adapters; the bridge drives the seam, not the framework. See RFC §4.2.
8
+
9
+ /** A reasoning backend reduced to one normalized pull-stream per turn. */
10
+ export interface Reasoner {
11
+ /**
12
+ * Drive one reasoning turn. The returned async-iterable IS the response.
13
+ * Cancellation (barge-in) is via `turn.signal` (abort) — the adapter forwards
14
+ * it into the backend stream and into tool execution.
15
+ *
16
+ * LATENCY INVARIANT (non-negotiable, see §7a): the adapter MUST yield every
17
+ * part the instant the backend produces it — NO buffering, NO awaiting the
18
+ * stream to completion, NO batching. The first `text-delta` must reach the
19
+ * caller as soon as the backend's first token lands. The seam adds at most one
20
+ * microtask + a synchronous object remap per part; it must add no I/O hop.
21
+ */
22
+ stream(turn: ReasonerTurn): AsyncIterable<ReasoningPart>;
23
+ }
24
+
25
+ export interface ReasonerTurn {
26
+ /** Finalized user transcript for this turn (from `eos.turn_complete`). */
27
+ readonly userText: string;
28
+ /** Full prior conversation context. The BRIDGE owns history (see §4.5). */
29
+ readonly messages: readonly ReasonerMessage[];
30
+ /** Barge-in / supersede cancellation. */
31
+ readonly signal: AbortSignal;
32
+ /** Present only when resuming a previously-suspended run (step 3). */
33
+ readonly resume?: { readonly runId: string; readonly data: unknown };
34
+ }
35
+
36
+ export interface ReasonerMessage {
37
+ readonly role: "system" | "user" | "assistant" | "tool";
38
+ readonly content: string;
39
+ readonly toolCallId?: string;
40
+ }
41
+
42
+ /** Normalized output — the union of what AI SDK + Mastra can produce, minus noise. */
43
+ export type ReasoningPart =
44
+ | { readonly type: "text-delta"; readonly text: string }
45
+ | { readonly type: "tool-call"; readonly toolId: string; readonly toolName: string; readonly args: Record<string, unknown> }
46
+ | { readonly type: "tool-result"; readonly toolId: string; readonly toolName: string; readonly result: string }
47
+ // Human-in-the-loop pause (step 3). ALWAYS the terminal part for the turn.
48
+ | { readonly type: "suspended"; readonly runId: string; readonly toolId?: string; readonly prompt?: string; readonly payload: unknown }
49
+ // (B1) Error/abort the backend surfaced. The bridge treats `error` like today's
50
+ // thrown TextStreamPart `error`/`tool-error`/`finish-step(error)`: it drives the
51
+ // retry/`llm.error` path. `recoverable` mirrors `categorizeLlmError`. ALWAYS terminal.
52
+ | { readonly type: "error"; readonly cause: Error; readonly recoverable: boolean }
53
+ | { readonly type: "finish"; readonly reason: "stop" | "tool" | "length"; readonly text: string };
54
+