@kuralle-syrinx/aisdk 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/package.json +21 -3
- package/src/heard-prefix-commit.test.ts +291 -0
- package/src/index.test.ts +141 -0
- package/src/index.ts +202 -34
- package/src/speculative-on-ledger.test.ts +211 -0
- package/src/speculative-post-promotion.test.ts +86 -0
package/package.json
CHANGED
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/aisdk",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"
|
|
5
|
+
"description": "Vercel AI SDK bridge for Syrinx — drive any ai@6 model or agent as the voice pipeline's reasoner, with opt-in speculative generation",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"voice",
|
|
8
|
+
"voice-agent",
|
|
9
|
+
"speech",
|
|
10
|
+
"syrinx",
|
|
11
|
+
"ai-sdk",
|
|
12
|
+
"vercel-ai"
|
|
13
|
+
],
|
|
6
14
|
"license": "MIT",
|
|
15
|
+
"homepage": "https://github.com/kuralle/syrinx#readme",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/kuralle/syrinx.git",
|
|
19
|
+
"directory": "packages/aisdk"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/kuralle/syrinx/issues"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
7
25
|
"main": "./src/index.ts",
|
|
8
26
|
"types": "./src/index.ts",
|
|
9
27
|
"dependencies": {
|
|
10
28
|
"@ai-sdk/openai": "^3.0.67",
|
|
11
29
|
"ai": "^6.0.0",
|
|
12
30
|
"zod": "^4.1.8",
|
|
13
|
-
"@kuralle-syrinx/core": "4.
|
|
31
|
+
"@kuralle-syrinx/core": "4.2.0"
|
|
14
32
|
},
|
|
15
33
|
"devDependencies": {
|
|
16
34
|
"typescript": "^5.7.0",
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
PipelineBusImpl,
|
|
6
|
+
Route,
|
|
7
|
+
type EndOfSpeechPacket,
|
|
8
|
+
type InMemoryIuLedger,
|
|
9
|
+
type IncrementalUnitId,
|
|
10
|
+
type InterruptLlmPacket,
|
|
11
|
+
type TextToSpeechPlayoutProgressPacket,
|
|
12
|
+
type TextToSpeechTextPacket,
|
|
13
|
+
type TextToSpeechWordTimestampsPacket,
|
|
14
|
+
type TtsWordTimestamp,
|
|
15
|
+
} from "@kuralle-syrinx/core";
|
|
16
|
+
import type { FinishReason, TextStreamPart, ToolSet } from "ai";
|
|
17
|
+
import { fromStreamFactory } from "./from-ai-sdk.js";
|
|
18
|
+
import { ReasoningBridge } from "./index.js";
|
|
19
|
+
|
|
20
|
+
const ZERO_USAGE = {
|
|
21
|
+
inputTokens: 0,
|
|
22
|
+
inputTokenDetails: { noCacheTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
|
23
|
+
outputTokens: 0,
|
|
24
|
+
outputTokenDetails: { textTokens: 0, reasoningTokens: 0 },
|
|
25
|
+
totalTokens: 0,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type BridgeLedgerAccess = {
|
|
29
|
+
iuLedger: InMemoryIuLedger;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function bridgeLedger(plugin: ReasoningBridge): BridgeLedgerAccess {
|
|
33
|
+
return plugin as unknown as BridgeLedgerAccess;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assistantId(contextId: string, epoch = 1): IncrementalUnitId {
|
|
37
|
+
return { contextId, iuId: `${contextId}#assistant`, epoch };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function userTurnId(contextId: string, epoch = 1): IncrementalUnitId {
|
|
41
|
+
return { contextId, iuId: contextId, epoch };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("ReasoningBridge heard-prefix commit (S2-01)", () => {
|
|
45
|
+
it("commits assistant IU with word-boundary prefix on barge-in during playback", async () => {
|
|
46
|
+
const packets: Array<{ packet: unknown }> = [];
|
|
47
|
+
const plugin = new ReasoningBridge(
|
|
48
|
+
fromStreamFactory(async function* () {
|
|
49
|
+
yield textDelta("Hello world foo bar.");
|
|
50
|
+
yield finish("stop");
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
const bus = new PipelineBusImpl({ onPacket: (_route, packet) => packets.push({ packet }) });
|
|
54
|
+
const drain = bus.start();
|
|
55
|
+
await plugin.initialize(bus, baseConfig());
|
|
56
|
+
const ledger = bridgeLedger(plugin);
|
|
57
|
+
const ctx = "turn-word";
|
|
58
|
+
|
|
59
|
+
bus.push(Route.Main, turnComplete(ctx, "first question"));
|
|
60
|
+
await waitFor(() => hasPacket(packets, "llm.done", ctx));
|
|
61
|
+
|
|
62
|
+
bus.push(Route.Main, wordTimestamps(ctx, [
|
|
63
|
+
{ word: "Hello", startMs: 0, endMs: 200 },
|
|
64
|
+
{ word: "world", startMs: 220, endMs: 400 },
|
|
65
|
+
{ word: "foo", startMs: 420, endMs: 600 },
|
|
66
|
+
{ word: "bar.", startMs: 620, endMs: 800 },
|
|
67
|
+
]));
|
|
68
|
+
bus.push(Route.Main, playoutProgress(ctx, 450));
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
70
|
+
|
|
71
|
+
bus.push(Route.Critical, interruptLlm(ctx));
|
|
72
|
+
await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
|
|
73
|
+
|
|
74
|
+
const spoken = "Hello world";
|
|
75
|
+
const iu = ledger.iuLedger.get(assistantId(ctx))!;
|
|
76
|
+
expect(iu.state).toBe("committed");
|
|
77
|
+
expect(iu.committedPrefix?.chars).toBe(spoken.length);
|
|
78
|
+
expect(iu.committedPrefix?.ms).toBe(450);
|
|
79
|
+
expect(iu.committedPrefix?.chars).toBeLessThan("Hello world foo bar.".length);
|
|
80
|
+
|
|
81
|
+
bus.stop();
|
|
82
|
+
await drain;
|
|
83
|
+
await plugin.close();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("commits assistant IU with spokenByContext prefix when word timestamps are absent", async () => {
|
|
87
|
+
const packets: Array<{ packet: unknown }> = [];
|
|
88
|
+
const plugin = new ReasoningBridge(
|
|
89
|
+
fromStreamFactory(async function* () {
|
|
90
|
+
yield textDelta("Sentence one. Sentence two.");
|
|
91
|
+
yield finish("stop");
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
const bus = new PipelineBusImpl({ onPacket: (_route, packet) => packets.push({ packet }) });
|
|
95
|
+
const drain = bus.start();
|
|
96
|
+
await plugin.initialize(bus, baseConfig());
|
|
97
|
+
const ledger = bridgeLedger(plugin);
|
|
98
|
+
const ctx = "turn-fallback";
|
|
99
|
+
|
|
100
|
+
bus.push(Route.Main, turnComplete(ctx, "first question"));
|
|
101
|
+
await waitFor(() => hasPacket(packets, "llm.done", ctx));
|
|
102
|
+
|
|
103
|
+
bus.push(Route.Main, ttsText(ctx, "Sentence one."));
|
|
104
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
105
|
+
bus.push(Route.Critical, interruptLlm(ctx));
|
|
106
|
+
await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
|
|
107
|
+
|
|
108
|
+
const spoken = "Sentence one.";
|
|
109
|
+
const iu = ledger.iuLedger.get(assistantId(ctx))!;
|
|
110
|
+
expect(iu.state).toBe("committed");
|
|
111
|
+
expect(iu.committedPrefix?.chars).toBe(spoken.length);
|
|
112
|
+
|
|
113
|
+
bus.stop();
|
|
114
|
+
await drain;
|
|
115
|
+
await plugin.close();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("commits heard prefix on mid-stream interrupt without losing streamed packets", async () => {
|
|
119
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
120
|
+
const plugin = new ReasoningBridge(
|
|
121
|
+
fromStreamFactory(async function* ({ signal }) {
|
|
122
|
+
yield textDelta("Hello");
|
|
123
|
+
await new Promise<void>((resolve) => {
|
|
124
|
+
if (signal.aborted) {
|
|
125
|
+
resolve();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
129
|
+
});
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
133
|
+
const drain = bus.start();
|
|
134
|
+
await plugin.initialize(bus, baseConfig());
|
|
135
|
+
const ledger = bridgeLedger(plugin);
|
|
136
|
+
const ctx = "turn-mid";
|
|
137
|
+
|
|
138
|
+
bus.push(Route.Main, turnComplete(ctx, "first question"));
|
|
139
|
+
await waitFor(() =>
|
|
140
|
+
packets.some(
|
|
141
|
+
({ packet }) =>
|
|
142
|
+
(packet as { kind?: string }).kind === "llm.delta" &&
|
|
143
|
+
(packet as { text?: string }).text === "Hello",
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
bus.push(Route.Main, ttsText(ctx, "Hello"));
|
|
148
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
149
|
+
bus.push(Route.Critical, interruptLlm(ctx));
|
|
150
|
+
await waitFor(() => hasMetric(packets, "llm.history_truncated_to_spoken"));
|
|
151
|
+
|
|
152
|
+
const iu = ledger.iuLedger.get(assistantId(ctx))!;
|
|
153
|
+
expect(iu.state).toBe("committed");
|
|
154
|
+
expect(iu.committedPrefix?.chars).toBe("Hello".length);
|
|
155
|
+
|
|
156
|
+
expect(packets).toContainEqual({
|
|
157
|
+
route: Route.Main,
|
|
158
|
+
packet: expect.objectContaining({ kind: "llm.delta", contextId: ctx, text: "Hello" }),
|
|
159
|
+
});
|
|
160
|
+
expect(packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done")).toBe(false);
|
|
161
|
+
|
|
162
|
+
bus.stop();
|
|
163
|
+
await drain;
|
|
164
|
+
await plugin.close();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("commits assistant IU fully on clean completion without a truncated prefix", async () => {
|
|
168
|
+
const packets: Array<{ packet: unknown }> = [];
|
|
169
|
+
const plugin = new ReasoningBridge(
|
|
170
|
+
fromStreamFactory(async function* () {
|
|
171
|
+
yield textDelta("Clean answer.");
|
|
172
|
+
yield finish("stop");
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
const bus = new PipelineBusImpl({ onPacket: (_route, packet) => packets.push({ packet }) });
|
|
176
|
+
const drain = bus.start();
|
|
177
|
+
await plugin.initialize(bus, baseConfig());
|
|
178
|
+
const ledger = bridgeLedger(plugin);
|
|
179
|
+
const ctx = "turn-clean";
|
|
180
|
+
|
|
181
|
+
bus.push(Route.Main, turnComplete(ctx, "question"));
|
|
182
|
+
await waitFor(() => hasPacket(packets, "llm.done", ctx));
|
|
183
|
+
|
|
184
|
+
const iu = ledger.iuLedger.get(assistantId(ctx))!;
|
|
185
|
+
expect(iu.state).toBe("committed");
|
|
186
|
+
expect(iu.committedPrefix).toBeUndefined();
|
|
187
|
+
|
|
188
|
+
bus.stop();
|
|
189
|
+
await drain;
|
|
190
|
+
await plugin.close();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("keeps distinct assistant and user-turn IUs in the ledger for one contextId", async () => {
|
|
194
|
+
const plugin = new ReasoningBridge(
|
|
195
|
+
fromStreamFactory(async function* () {
|
|
196
|
+
yield textDelta("Answer.");
|
|
197
|
+
yield finish("stop");
|
|
198
|
+
}),
|
|
199
|
+
{ speculative: true },
|
|
200
|
+
);
|
|
201
|
+
const bus = new PipelineBusImpl({ onPacket: () => {} });
|
|
202
|
+
const drain = bus.start();
|
|
203
|
+
await plugin.initialize(bus, baseConfig());
|
|
204
|
+
const ledger = bridgeLedger(plugin);
|
|
205
|
+
const ctx = "turn-dual";
|
|
206
|
+
|
|
207
|
+
bus.push(Route.Main, eosInterim(ctx, "hello"));
|
|
208
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
209
|
+
|
|
210
|
+
const userIu = ledger.iuLedger.get(userTurnId(ctx));
|
|
211
|
+
const assistantIu = ledger.iuLedger.get(assistantId(ctx));
|
|
212
|
+
expect(userIu?.kind).toBe("user_turn");
|
|
213
|
+
expect(assistantIu?.kind).toBe("assistant_response");
|
|
214
|
+
expect(userIu?.id.iuId).toBe(ctx);
|
|
215
|
+
expect(assistantIu?.id.iuId).toBe(`${ctx}#assistant`);
|
|
216
|
+
expect(userIu?.id.epoch).toBe(assistantIu?.id.epoch);
|
|
217
|
+
|
|
218
|
+
bus.stop();
|
|
219
|
+
await drain;
|
|
220
|
+
await plugin.close();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
function baseConfig(): Record<string, unknown> {
|
|
225
|
+
return {
|
|
226
|
+
api_key: "test-key",
|
|
227
|
+
model: "gpt-test",
|
|
228
|
+
system_prompt: "test",
|
|
229
|
+
retry_max_attempts: 1,
|
|
230
|
+
timeout_ms: 1000,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function turnComplete(contextId: string, text: string): EndOfSpeechPacket {
|
|
235
|
+
return { kind: "eos.turn_complete", contextId, timestampMs: Date.now(), text, transcripts: [] };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function eosInterim(contextId: string, text: string): { kind: "eos.interim"; contextId: string; timestampMs: number; text: string } {
|
|
239
|
+
return { kind: "eos.interim", contextId, timestampMs: Date.now(), text };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function ttsText(contextId: string, text: string): TextToSpeechTextPacket {
|
|
243
|
+
return { kind: "tts.text", contextId, timestampMs: Date.now(), text };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function wordTimestamps(contextId: string, words: TtsWordTimestamp[]): TextToSpeechWordTimestampsPacket {
|
|
247
|
+
return { kind: "tts.word_timestamps", contextId, timestampMs: Date.now(), words };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function playoutProgress(contextId: string, playedOutMs: number): TextToSpeechPlayoutProgressPacket {
|
|
251
|
+
return { kind: "tts.playout_progress", contextId, timestampMs: Date.now(), playedOutMs, complete: false };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function interruptLlm(contextId: string): InterruptLlmPacket {
|
|
255
|
+
return { kind: "interrupt.llm", contextId, timestampMs: Date.now() };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function textDelta(text: string): TextStreamPart<ToolSet> {
|
|
259
|
+
return { type: "text-delta", id: "0", text, providerMetadata: undefined } as TextStreamPart<ToolSet>;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function finish(finishReason: FinishReason): TextStreamPart<ToolSet> {
|
|
263
|
+
return {
|
|
264
|
+
type: "finish",
|
|
265
|
+
finishReason,
|
|
266
|
+
totalUsage: ZERO_USAGE,
|
|
267
|
+
usage: ZERO_USAGE,
|
|
268
|
+
providerMetadata: undefined,
|
|
269
|
+
response: {},
|
|
270
|
+
} as unknown as TextStreamPart<ToolSet>;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function hasPacket(packets: Array<{ packet: unknown }>, kind: string, contextId: string): boolean {
|
|
274
|
+
return packets.some(
|
|
275
|
+
({ packet }) =>
|
|
276
|
+
(packet as { kind?: string }).kind === kind &&
|
|
277
|
+
(packet as { contextId?: string }).contextId === contextId,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function hasMetric(packets: Array<{ packet: unknown }>, name: string): boolean {
|
|
282
|
+
return packets.some(({ packet }) => (packet as { name?: string }).name === name);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function waitFor(predicate: () => boolean): Promise<void> {
|
|
286
|
+
const started = Date.now();
|
|
287
|
+
while (!predicate()) {
|
|
288
|
+
if (Date.now() - started > 2000) throw new Error("Timed out waiting for condition");
|
|
289
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
290
|
+
}
|
|
291
|
+
}
|
package/src/index.test.ts
CHANGED
|
@@ -793,6 +793,147 @@ function interruptLlm(contextId: string): InterruptLlmPacket {
|
|
|
793
793
|
return { kind: "interrupt.llm", contextId, timestampMs: Date.now() };
|
|
794
794
|
}
|
|
795
795
|
|
|
796
|
+
function eosInterim(contextId: string, text: string): { kind: "eos.interim"; contextId: string; timestampMs: number; text: string } {
|
|
797
|
+
return { kind: "eos.interim", contextId, timestampMs: Date.now(), text };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function eosRetracted(contextId: string): { kind: "eos.retracted"; contextId: string; timestampMs: number } {
|
|
801
|
+
return { kind: "eos.retracted", contextId, timestampMs: Date.now() };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
describe("ReasoningBridge speculative generation", () => {
|
|
805
|
+
function kinds(packets: Array<{ packet: unknown }>): string[] {
|
|
806
|
+
return packets.map(({ packet }) => (packet as { kind: string }).kind);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
it("buffers a draft on eos.interim and flushes it on a matching eos.turn_complete (one generation)", async () => {
|
|
810
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
811
|
+
let streams = 0;
|
|
812
|
+
const plugin = new ReasoningBridge(
|
|
813
|
+
fromStreamFactory(async function* () {
|
|
814
|
+
streams += 1;
|
|
815
|
+
yield textDelta("The fee is ten dollars.");
|
|
816
|
+
yield finish("stop");
|
|
817
|
+
}),
|
|
818
|
+
{ speculative: true },
|
|
819
|
+
);
|
|
820
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
821
|
+
const drain = bus.start();
|
|
822
|
+
await plugin.initialize(bus, baseConfig());
|
|
823
|
+
|
|
824
|
+
bus.push(Route.Main, eosInterim("turn-1", "what are the lab fees"));
|
|
825
|
+
// Give the draft time to stream fully — nothing may reach the bus yet.
|
|
826
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
827
|
+
expect(kinds(packets)).not.toContain("llm.delta");
|
|
828
|
+
expect(kinds(packets)).not.toContain("llm.done");
|
|
829
|
+
expect(kinds(packets)).not.toContain("delegate.query");
|
|
830
|
+
|
|
831
|
+
bus.push(Route.Main, turnComplete("turn-1", "what are the lab fees"));
|
|
832
|
+
await waitFor(() => kinds(packets).includes("llm.done"));
|
|
833
|
+
bus.stop();
|
|
834
|
+
await drain;
|
|
835
|
+
await plugin.close();
|
|
836
|
+
|
|
837
|
+
expect(streams).toBe(1); // the draft WAS the generation — no second LLM call
|
|
838
|
+
expect(packets).toContainEqual({
|
|
839
|
+
route: Route.Main,
|
|
840
|
+
packet: expect.objectContaining({ kind: "llm.delta", contextId: "turn-1", text: "The fee is ten dollars." }),
|
|
841
|
+
});
|
|
842
|
+
expect(packets).toContainEqual({
|
|
843
|
+
route: Route.Background,
|
|
844
|
+
packet: expect.objectContaining({ kind: "delegate.result", contextId: "turn-1", answer: "The fee is ten dollars." }),
|
|
845
|
+
});
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it("discards the draft on eos.retracted — nothing is ever pushed for it", async () => {
|
|
849
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
850
|
+
let streams = 0;
|
|
851
|
+
const plugin = new ReasoningBridge(
|
|
852
|
+
fromStreamFactory(async function* () {
|
|
853
|
+
streams += 1;
|
|
854
|
+
yield textDelta("Draft answer.");
|
|
855
|
+
yield finish("stop");
|
|
856
|
+
}),
|
|
857
|
+
{ speculative: true },
|
|
858
|
+
);
|
|
859
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
860
|
+
const drain = bus.start();
|
|
861
|
+
await plugin.initialize(bus, baseConfig());
|
|
862
|
+
|
|
863
|
+
bus.push(Route.Main, eosInterim("turn-1", "book a"));
|
|
864
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
865
|
+
bus.push(Route.Main, eosRetracted("turn-1"));
|
|
866
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
867
|
+
|
|
868
|
+
// The user finishes the real utterance later; a fresh generation answers it.
|
|
869
|
+
bus.push(Route.Main, turnComplete("turn-1", "book a room for tomorrow"));
|
|
870
|
+
await waitFor(() => kinds(packets).includes("llm.done"));
|
|
871
|
+
bus.stop();
|
|
872
|
+
await drain;
|
|
873
|
+
await plugin.close();
|
|
874
|
+
|
|
875
|
+
expect(streams).toBe(2); // draft + fresh confirmed run
|
|
876
|
+
const deltas = packets.filter(({ packet }) => (packet as { kind: string }).kind === "llm.delta");
|
|
877
|
+
expect(deltas).toHaveLength(1); // the discarded draft's delta never surfaced
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it("regenerates when the confirmed transcript differs from the draft's", async () => {
|
|
881
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
882
|
+
const seenTexts: string[] = [];
|
|
883
|
+
const plugin = new ReasoningBridge(
|
|
884
|
+
fromStreamFactory(async function* (request: { userText: string }) {
|
|
885
|
+
seenTexts.push(request.userText);
|
|
886
|
+
yield textDelta(`Answer to: ${request.userText}`);
|
|
887
|
+
yield finish("stop");
|
|
888
|
+
}),
|
|
889
|
+
{ speculative: true },
|
|
890
|
+
);
|
|
891
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
892
|
+
const drain = bus.start();
|
|
893
|
+
await plugin.initialize(bus, baseConfig());
|
|
894
|
+
|
|
895
|
+
bus.push(Route.Main, eosInterim("turn-1", "what are the"));
|
|
896
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
897
|
+
bus.push(Route.Main, turnComplete("turn-1", "what are the lab fees"));
|
|
898
|
+
await waitFor(() => kinds(packets).includes("llm.done"));
|
|
899
|
+
bus.stop();
|
|
900
|
+
await drain;
|
|
901
|
+
await plugin.close();
|
|
902
|
+
|
|
903
|
+
expect(seenTexts).toEqual(["what are the", "what are the lab fees"]);
|
|
904
|
+
expect(packets).toContainEqual({
|
|
905
|
+
route: Route.Main,
|
|
906
|
+
packet: expect.objectContaining({ kind: "llm.done", text: "Answer to: what are the lab fees" }),
|
|
907
|
+
});
|
|
908
|
+
const deltas = packets.filter(({ packet }) => (packet as { kind: string }).kind === "llm.delta");
|
|
909
|
+
expect(deltas).toHaveLength(1);
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
it("ignores eos.interim when speculative mode is off (default)", async () => {
|
|
913
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
914
|
+
let streams = 0;
|
|
915
|
+
const plugin = new ReasoningBridge(
|
|
916
|
+
fromStreamFactory(async function* () {
|
|
917
|
+
streams += 1;
|
|
918
|
+
yield textDelta("Hello.");
|
|
919
|
+
yield finish("stop");
|
|
920
|
+
}),
|
|
921
|
+
);
|
|
922
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
923
|
+
const drain = bus.start();
|
|
924
|
+
await plugin.initialize(bus, baseConfig());
|
|
925
|
+
|
|
926
|
+
bus.push(Route.Main, eosInterim("turn-1", "hi"));
|
|
927
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
928
|
+
bus.stop();
|
|
929
|
+
await drain;
|
|
930
|
+
await plugin.close();
|
|
931
|
+
|
|
932
|
+
expect(streams).toBe(0);
|
|
933
|
+
expect(kinds(packets).filter((k) => k.startsWith("llm."))).toHaveLength(0);
|
|
934
|
+
});
|
|
935
|
+
});
|
|
936
|
+
|
|
796
937
|
function baseConfig(): Record<string, unknown> {
|
|
797
938
|
return {
|
|
798
939
|
api_key: "test-key",
|
package/src/index.ts
CHANGED
|
@@ -20,12 +20,14 @@ import {
|
|
|
20
20
|
type ReasonerSessionStore,
|
|
21
21
|
type ReasonerTurn,
|
|
22
22
|
type TtsWordTimestamp,
|
|
23
|
+
type IncrementalUnitId,
|
|
23
24
|
categorizeLlmError,
|
|
24
25
|
isRecoverable,
|
|
25
26
|
readRetryConfig,
|
|
26
27
|
waitForRetryDelay,
|
|
27
28
|
ErrorCategory,
|
|
28
29
|
type RetryConfig,
|
|
30
|
+
InMemoryIuLedger,
|
|
29
31
|
} from "@kuralle-syrinx/core";
|
|
30
32
|
|
|
31
33
|
export {
|
|
@@ -53,12 +55,33 @@ export interface RunStore {
|
|
|
53
55
|
discard(contextId: string): void | Promise<void>;
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Gate for a speculative draft's side effects. While unpromoted, every bus push
|
|
60
|
+
* and history/store mutation is buffered; promotion replays them in order and
|
|
61
|
+
* lets the still-running stream continue live. A discarded draft's buffer is
|
|
62
|
+
* simply dropped — the generation was never observable.
|
|
63
|
+
*/
|
|
64
|
+
interface SpeculativeHold {
|
|
65
|
+
buffered: Array<() => void>;
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
export class ReasoningBridge implements VoicePlugin {
|
|
57
69
|
private bus: PipelineBus | null = null;
|
|
58
70
|
private timeoutMs: number = 30_000;
|
|
59
71
|
private maxHistoryTurns: number = 12;
|
|
60
72
|
private history: Array<{ role: "system" | "user" | "assistant" | "tool"; content: string; toolCallId?: string }> = [];
|
|
61
73
|
private activeGeneration: { contextId: string; controller: AbortController } | null = null;
|
|
74
|
+
// At most one speculative draft at a time; `hold` gates its side effects.
|
|
75
|
+
private speculativeDraft: {
|
|
76
|
+
contextId: string;
|
|
77
|
+
userText: string;
|
|
78
|
+
controller: AbortController;
|
|
79
|
+
hold: SpeculativeHold;
|
|
80
|
+
id: IncrementalUnitId;
|
|
81
|
+
} | null = null;
|
|
82
|
+
private iuLedger!: InMemoryIuLedger;
|
|
83
|
+
private readonly epochByContext = new Map<string, number>();
|
|
84
|
+
private turnEpochCounter = 0;
|
|
62
85
|
private retryConfig: RetryConfig = readRetryConfig({});
|
|
63
86
|
private disposers: Array<() => void> = [];
|
|
64
87
|
// G2/G25: per-turn state so a barged-in turn is remembered as what the user HEARD,
|
|
@@ -95,6 +118,17 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
95
118
|
*/
|
|
96
119
|
sessionStore?: ReasonerSessionStore;
|
|
97
120
|
sessionId?: string;
|
|
121
|
+
/**
|
|
122
|
+
* Speculative generation (LiveKit preemptive-generation / Deepgram Flux
|
|
123
|
+
* eager-EOT semantics): start the LLM on `eos.interim` with every side effect
|
|
124
|
+
* held back; commit as-is when `eos.turn_complete` confirms the same
|
|
125
|
+
* transcript, regenerate when it differs, discard on `eos.retracted`.
|
|
126
|
+
* Parallelizes LLM TTFT with the endpoint-confirmation window. Opt-in:
|
|
127
|
+
* unconfirmed endpoints cost extra LLM calls (Deepgram measures +50–70% at
|
|
128
|
+
* eager thresholds 0.3–0.5). Drafts never consume a suspended-run pointer —
|
|
129
|
+
* `runStore` resume stays confirmed-turn-only.
|
|
130
|
+
*/
|
|
131
|
+
speculative?: boolean;
|
|
98
132
|
} = {},
|
|
99
133
|
) {
|
|
100
134
|
if (this.opts.onResumeConflict === "replay") {
|
|
@@ -104,6 +138,21 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
104
138
|
|
|
105
139
|
async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
|
|
106
140
|
this.bus = bus;
|
|
141
|
+
this.iuLedger = new InMemoryIuLedger((a) => {
|
|
142
|
+
const detail =
|
|
143
|
+
a.kind === "terminal_op"
|
|
144
|
+
? `${a.op} on ${a.state} IU`
|
|
145
|
+
: `${a.op} on unknown IU`;
|
|
146
|
+
this.bus?.push(Route.Background, {
|
|
147
|
+
kind: "llm.error",
|
|
148
|
+
contextId: a.id.contextId,
|
|
149
|
+
timestampMs: Date.now(),
|
|
150
|
+
component: "iu_ledger",
|
|
151
|
+
category: ErrorCategory.InternalFault,
|
|
152
|
+
cause: new Error(`iu_ledger anomaly: ${a.kind} ${detail}`),
|
|
153
|
+
isRecoverable: true,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
107
156
|
this.timeoutMs = readPositiveIntegerConfig(config["timeout_ms"], 30_000);
|
|
108
157
|
this.maxHistoryTurns = readPositiveIntegerConfig(config["max_history_turns"], 12);
|
|
109
158
|
this.retryConfig = readRetryConfig(config);
|
|
@@ -125,6 +174,26 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
125
174
|
// still-in-flight generation (see below).
|
|
126
175
|
bus.on("eos.turn_complete", async (pkt: unknown) => {
|
|
127
176
|
const eos = pkt as { text: string; contextId: string };
|
|
177
|
+
// R2: a draft for this exact transcript is already generating (or done) —
|
|
178
|
+
// promote it instead of paying a second LLM call. Flux guarantees the
|
|
179
|
+
// EndOfTurn transcript matches the preceding EagerEndOfTurn when no
|
|
180
|
+
// TurnResumed intervened, so commit-as-is is safe.
|
|
181
|
+
const draft = this.speculativeDraft;
|
|
182
|
+
if (
|
|
183
|
+
draft &&
|
|
184
|
+
draft.contextId === eos.contextId &&
|
|
185
|
+
draft.userText === eos.text &&
|
|
186
|
+
!draft.controller.signal.aborted &&
|
|
187
|
+
this.iuLedger.get(draft.id)?.state === "hypothesized"
|
|
188
|
+
) {
|
|
189
|
+
this.iuLedger.commit(draft.id);
|
|
190
|
+
this.speculativeDraft = null;
|
|
191
|
+
for (const flush of draft.hold.buffered.splice(0)) flush();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// Stale, mismatched, or failed draft: its speculation was wrong — drop it
|
|
195
|
+
// and answer the confirmed transcript fresh.
|
|
196
|
+
this.discardDraft();
|
|
128
197
|
await this.processTurn(eos.text, eos.contextId);
|
|
129
198
|
}, { concurrent: true }),
|
|
130
199
|
|
|
@@ -159,6 +228,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
159
228
|
// said words the user never heard (nor amnesiac about the exchange).
|
|
160
229
|
bus.on("interrupt.llm", (pkt: unknown) => {
|
|
161
230
|
const contextId = (pkt as { contextId: string }).contextId;
|
|
231
|
+
if (this.speculativeDraft?.contextId === contextId) this.discardDraft();
|
|
162
232
|
if (this.activeGeneration?.contextId === contextId) {
|
|
163
233
|
this.activeGeneration.controller.abort();
|
|
164
234
|
this.activeGeneration = null;
|
|
@@ -169,9 +239,70 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
169
239
|
}
|
|
170
240
|
}),
|
|
171
241
|
);
|
|
242
|
+
|
|
243
|
+
if (this.opts.speculative) {
|
|
244
|
+
this.disposers.push(
|
|
245
|
+
bus.on("eos.interim", async (pkt: unknown) => {
|
|
246
|
+
const interim = pkt as { text?: string; contextId: string };
|
|
247
|
+
const text = (interim.text ?? "").trim();
|
|
248
|
+
if (!text) return;
|
|
249
|
+
await this.runDraft(text, interim.contextId);
|
|
250
|
+
}, { concurrent: true }),
|
|
251
|
+
bus.on("eos.retracted", (pkt: unknown) => {
|
|
252
|
+
const contextId = (pkt as { contextId: string }).contextId;
|
|
253
|
+
if (this.speculativeDraft?.contextId === contextId) this.discardDraft();
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
172
257
|
}
|
|
173
258
|
|
|
174
|
-
|
|
259
|
+
/** Start (or restart, if a newer eager endpoint supersedes) the speculative draft. */
|
|
260
|
+
private async runDraft(userText: string, contextId: string): Promise<void> {
|
|
261
|
+
this.discardDraft();
|
|
262
|
+
const id = this.iuIdFor(contextId);
|
|
263
|
+
this.iuLedger.add({ id, kind: "user_turn", state: "hypothesized" });
|
|
264
|
+
const controller = new AbortController();
|
|
265
|
+
const hold: SpeculativeHold = { buffered: [] };
|
|
266
|
+
this.speculativeDraft = { contextId, userText, controller, hold, id };
|
|
267
|
+
await this.processTurn(userText, contextId, hold, controller, id);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private iuIdFor(contextId: string): IncrementalUnitId {
|
|
271
|
+
let epoch = this.epochByContext.get(contextId);
|
|
272
|
+
if (epoch === undefined) {
|
|
273
|
+
epoch = ++this.turnEpochCounter;
|
|
274
|
+
this.epochByContext.set(contextId, epoch);
|
|
275
|
+
}
|
|
276
|
+
return { contextId, iuId: contextId, epoch };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private assistantIuIdFor(contextId: string): IncrementalUnitId {
|
|
280
|
+
let epoch = this.epochByContext.get(contextId);
|
|
281
|
+
if (epoch === undefined) {
|
|
282
|
+
epoch = ++this.turnEpochCounter;
|
|
283
|
+
this.epochByContext.set(contextId, epoch);
|
|
284
|
+
}
|
|
285
|
+
return { contextId, iuId: `${contextId}#assistant`, epoch };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private discardDraft(): void {
|
|
289
|
+
const draft = this.speculativeDraft;
|
|
290
|
+
if (!draft) return;
|
|
291
|
+
this.speculativeDraft = null;
|
|
292
|
+
const committed = this.iuLedger.get(draft.id)?.state === "committed";
|
|
293
|
+
if (!committed) {
|
|
294
|
+
this.iuLedger.revoke(draft.id);
|
|
295
|
+
draft.controller.abort();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private async processTurn(
|
|
300
|
+
userText: string,
|
|
301
|
+
contextId: string,
|
|
302
|
+
hold?: SpeculativeHold,
|
|
303
|
+
presetController?: AbortController,
|
|
304
|
+
iuId?: IncrementalUnitId,
|
|
305
|
+
): Promise<void> {
|
|
175
306
|
if (!this.bus) return;
|
|
176
307
|
|
|
177
308
|
this.turnUserText.set(contextId, userText);
|
|
@@ -179,10 +310,32 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
179
310
|
// Handlers are concurrent, so a new turn can begin while a prior generation is
|
|
180
311
|
// still in flight. Supersede it: abort the previous controller before starting.
|
|
181
312
|
this.activeGeneration?.controller.abort();
|
|
182
|
-
const controller = new AbortController();
|
|
313
|
+
const controller = presetController ?? new AbortController();
|
|
183
314
|
this.activeGeneration = { contextId, controller };
|
|
315
|
+
const aid = this.assistantIuIdFor(contextId);
|
|
316
|
+
this.iuLedger.add({ id: aid, kind: "assistant_response", state: "hypothesized" });
|
|
184
317
|
const signal = controller.signal;
|
|
185
318
|
|
|
319
|
+
// R2: while a speculative hold is unpromoted, every push/mutation buffers.
|
|
320
|
+
// Packets are constructed eagerly (their timestamps are event time); only
|
|
321
|
+
// delivery is deferred. Promotion replays in order, then later effects run live.
|
|
322
|
+
const isBuffering = (): boolean =>
|
|
323
|
+
hold !== undefined &&
|
|
324
|
+
iuId !== undefined &&
|
|
325
|
+
this.iuLedger.get(iuId)?.state !== "committed";
|
|
326
|
+
const push = <T extends Parameters<PipelineBus["push"]>[1]>(route: Route, packet: T): void => {
|
|
327
|
+
if (isBuffering()) {
|
|
328
|
+
if ((packet as { kind?: string }).kind === "llm.error" && iuId) this.iuLedger.revoke(iuId);
|
|
329
|
+
hold!.buffered.push(() => this.bus?.push(route, packet));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
this.bus?.push(route, packet);
|
|
333
|
+
};
|
|
334
|
+
const defer = (fn: () => void): void => {
|
|
335
|
+
if (isBuffering()) hold!.buffered.push(fn);
|
|
336
|
+
else fn();
|
|
337
|
+
};
|
|
338
|
+
|
|
186
339
|
let reply = "";
|
|
187
340
|
let emittedDelta = false;
|
|
188
341
|
let committed = false;
|
|
@@ -192,7 +345,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
192
345
|
// route, droppable — RFC bimodel-delegate-seam R4). Cascade turns have no
|
|
193
346
|
// front-model tool call, so toolId/toolName are absent.
|
|
194
347
|
const queryStartedMs = Date.now();
|
|
195
|
-
|
|
348
|
+
push(Route.Background, {
|
|
196
349
|
kind: "delegate.query",
|
|
197
350
|
contextId,
|
|
198
351
|
timestampMs: queryStartedMs,
|
|
@@ -203,7 +356,9 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
203
356
|
for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt += 1) {
|
|
204
357
|
grounded = false;
|
|
205
358
|
try {
|
|
206
|
-
|
|
359
|
+
// Drafts never consume a suspended-run pointer: takePending mutates the
|
|
360
|
+
// store, and a retracted draft would silently lose the resume.
|
|
361
|
+
const pending = this.opts.runStore && !hold
|
|
207
362
|
? await Promise.resolve(this.opts.runStore.takePending(contextId))
|
|
208
363
|
: null;
|
|
209
364
|
const resuming = pending !== null;
|
|
@@ -219,7 +374,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
219
374
|
case "text-delta":
|
|
220
375
|
reply += part.text;
|
|
221
376
|
emittedDelta = true;
|
|
222
|
-
|
|
377
|
+
push(Route.Main, {
|
|
223
378
|
kind: "llm.delta",
|
|
224
379
|
contextId,
|
|
225
380
|
timestampMs: Date.now(),
|
|
@@ -227,7 +382,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
227
382
|
});
|
|
228
383
|
break;
|
|
229
384
|
case "tool-call":
|
|
230
|
-
|
|
385
|
+
push(Route.Main, {
|
|
231
386
|
kind: "llm.tool_call",
|
|
232
387
|
contextId,
|
|
233
388
|
timestampMs: Date.now(),
|
|
@@ -238,7 +393,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
238
393
|
break;
|
|
239
394
|
case "tool-result":
|
|
240
395
|
grounded = true;
|
|
241
|
-
|
|
396
|
+
push(Route.Main, {
|
|
242
397
|
kind: "llm.tool_result",
|
|
243
398
|
contextId,
|
|
244
399
|
timestampMs: Date.now(),
|
|
@@ -250,12 +405,18 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
250
405
|
case "error":
|
|
251
406
|
throw part.cause;
|
|
252
407
|
case "finish":
|
|
253
|
-
|
|
408
|
+
push(Route.Background, {
|
|
409
|
+
kind: "metric.conversation",
|
|
410
|
+
contextId,
|
|
411
|
+
timestampMs: Date.now(),
|
|
412
|
+
name: "llm.finish_reason",
|
|
413
|
+
value: part.reason,
|
|
414
|
+
});
|
|
254
415
|
finishReason = part.reason;
|
|
255
416
|
break;
|
|
256
417
|
case "suspended": {
|
|
257
418
|
if (part.prompt && !emittedDelta) {
|
|
258
|
-
|
|
419
|
+
push(Route.Main, {
|
|
259
420
|
kind: "llm.delta",
|
|
260
421
|
contextId,
|
|
261
422
|
timestampMs: Date.now(),
|
|
@@ -264,14 +425,14 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
264
425
|
reply += part.prompt;
|
|
265
426
|
}
|
|
266
427
|
if (signal.aborted) return;
|
|
267
|
-
|
|
428
|
+
push(Route.Main, {
|
|
268
429
|
kind: "llm.done",
|
|
269
430
|
contextId,
|
|
270
431
|
timestampMs: Date.now(),
|
|
271
432
|
text: reply,
|
|
272
433
|
});
|
|
273
|
-
this.rememberTurn(userText, reply, contextId);
|
|
274
|
-
|
|
434
|
+
defer(() => this.rememberTurn(userText, reply, contextId));
|
|
435
|
+
push(Route.Background, {
|
|
275
436
|
kind: "reasoning.suspended",
|
|
276
437
|
contextId,
|
|
277
438
|
timestampMs: Date.now(),
|
|
@@ -280,7 +441,13 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
280
441
|
payload: part.payload,
|
|
281
442
|
});
|
|
282
443
|
if (this.opts.runStore) {
|
|
283
|
-
|
|
444
|
+
const store = this.opts.runStore;
|
|
445
|
+
const runId = part.runId;
|
|
446
|
+
if (isBuffering()) {
|
|
447
|
+
hold!.buffered.push(() => void Promise.resolve(store.save(contextId, runId)).catch(() => undefined));
|
|
448
|
+
} else {
|
|
449
|
+
await Promise.resolve(store.save(contextId, runId));
|
|
450
|
+
}
|
|
284
451
|
}
|
|
285
452
|
committed = true;
|
|
286
453
|
return;
|
|
@@ -296,7 +463,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
296
463
|
// recoverably — the caller hears the graceful fallback, the call stays up.
|
|
297
464
|
if (finishReason !== "stop" && finishReason !== "length") {
|
|
298
465
|
if (signal.aborted) return;
|
|
299
|
-
|
|
466
|
+
push(Route.Critical, {
|
|
300
467
|
kind: "llm.error",
|
|
301
468
|
contextId,
|
|
302
469
|
timestampMs: Date.now(),
|
|
@@ -308,7 +475,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
308
475
|
return;
|
|
309
476
|
}
|
|
310
477
|
if (finishReason === "length") {
|
|
311
|
-
|
|
478
|
+
push(Route.Background, {
|
|
312
479
|
kind: "metric.conversation",
|
|
313
480
|
contextId,
|
|
314
481
|
timestampMs: Date.now(),
|
|
@@ -322,14 +489,14 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
322
489
|
if (signal.aborted) return;
|
|
323
490
|
|
|
324
491
|
const answeredMs = Date.now();
|
|
325
|
-
|
|
492
|
+
push(Route.Main, {
|
|
326
493
|
kind: "llm.done",
|
|
327
494
|
contextId,
|
|
328
495
|
timestampMs: answeredMs,
|
|
329
496
|
text: reply,
|
|
330
497
|
});
|
|
331
498
|
// G2 observability: the reasoner produced the turn's final answer.
|
|
332
|
-
|
|
499
|
+
push(Route.Background, {
|
|
333
500
|
kind: "delegate.result",
|
|
334
501
|
contextId,
|
|
335
502
|
timestampMs: answeredMs,
|
|
@@ -338,7 +505,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
338
505
|
durationMs: answeredMs - queryStartedMs,
|
|
339
506
|
grounded,
|
|
340
507
|
});
|
|
341
|
-
this.rememberTurn(userText, reply, contextId);
|
|
508
|
+
defer(() => this.rememberTurn(userText, reply, contextId));
|
|
342
509
|
if (this.opts.runStore && resuming) {
|
|
343
510
|
await Promise.resolve(this.opts.runStore.discard(contextId));
|
|
344
511
|
}
|
|
@@ -349,7 +516,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
349
516
|
const category = categorizeLlmError(err);
|
|
350
517
|
const recoverable = isRecoverable(category);
|
|
351
518
|
if (!recoverable || emittedDelta || attempt >= this.retryConfig.maxAttempts) {
|
|
352
|
-
|
|
519
|
+
push(Route.Critical, {
|
|
353
520
|
kind: "llm.error",
|
|
354
521
|
contextId,
|
|
355
522
|
timestampMs: Date.now(),
|
|
@@ -361,7 +528,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
361
528
|
return;
|
|
362
529
|
}
|
|
363
530
|
|
|
364
|
-
|
|
531
|
+
push(Route.Background, {
|
|
365
532
|
kind: "metric.conversation",
|
|
366
533
|
contextId,
|
|
367
534
|
timestampMs: Date.now(),
|
|
@@ -379,21 +546,8 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
379
546
|
}
|
|
380
547
|
}
|
|
381
548
|
|
|
382
|
-
private recordFinishReason(
|
|
383
|
-
contextId: string,
|
|
384
|
-
name: string,
|
|
385
|
-
finishReason: "stop" | "tool" | "length",
|
|
386
|
-
): void {
|
|
387
|
-
this.bus?.push(Route.Background, {
|
|
388
|
-
kind: "metric.conversation",
|
|
389
|
-
contextId,
|
|
390
|
-
timestampMs: Date.now(),
|
|
391
|
-
name,
|
|
392
|
-
value: finishReason,
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
|
|
396
549
|
async close(): Promise<void> {
|
|
550
|
+
this.discardDraft();
|
|
397
551
|
this.activeGeneration?.controller.abort();
|
|
398
552
|
this.activeGeneration = null;
|
|
399
553
|
for (const dispose of this.disposers.splice(0)) dispose();
|
|
@@ -409,6 +563,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
409
563
|
const assistantMsg = { role: "assistant" as const, content: assistantText };
|
|
410
564
|
this.history.push({ role: "user", content: userText }, assistantMsg);
|
|
411
565
|
this.assistantMsgByContext.set(contextId, assistantMsg);
|
|
566
|
+
this.iuLedger.commit(this.assistantIuIdFor(contextId));
|
|
412
567
|
this.trimHistory();
|
|
413
568
|
this.persistHistory();
|
|
414
569
|
}
|
|
@@ -454,6 +609,15 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
454
609
|
*/
|
|
455
610
|
private commitInterruptedHistory(contextId: string): void {
|
|
456
611
|
const spoken = this.computeSpokenPrefix(contextId);
|
|
612
|
+
const aid = this.assistantIuIdFor(contextId);
|
|
613
|
+
const ms = this.playedOutMsByContext.get(contextId);
|
|
614
|
+
const prefix = { chars: spoken.length, ms };
|
|
615
|
+
const assistantIu = this.iuLedger.get(aid);
|
|
616
|
+
if (assistantIu?.state === "hypothesized") {
|
|
617
|
+
this.iuLedger.commit(aid, prefix);
|
|
618
|
+
} else if (assistantIu?.state === "committed") {
|
|
619
|
+
assistantIu.committedPrefix = prefix;
|
|
620
|
+
}
|
|
457
621
|
const existing = this.assistantMsgByContext.get(contextId);
|
|
458
622
|
if (existing) {
|
|
459
623
|
if (spoken) {
|
|
@@ -493,6 +657,10 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
493
657
|
}
|
|
494
658
|
|
|
495
659
|
private clearTurnState(contextId: string): void {
|
|
660
|
+
const aid = this.assistantIuIdFor(contextId);
|
|
661
|
+
if (this.iuLedger.get(aid)?.state === "hypothesized") {
|
|
662
|
+
this.iuLedger.revoke(aid);
|
|
663
|
+
}
|
|
496
664
|
this.spokenByContext.delete(contextId);
|
|
497
665
|
this.turnUserText.delete(contextId);
|
|
498
666
|
this.assistantMsgByContext.delete(contextId);
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
PipelineBusImpl,
|
|
6
|
+
Route,
|
|
7
|
+
type EndOfSpeechPacket,
|
|
8
|
+
type InMemoryIuLedger,
|
|
9
|
+
type IncrementalUnitId,
|
|
10
|
+
} from "@kuralle-syrinx/core";
|
|
11
|
+
import type { FinishReason, TextStreamPart, ToolSet } from "ai";
|
|
12
|
+
import { fromStreamFactory } from "./from-ai-sdk.js";
|
|
13
|
+
import { ReasoningBridge } from "./index.js";
|
|
14
|
+
|
|
15
|
+
const ZERO_USAGE = {
|
|
16
|
+
inputTokens: 0,
|
|
17
|
+
inputTokenDetails: { noCacheTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
|
18
|
+
outputTokens: 0,
|
|
19
|
+
outputTokenDetails: { textTokens: 0, reasoningTokens: 0 },
|
|
20
|
+
totalTokens: 0,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type BridgeLedgerAccess = {
|
|
24
|
+
iuLedger: InMemoryIuLedger;
|
|
25
|
+
speculativeDraft: { id: IncrementalUnitId } | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function bridgeLedger(plugin: ReasoningBridge): BridgeLedgerAccess {
|
|
29
|
+
return plugin as unknown as BridgeLedgerAccess;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("ReasoningBridge speculative-on-ledger", () => {
|
|
33
|
+
it("eos.interim adds a hypothesized IU and matching eos.turn_complete commits it", async () => {
|
|
34
|
+
const plugin = new ReasoningBridge(
|
|
35
|
+
fromStreamFactory(async function* () {
|
|
36
|
+
yield textDelta("Answer.");
|
|
37
|
+
yield finish("stop");
|
|
38
|
+
}),
|
|
39
|
+
{ speculative: true },
|
|
40
|
+
);
|
|
41
|
+
const bus = new PipelineBusImpl({ onPacket: () => {} });
|
|
42
|
+
const drain = bus.start();
|
|
43
|
+
await plugin.initialize(bus, baseConfig());
|
|
44
|
+
const ledger = bridgeLedger(plugin);
|
|
45
|
+
|
|
46
|
+
bus.push(Route.Main, eosInterim("turn-1", "hello"));
|
|
47
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
48
|
+
|
|
49
|
+
const draftId = ledger.speculativeDraft?.id;
|
|
50
|
+
expect(draftId).toEqual({ contextId: "turn-1", iuId: "turn-1", epoch: 1 });
|
|
51
|
+
expect(ledger.iuLedger.get(draftId!)?.state).toBe("hypothesized");
|
|
52
|
+
|
|
53
|
+
bus.push(Route.Main, turnComplete("turn-1", "hello"));
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
55
|
+
|
|
56
|
+
expect(ledger.iuLedger.get(draftId!)?.state).toBe("committed");
|
|
57
|
+
bus.stop();
|
|
58
|
+
await drain;
|
|
59
|
+
await plugin.close();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("eos.retracted revokes the hypothesized IU", async () => {
|
|
63
|
+
const plugin = new ReasoningBridge(
|
|
64
|
+
fromStreamFactory(async function* () {
|
|
65
|
+
yield textDelta("Draft.");
|
|
66
|
+
yield finish("stop");
|
|
67
|
+
}),
|
|
68
|
+
{ speculative: true },
|
|
69
|
+
);
|
|
70
|
+
const bus = new PipelineBusImpl({ onPacket: () => {} });
|
|
71
|
+
const drain = bus.start();
|
|
72
|
+
await plugin.initialize(bus, baseConfig());
|
|
73
|
+
const ledger = bridgeLedger(plugin);
|
|
74
|
+
|
|
75
|
+
bus.push(Route.Main, eosInterim("turn-1", "book a"));
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
77
|
+
const draftId = ledger.speculativeDraft?.id;
|
|
78
|
+
expect(ledger.iuLedger.get(draftId!)?.state).toBe("hypothesized");
|
|
79
|
+
|
|
80
|
+
bus.push(Route.Main, eosRetracted("turn-1"));
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
82
|
+
|
|
83
|
+
expect(ledger.iuLedger.get(draftId!)?.state).toBe("revoked");
|
|
84
|
+
bus.stop();
|
|
85
|
+
await drain;
|
|
86
|
+
await plugin.close();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("double-commit fires an iu_ledger anomaly on the bus", async () => {
|
|
90
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
91
|
+
const plugin = new ReasoningBridge(
|
|
92
|
+
fromStreamFactory(async function* () {
|
|
93
|
+
yield textDelta("Answer.");
|
|
94
|
+
yield finish("stop");
|
|
95
|
+
}),
|
|
96
|
+
{ speculative: true },
|
|
97
|
+
);
|
|
98
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
99
|
+
const drain = bus.start();
|
|
100
|
+
await plugin.initialize(bus, baseConfig());
|
|
101
|
+
const ledger = bridgeLedger(plugin);
|
|
102
|
+
|
|
103
|
+
bus.push(Route.Main, eosInterim("turn-1", "hello"));
|
|
104
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
105
|
+
const draftId = ledger.speculativeDraft?.id;
|
|
106
|
+
expect(draftId).toBeDefined();
|
|
107
|
+
|
|
108
|
+
bus.push(Route.Main, turnComplete("turn-1", "hello"));
|
|
109
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
110
|
+
expect(ledger.iuLedger.get(draftId!)?.state).toBe("committed");
|
|
111
|
+
|
|
112
|
+
ledger.iuLedger.commit(draftId!);
|
|
113
|
+
await waitFor(() =>
|
|
114
|
+
packets.some(
|
|
115
|
+
({ packet }) =>
|
|
116
|
+
(packet as { kind?: string; component?: string }).kind === "llm.error" &&
|
|
117
|
+
(packet as { component?: string }).component === "iu_ledger",
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(packets).toContainEqual({
|
|
122
|
+
route: Route.Background,
|
|
123
|
+
packet: expect.objectContaining({
|
|
124
|
+
kind: "llm.error",
|
|
125
|
+
component: "iu_ledger",
|
|
126
|
+
contextId: "turn-1",
|
|
127
|
+
isRecoverable: true,
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
bus.stop();
|
|
131
|
+
await drain;
|
|
132
|
+
await plugin.close();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("assigns monotonic epoch across distinct contextIds", async () => {
|
|
136
|
+
const plugin = new ReasoningBridge(
|
|
137
|
+
fromStreamFactory(async function* () {
|
|
138
|
+
yield textDelta("Answer.");
|
|
139
|
+
yield finish("stop");
|
|
140
|
+
}),
|
|
141
|
+
{ speculative: true },
|
|
142
|
+
);
|
|
143
|
+
const bus = new PipelineBusImpl({ onPacket: () => {} });
|
|
144
|
+
const drain = bus.start();
|
|
145
|
+
await plugin.initialize(bus, baseConfig());
|
|
146
|
+
const ledger = bridgeLedger(plugin);
|
|
147
|
+
|
|
148
|
+
bus.push(Route.Main, eosInterim("ctx-a", "first"));
|
|
149
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
150
|
+
const idA = ledger.speculativeDraft?.id;
|
|
151
|
+
bus.push(Route.Main, turnComplete("ctx-a", "first"));
|
|
152
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
153
|
+
|
|
154
|
+
bus.push(Route.Main, eosInterim("ctx-b", "second"));
|
|
155
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
156
|
+
const idB = ledger.speculativeDraft?.id;
|
|
157
|
+
|
|
158
|
+
expect(idA?.epoch).toBe(1);
|
|
159
|
+
expect(idB?.epoch).toBe(2);
|
|
160
|
+
expect(idB!.epoch).toBeGreaterThan(idA!.epoch);
|
|
161
|
+
bus.stop();
|
|
162
|
+
await drain;
|
|
163
|
+
await plugin.close();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
function baseConfig(): Record<string, unknown> {
|
|
168
|
+
return {
|
|
169
|
+
api_key: "test-key",
|
|
170
|
+
model: "gpt-test",
|
|
171
|
+
system_prompt: "test",
|
|
172
|
+
retry_max_attempts: 1,
|
|
173
|
+
timeout_ms: 1000,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function turnComplete(contextId: string, text: string): EndOfSpeechPacket {
|
|
178
|
+
return { kind: "eos.turn_complete", contextId, timestampMs: Date.now(), text, transcripts: [] };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function eosInterim(contextId: string, text: string): { kind: "eos.interim"; contextId: string; timestampMs: number; text: string } {
|
|
182
|
+
return { kind: "eos.interim", contextId, timestampMs: Date.now(), text };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function eosRetracted(contextId: string): { kind: "eos.retracted"; contextId: string; timestampMs: number } {
|
|
186
|
+
return { kind: "eos.retracted", contextId, timestampMs: Date.now() };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function textDelta(text: string): TextStreamPart<ToolSet> {
|
|
190
|
+
return { type: "text-delta", id: "0", text, providerMetadata: undefined } as TextStreamPart<ToolSet>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function finish(finishReason: FinishReason, rawFinishReason?: string): TextStreamPart<ToolSet> {
|
|
194
|
+
return {
|
|
195
|
+
type: "finish",
|
|
196
|
+
finishReason,
|
|
197
|
+
rawFinishReason,
|
|
198
|
+
totalUsage: ZERO_USAGE,
|
|
199
|
+
usage: ZERO_USAGE,
|
|
200
|
+
providerMetadata: undefined,
|
|
201
|
+
response: {},
|
|
202
|
+
} as TextStreamPart<ToolSet>;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function waitFor(predicate: () => boolean): Promise<void> {
|
|
206
|
+
const started = Date.now();
|
|
207
|
+
while (!predicate()) {
|
|
208
|
+
if (Date.now() - started > 1000) throw new Error("Timed out waiting for packet");
|
|
209
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Regression guard (S1-01, RFC C2): when a speculative draft is promoted MID-STREAM,
|
|
4
|
+
// deltas produced AFTER the promotion must go live, not stay buffered. The speculative
|
|
5
|
+
// side-effect gate must re-check commit state per push — hoisting it to a single value
|
|
6
|
+
// captured at generation start loses the post-promotion tail (incl. llm.done).
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { PipelineBusImpl, Route } from "@kuralle-syrinx/core";
|
|
10
|
+
import type { EndOfSpeechPacket } from "@kuralle-syrinx/core";
|
|
11
|
+
import type { FinishReason, TextStreamPart, ToolSet } from "ai";
|
|
12
|
+
import { fromStreamFactory } from "./from-ai-sdk.js";
|
|
13
|
+
import { ReasoningBridge } from "./index.js";
|
|
14
|
+
|
|
15
|
+
const ZERO_USAGE = {
|
|
16
|
+
inputTokens: 0,
|
|
17
|
+
inputTokenDetails: { noCacheTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
|
18
|
+
outputTokens: 0,
|
|
19
|
+
outputTokenDetails: { reasoningTokens: 0 },
|
|
20
|
+
totalTokens: 0,
|
|
21
|
+
reasoningTokens: 0,
|
|
22
|
+
cachedInputTokens: 0,
|
|
23
|
+
} as unknown as Record<string, unknown>;
|
|
24
|
+
|
|
25
|
+
function textDelta(text: string): TextStreamPart<ToolSet> {
|
|
26
|
+
return { type: "text-delta", id: "0", text, providerMetadata: undefined } as TextStreamPart<ToolSet>;
|
|
27
|
+
}
|
|
28
|
+
function finish(finishReason: FinishReason): TextStreamPart<ToolSet> {
|
|
29
|
+
return { type: "finish", finishReason, totalUsage: ZERO_USAGE, usage: ZERO_USAGE, providerMetadata: undefined, response: {} } as unknown as TextStreamPart<ToolSet>;
|
|
30
|
+
}
|
|
31
|
+
function eosInterim(contextId: string, text: string) {
|
|
32
|
+
return { kind: "eos.interim" as const, contextId, timestampMs: Date.now(), text };
|
|
33
|
+
}
|
|
34
|
+
function turnComplete(contextId: string, text: string): EndOfSpeechPacket {
|
|
35
|
+
return { kind: "eos.turn_complete", contextId, timestampMs: Date.now(), text, transcripts: [] };
|
|
36
|
+
}
|
|
37
|
+
function baseConfig(): Record<string, unknown> {
|
|
38
|
+
return { api_key: "test-key", model: "gpt-test", system_prompt: "test", retry_max_attempts: 1, timeout_ms: 1000 };
|
|
39
|
+
}
|
|
40
|
+
async function waitFor(predicate: () => boolean): Promise<void> {
|
|
41
|
+
for (let i = 0; i < 200; i++) {
|
|
42
|
+
if (predicate()) return;
|
|
43
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
44
|
+
}
|
|
45
|
+
throw new Error("waitFor timed out");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("S1 repro — post-promotion streaming", () => {
|
|
49
|
+
it("a delta streamed AFTER a mid-stream promotion must still reach the bus", async () => {
|
|
50
|
+
const packets: Array<{ route: Route; packet: unknown }> = [];
|
|
51
|
+
let release!: () => void;
|
|
52
|
+
const gate = new Promise<void>((r) => { release = r; });
|
|
53
|
+
|
|
54
|
+
const plugin = new ReasoningBridge(
|
|
55
|
+
fromStreamFactory(async function* () {
|
|
56
|
+
yield textDelta("first "); // buffered while hypothesized
|
|
57
|
+
await gate; // still streaming — pause BEFORE promotion completes
|
|
58
|
+
yield textDelta("second"); // streamed AFTER promotion
|
|
59
|
+
yield finish("stop");
|
|
60
|
+
}),
|
|
61
|
+
{ speculative: true },
|
|
62
|
+
);
|
|
63
|
+
const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
|
|
64
|
+
const drain = bus.start();
|
|
65
|
+
await plugin.initialize(bus, baseConfig());
|
|
66
|
+
|
|
67
|
+
bus.push(Route.Main, eosInterim("turn-1", "hello there"));
|
|
68
|
+
await new Promise((r) => setTimeout(r, 50)); // "first " buffered; generator paused at gate
|
|
69
|
+
|
|
70
|
+
bus.push(Route.Main, turnComplete("turn-1", "hello there")); // promote (commit) mid-stream
|
|
71
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
72
|
+
release(); // now let the generator stream the post-promotion delta
|
|
73
|
+
|
|
74
|
+
await waitFor(() => packets.some((p) => (p.packet as { kind: string }).kind === "llm.done"));
|
|
75
|
+
bus.stop();
|
|
76
|
+
await drain;
|
|
77
|
+
await plugin.close();
|
|
78
|
+
|
|
79
|
+
const deltaTexts = packets
|
|
80
|
+
.filter((p) => (p.packet as { kind: string }).kind === "llm.delta")
|
|
81
|
+
.map((p) => (p.packet as { text: string }).text);
|
|
82
|
+
|
|
83
|
+
// The post-promotion "second" delta MUST have reached the bus.
|
|
84
|
+
expect(deltaTexts).toContain("second");
|
|
85
|
+
});
|
|
86
|
+
});
|