@kuralle-syrinx/aisdk 4.0.0 → 4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/aisdk",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,7 +10,7 @@
10
10
  "@ai-sdk/openai": "^3.0.67",
11
11
  "ai": "^6.0.0",
12
12
  "zod": "^4.1.8",
13
- "@kuralle-syrinx/core": "4.0.0"
13
+ "@kuralle-syrinx/core": "4.1.0"
14
14
  },
15
15
  "devDependencies": {
16
16
  "typescript": "^5.7.0",
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
@@ -53,12 +53,31 @@ export interface RunStore {
53
53
  discard(contextId: string): void | Promise<void>;
54
54
  }
55
55
 
56
+ /**
57
+ * Gate for a speculative draft's side effects. While unpromoted, every bus push
58
+ * and history/store mutation is buffered; promotion replays them in order and
59
+ * lets the still-running stream continue live. A discarded draft's buffer is
60
+ * simply dropped — the generation was never observable.
61
+ */
62
+ interface SpeculativeHold {
63
+ promoted: boolean;
64
+ failed: boolean;
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
+ } | null = null;
62
81
  private retryConfig: RetryConfig = readRetryConfig({});
63
82
  private disposers: Array<() => void> = [];
64
83
  // G2/G25: per-turn state so a barged-in turn is remembered as what the user HEARD,
@@ -95,6 +114,17 @@ export class ReasoningBridge implements VoicePlugin {
95
114
  */
96
115
  sessionStore?: ReasonerSessionStore;
97
116
  sessionId?: string;
117
+ /**
118
+ * Speculative generation (LiveKit preemptive-generation / Deepgram Flux
119
+ * eager-EOT semantics): start the LLM on `eos.interim` with every side effect
120
+ * held back; commit as-is when `eos.turn_complete` confirms the same
121
+ * transcript, regenerate when it differs, discard on `eos.retracted`.
122
+ * Parallelizes LLM TTFT with the endpoint-confirmation window. Opt-in:
123
+ * unconfirmed endpoints cost extra LLM calls (Deepgram measures +50–70% at
124
+ * eager thresholds 0.3–0.5). Drafts never consume a suspended-run pointer —
125
+ * `runStore` resume stays confirmed-turn-only.
126
+ */
127
+ speculative?: boolean;
98
128
  } = {},
99
129
  ) {
100
130
  if (this.opts.onResumeConflict === "replay") {
@@ -125,6 +155,26 @@ export class ReasoningBridge implements VoicePlugin {
125
155
  // still-in-flight generation (see below).
126
156
  bus.on("eos.turn_complete", async (pkt: unknown) => {
127
157
  const eos = pkt as { text: string; contextId: string };
158
+ // R2: a draft for this exact transcript is already generating (or done) —
159
+ // promote it instead of paying a second LLM call. Flux guarantees the
160
+ // EndOfTurn transcript matches the preceding EagerEndOfTurn when no
161
+ // TurnResumed intervened, so commit-as-is is safe.
162
+ const draft = this.speculativeDraft;
163
+ if (
164
+ draft &&
165
+ draft.contextId === eos.contextId &&
166
+ draft.userText === eos.text &&
167
+ !draft.controller.signal.aborted &&
168
+ !draft.hold.failed
169
+ ) {
170
+ this.speculativeDraft = null;
171
+ draft.hold.promoted = true;
172
+ for (const flush of draft.hold.buffered.splice(0)) flush();
173
+ return;
174
+ }
175
+ // Stale, mismatched, or failed draft: its speculation was wrong — drop it
176
+ // and answer the confirmed transcript fresh.
177
+ this.discardDraft();
128
178
  await this.processTurn(eos.text, eos.contextId);
129
179
  }, { concurrent: true }),
130
180
 
@@ -159,6 +209,7 @@ export class ReasoningBridge implements VoicePlugin {
159
209
  // said words the user never heard (nor amnesiac about the exchange).
160
210
  bus.on("interrupt.llm", (pkt: unknown) => {
161
211
  const contextId = (pkt as { contextId: string }).contextId;
212
+ if (this.speculativeDraft?.contextId === contextId) this.discardDraft();
162
213
  if (this.activeGeneration?.contextId === contextId) {
163
214
  this.activeGeneration.controller.abort();
164
215
  this.activeGeneration = null;
@@ -169,9 +220,45 @@ export class ReasoningBridge implements VoicePlugin {
169
220
  }
170
221
  }),
171
222
  );
223
+
224
+ if (this.opts.speculative) {
225
+ this.disposers.push(
226
+ bus.on("eos.interim", async (pkt: unknown) => {
227
+ const interim = pkt as { text?: string; contextId: string };
228
+ const text = (interim.text ?? "").trim();
229
+ if (!text) return;
230
+ await this.runDraft(text, interim.contextId);
231
+ }, { concurrent: true }),
232
+ bus.on("eos.retracted", (pkt: unknown) => {
233
+ const contextId = (pkt as { contextId: string }).contextId;
234
+ if (this.speculativeDraft?.contextId === contextId) this.discardDraft();
235
+ }),
236
+ );
237
+ }
238
+ }
239
+
240
+ /** Start (or restart, if a newer eager endpoint supersedes) the speculative draft. */
241
+ private async runDraft(userText: string, contextId: string): Promise<void> {
242
+ this.discardDraft();
243
+ const controller = new AbortController();
244
+ const hold: SpeculativeHold = { promoted: false, failed: false, buffered: [] };
245
+ this.speculativeDraft = { contextId, userText, controller, hold };
246
+ await this.processTurn(userText, contextId, hold, controller);
172
247
  }
173
248
 
174
- private async processTurn(userText: string, contextId: string): Promise<void> {
249
+ private discardDraft(): void {
250
+ const draft = this.speculativeDraft;
251
+ if (!draft) return;
252
+ this.speculativeDraft = null;
253
+ if (!draft.hold.promoted) draft.controller.abort();
254
+ }
255
+
256
+ private async processTurn(
257
+ userText: string,
258
+ contextId: string,
259
+ hold?: SpeculativeHold,
260
+ presetController?: AbortController,
261
+ ): Promise<void> {
175
262
  if (!this.bus) return;
176
263
 
177
264
  this.turnUserText.set(contextId, userText);
@@ -179,10 +266,26 @@ export class ReasoningBridge implements VoicePlugin {
179
266
  // Handlers are concurrent, so a new turn can begin while a prior generation is
180
267
  // still in flight. Supersede it: abort the previous controller before starting.
181
268
  this.activeGeneration?.controller.abort();
182
- const controller = new AbortController();
269
+ const controller = presetController ?? new AbortController();
183
270
  this.activeGeneration = { contextId, controller };
184
271
  const signal = controller.signal;
185
272
 
273
+ // R2: while a speculative hold is unpromoted, every push/mutation buffers.
274
+ // Packets are constructed eagerly (their timestamps are event time); only
275
+ // delivery is deferred. Promotion replays in order, then later effects run live.
276
+ const push = <T extends Parameters<PipelineBus["push"]>[1]>(route: Route, packet: T): void => {
277
+ if (hold && !hold.promoted) {
278
+ if ((packet as { kind?: string }).kind === "llm.error") hold.failed = true;
279
+ hold.buffered.push(() => this.bus?.push(route, packet));
280
+ return;
281
+ }
282
+ this.bus?.push(route, packet);
283
+ };
284
+ const defer = (fn: () => void): void => {
285
+ if (hold && !hold.promoted) hold.buffered.push(fn);
286
+ else fn();
287
+ };
288
+
186
289
  let reply = "";
187
290
  let emittedDelta = false;
188
291
  let committed = false;
@@ -192,7 +295,7 @@ export class ReasoningBridge implements VoicePlugin {
192
295
  // route, droppable — RFC bimodel-delegate-seam R4). Cascade turns have no
193
296
  // front-model tool call, so toolId/toolName are absent.
194
297
  const queryStartedMs = Date.now();
195
- this.bus.push(Route.Background, {
298
+ push(Route.Background, {
196
299
  kind: "delegate.query",
197
300
  contextId,
198
301
  timestampMs: queryStartedMs,
@@ -203,7 +306,9 @@ export class ReasoningBridge implements VoicePlugin {
203
306
  for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt += 1) {
204
307
  grounded = false;
205
308
  try {
206
- const pending = this.opts.runStore
309
+ // Drafts never consume a suspended-run pointer: takePending mutates the
310
+ // store, and a retracted draft would silently lose the resume.
311
+ const pending = this.opts.runStore && !hold
207
312
  ? await Promise.resolve(this.opts.runStore.takePending(contextId))
208
313
  : null;
209
314
  const resuming = pending !== null;
@@ -219,7 +324,7 @@ export class ReasoningBridge implements VoicePlugin {
219
324
  case "text-delta":
220
325
  reply += part.text;
221
326
  emittedDelta = true;
222
- this.bus.push(Route.Main, {
327
+ push(Route.Main, {
223
328
  kind: "llm.delta",
224
329
  contextId,
225
330
  timestampMs: Date.now(),
@@ -227,7 +332,7 @@ export class ReasoningBridge implements VoicePlugin {
227
332
  });
228
333
  break;
229
334
  case "tool-call":
230
- this.bus.push(Route.Main, {
335
+ push(Route.Main, {
231
336
  kind: "llm.tool_call",
232
337
  contextId,
233
338
  timestampMs: Date.now(),
@@ -238,7 +343,7 @@ export class ReasoningBridge implements VoicePlugin {
238
343
  break;
239
344
  case "tool-result":
240
345
  grounded = true;
241
- this.bus.push(Route.Main, {
346
+ push(Route.Main, {
242
347
  kind: "llm.tool_result",
243
348
  contextId,
244
349
  timestampMs: Date.now(),
@@ -250,12 +355,18 @@ export class ReasoningBridge implements VoicePlugin {
250
355
  case "error":
251
356
  throw part.cause;
252
357
  case "finish":
253
- this.recordFinishReason(contextId, "llm.finish_reason", part.reason);
358
+ push(Route.Background, {
359
+ kind: "metric.conversation",
360
+ contextId,
361
+ timestampMs: Date.now(),
362
+ name: "llm.finish_reason",
363
+ value: part.reason,
364
+ });
254
365
  finishReason = part.reason;
255
366
  break;
256
367
  case "suspended": {
257
368
  if (part.prompt && !emittedDelta) {
258
- this.bus.push(Route.Main, {
369
+ push(Route.Main, {
259
370
  kind: "llm.delta",
260
371
  contextId,
261
372
  timestampMs: Date.now(),
@@ -264,14 +375,14 @@ export class ReasoningBridge implements VoicePlugin {
264
375
  reply += part.prompt;
265
376
  }
266
377
  if (signal.aborted) return;
267
- this.bus.push(Route.Main, {
378
+ push(Route.Main, {
268
379
  kind: "llm.done",
269
380
  contextId,
270
381
  timestampMs: Date.now(),
271
382
  text: reply,
272
383
  });
273
- this.rememberTurn(userText, reply, contextId);
274
- this.bus.push(Route.Background, {
384
+ defer(() => this.rememberTurn(userText, reply, contextId));
385
+ push(Route.Background, {
275
386
  kind: "reasoning.suspended",
276
387
  contextId,
277
388
  timestampMs: Date.now(),
@@ -280,7 +391,13 @@ export class ReasoningBridge implements VoicePlugin {
280
391
  payload: part.payload,
281
392
  });
282
393
  if (this.opts.runStore) {
283
- await Promise.resolve(this.opts.runStore.save(contextId, part.runId));
394
+ const store = this.opts.runStore;
395
+ const runId = part.runId;
396
+ if (hold && !hold.promoted) {
397
+ hold.buffered.push(() => void Promise.resolve(store.save(contextId, runId)).catch(() => undefined));
398
+ } else {
399
+ await Promise.resolve(store.save(contextId, runId));
400
+ }
284
401
  }
285
402
  committed = true;
286
403
  return;
@@ -296,7 +413,7 @@ export class ReasoningBridge implements VoicePlugin {
296
413
  // recoverably — the caller hears the graceful fallback, the call stays up.
297
414
  if (finishReason !== "stop" && finishReason !== "length") {
298
415
  if (signal.aborted) return;
299
- this.bus.push(Route.Critical, {
416
+ push(Route.Critical, {
300
417
  kind: "llm.error",
301
418
  contextId,
302
419
  timestampMs: Date.now(),
@@ -308,7 +425,7 @@ export class ReasoningBridge implements VoicePlugin {
308
425
  return;
309
426
  }
310
427
  if (finishReason === "length") {
311
- this.bus.push(Route.Background, {
428
+ push(Route.Background, {
312
429
  kind: "metric.conversation",
313
430
  contextId,
314
431
  timestampMs: Date.now(),
@@ -322,14 +439,14 @@ export class ReasoningBridge implements VoicePlugin {
322
439
  if (signal.aborted) return;
323
440
 
324
441
  const answeredMs = Date.now();
325
- this.bus.push(Route.Main, {
442
+ push(Route.Main, {
326
443
  kind: "llm.done",
327
444
  contextId,
328
445
  timestampMs: answeredMs,
329
446
  text: reply,
330
447
  });
331
448
  // G2 observability: the reasoner produced the turn's final answer.
332
- this.bus.push(Route.Background, {
449
+ push(Route.Background, {
333
450
  kind: "delegate.result",
334
451
  contextId,
335
452
  timestampMs: answeredMs,
@@ -338,7 +455,7 @@ export class ReasoningBridge implements VoicePlugin {
338
455
  durationMs: answeredMs - queryStartedMs,
339
456
  grounded,
340
457
  });
341
- this.rememberTurn(userText, reply, contextId);
458
+ defer(() => this.rememberTurn(userText, reply, contextId));
342
459
  if (this.opts.runStore && resuming) {
343
460
  await Promise.resolve(this.opts.runStore.discard(contextId));
344
461
  }
@@ -349,7 +466,7 @@ export class ReasoningBridge implements VoicePlugin {
349
466
  const category = categorizeLlmError(err);
350
467
  const recoverable = isRecoverable(category);
351
468
  if (!recoverable || emittedDelta || attempt >= this.retryConfig.maxAttempts) {
352
- this.bus.push(Route.Critical, {
469
+ push(Route.Critical, {
353
470
  kind: "llm.error",
354
471
  contextId,
355
472
  timestampMs: Date.now(),
@@ -361,7 +478,7 @@ export class ReasoningBridge implements VoicePlugin {
361
478
  return;
362
479
  }
363
480
 
364
- this.bus.push(Route.Background, {
481
+ push(Route.Background, {
365
482
  kind: "metric.conversation",
366
483
  contextId,
367
484
  timestampMs: Date.now(),
@@ -379,21 +496,8 @@ export class ReasoningBridge implements VoicePlugin {
379
496
  }
380
497
  }
381
498
 
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
499
  async close(): Promise<void> {
500
+ this.discardDraft();
397
501
  this.activeGeneration?.controller.abort();
398
502
  this.activeGeneration = null;
399
503
  for (const dispose of this.disposers.splice(0)) dispose();