@kuralle-syrinx/aisdk 3.1.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": "3.1.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": "3.1.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
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
 
3
3
  import { describe, expect, it } from "vitest";
4
- import { PipelineBusImpl, Route } from "@kuralle-syrinx/core";
4
+ import { InMemoryReasonerSessionStore, PipelineBusImpl, Route } from "@kuralle-syrinx/core";
5
5
  import type {
6
6
  EndOfSpeechPacket,
7
7
  InterruptLlmPacket,
@@ -72,7 +72,75 @@ describe("ReasoningBridge", () => {
72
72
  });
73
73
  });
74
74
 
75
- it("emits llm.error instead of llm.done when provider reaches token limit", async () => {
75
+ it("G2/WBS-1: cascade turn emits delegate.query then delegate.result on the Background route", async () => {
76
+ const packets: Array<{ route: Route; packet: unknown }> = [];
77
+ const plugin = new ReasoningBridge(fromStreamFactory(async function* () {
78
+ yield toolCall("rag-1", "retrieve", { q: "deadline" });
79
+ yield toolResult("rag-1", "retrieve", "chunk");
80
+ yield textDelta("The deadline is March 31.");
81
+ yield finish("stop");
82
+ }));
83
+ const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
84
+ const drain = bus.start();
85
+
86
+ await plugin.initialize(bus, baseConfig());
87
+ bus.push(Route.Main, turnComplete("turn-1", "When is the deadline?"));
88
+
89
+ await waitFor(() => packets.some(({ packet }) => (packet as { kind?: string }).kind === "delegate.result"));
90
+ bus.stop();
91
+ await drain;
92
+ await plugin.close();
93
+
94
+ const delegatePackets = packets.filter(({ packet }) =>
95
+ String((packet as { kind?: string }).kind).startsWith("delegate."),
96
+ );
97
+ expect(delegatePackets.map(({ route }) => route)).toEqual([Route.Background, Route.Background]);
98
+ expect(delegatePackets[0]!.packet).toMatchObject({
99
+ kind: "delegate.query",
100
+ contextId: "turn-1",
101
+ query: "When is the deadline?",
102
+ });
103
+ expect((delegatePackets[0]!.packet as { toolName?: string }).toolName).toBeUndefined();
104
+ expect(delegatePackets[1]!.packet).toMatchObject({
105
+ kind: "delegate.result",
106
+ contextId: "turn-1",
107
+ query: "When is the deadline?",
108
+ answer: "The deadline is March 31.",
109
+ grounded: true,
110
+ });
111
+ expect((delegatePackets[1]!.packet as { durationMs: number }).durationMs).toBeGreaterThanOrEqual(0);
112
+ // delegate.query precedes the reasoner's first output.
113
+ const queryIndex = packets.findIndex(({ packet }) => (packet as { kind?: string }).kind === "delegate.query");
114
+ const firstDeltaIndex = packets.findIndex(({ packet }) => (packet as { kind?: string }).kind === "llm.delta");
115
+ expect(queryIndex).toBeGreaterThanOrEqual(0);
116
+ expect(queryIndex).toBeLessThan(firstDeltaIndex);
117
+ });
118
+
119
+ it("G2/WBS-1: cascade delegate.result grounded=false without tool use; none on error", async () => {
120
+ const packets: Array<{ route: Route; packet: unknown }> = [];
121
+ const plugin = new ReasoningBridge(fromStreamFactory(async function* () {
122
+ yield textDelta("From memory.");
123
+ yield finish("stop");
124
+ }));
125
+ const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
126
+ const drain = bus.start();
127
+
128
+ await plugin.initialize(bus, baseConfig());
129
+ bus.push(Route.Main, turnComplete("turn-1", "Hi"));
130
+
131
+ await waitFor(() => packets.some(({ packet }) => (packet as { kind?: string }).kind === "delegate.result"));
132
+ bus.stop();
133
+ await drain;
134
+ await plugin.close();
135
+
136
+ const result = packets.find(({ packet }) => (packet as { kind?: string }).kind === "delegate.result")!;
137
+ expect(result.packet).toMatchObject({ grounded: false, answer: "From memory." });
138
+ });
139
+
140
+ it("accepts the truncated reply on token-limit finish (fails the turn, never the call)", async () => {
141
+ // A `length` finish means the model hit the token cap: the streamed reply is
142
+ // truncated but usable. It must be spoken and the call kept up (L2) — never
143
+ // escalated to a session-killing llm.error.
76
144
  const packets: Array<{ route: Route; packet: unknown }> = [];
77
145
  const plugin = new ReasoningBridge(fromStreamFactory(async function* () {
78
146
  yield textDelta("This answer is incomplete");
@@ -84,20 +152,48 @@ describe("ReasoningBridge", () => {
84
152
  await plugin.initialize(bus, baseConfig());
85
153
  bus.push(Route.Main, turnComplete("turn-1", "Hi"));
86
154
 
155
+ await waitFor(() => packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done"));
156
+ bus.stop();
157
+ await drain;
158
+ await plugin.close();
159
+
160
+ // The partial reply is committed as a normal turn completion.
161
+ expect(packets).toContainEqual({
162
+ route: Route.Main,
163
+ packet: expect.objectContaining({ kind: "llm.done", contextId: "turn-1", text: "This answer is incomplete" }),
164
+ });
165
+ // No session-killing error was emitted.
166
+ expect(packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.error")).toBe(false);
167
+ // The truncation is observable for telemetry.
168
+ expect(packets.some(({ packet }) => (packet as { kind?: string; name?: string }).name === "llm.finish_length_truncated")).toBe(true);
169
+ });
170
+
171
+ it("fails the turn recoverably (not the call) on an unfinished tool-loop finish", async () => {
172
+ const packets: Array<{ route: Route; packet: unknown }> = [];
173
+ const plugin = new ReasoningBridge(fromStreamFactory(async function* () {
174
+ yield textDelta("partial");
175
+ yield finish("tool-calls");
176
+ }));
177
+ const bus = new PipelineBusImpl({ onPacket: (route, packet) => packets.push({ route, packet }) });
178
+ const drain = bus.start();
179
+
180
+ await plugin.initialize(bus, baseConfig());
181
+ bus.push(Route.Main, turnComplete("turn-1", "Hi"));
182
+
87
183
  await waitFor(() => packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.error"));
88
184
  bus.stop();
89
185
  await drain;
90
186
  await plugin.close();
91
187
 
92
- expect(packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done")).toBe(false);
93
188
  expect(packets).toContainEqual({
94
189
  route: Route.Critical,
95
190
  packet: expect.objectContaining({
96
191
  kind: "llm.error",
97
192
  contextId: "turn-1",
98
- isRecoverable: false,
193
+ isRecoverable: true, // recoverable → fallback spoken, session stays open
99
194
  } satisfies Partial<LlmErrorPacket>),
100
195
  });
196
+ expect(packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done")).toBe(false);
101
197
  });
102
198
 
103
199
  it("emits llm.error when the stream ends without finish metadata", async () => {
@@ -394,6 +490,110 @@ describe("ReasoningBridge", () => {
394
490
  });
395
491
  });
396
492
 
493
+ describe("ReasoningBridge durable session (G4/WBS-4)", () => {
494
+ it("re-seeds context from the session store after a simulated eviction; no double-answer", async () => {
495
+ const store = new InMemoryReasonerSessionStore();
496
+
497
+ // First lifetime: one committed turn, then the host is evicted (bridge closed).
498
+ const first = new ReasoningBridge(
499
+ fromStreamFactory(async function* () {
500
+ yield textDelta("Answer one.");
501
+ yield finish("stop");
502
+ }),
503
+ { sessionStore: store, sessionId: "s1" },
504
+ );
505
+ const firstPackets: Array<{ packet: unknown }> = [];
506
+ const firstBus = new PipelineBusImpl({ onPacket: (_route, packet) => firstPackets.push({ packet }) });
507
+ const firstDrain = firstBus.start();
508
+ await first.initialize(firstBus, baseConfig());
509
+ firstBus.push(Route.Main, turnComplete("turn-1", "First question"));
510
+ await waitFor(() => firstPackets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done"));
511
+ firstBus.stop();
512
+ await firstDrain;
513
+ await first.close();
514
+
515
+ // Second lifetime: a fresh bridge over the same store must hand the reasoner
516
+ // the prior turn as context — and must not re-answer it.
517
+ const seenMessages: Array<ReasonerTurn["messages"]> = [];
518
+ const secondReasoner: Reasoner = {
519
+ stream: (turn) => {
520
+ seenMessages.push([...turn.messages]);
521
+ return (async function* (): AsyncGenerator<ReasoningPart> {
522
+ yield { type: "text-delta", text: "Answer two." };
523
+ yield { type: "finish", reason: "stop", text: "Answer two." };
524
+ })();
525
+ },
526
+ };
527
+ const second = new ReasoningBridge(secondReasoner, { sessionStore: store, sessionId: "s1" });
528
+ const packets: Array<{ packet: unknown }> = [];
529
+ const bus = new PipelineBusImpl({ onPacket: (_route, packet) => packets.push({ packet }) });
530
+ const drain = bus.start();
531
+ await second.initialize(bus, baseConfig());
532
+ // Nothing speaks spontaneously on resume (no double-answer).
533
+ expect(packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done")).toBe(false);
534
+
535
+ bus.push(Route.Main, turnComplete("turn-2", "Second question"));
536
+ await waitFor(() => packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done"));
537
+ bus.stop();
538
+ await drain;
539
+ await second.close();
540
+
541
+ expect(seenMessages[0]).toEqual([
542
+ { role: "user", content: "First question" },
543
+ { role: "assistant", content: "Answer one." },
544
+ ]);
545
+ // The store now carries both turns for the next resume.
546
+ expect(store.load("s1")).toEqual([
547
+ { role: "user", content: "First question" },
548
+ { role: "assistant", content: "Answer one." },
549
+ { role: "user", content: "Second question" },
550
+ { role: "assistant", content: "Answer two." },
551
+ ]);
552
+ });
553
+
554
+ it("persists the interrupted turn's history as the heard prefix", async () => {
555
+ const store = new InMemoryReasonerSessionStore();
556
+ const plugin = new ReasoningBridge(
557
+ fromStreamFactory(async function* () {
558
+ yield textDelta("Full generated reply that was cut off.");
559
+ yield finish("stop");
560
+ }),
561
+ { sessionStore: store, sessionId: "s1" },
562
+ );
563
+ const packets: Array<{ packet: unknown }> = [];
564
+ const bus = new PipelineBusImpl({ onPacket: (_route, packet) => packets.push({ packet }) });
565
+ const drain = bus.start();
566
+ await plugin.initialize(bus, baseConfig());
567
+
568
+ bus.push(Route.Main, turnComplete("turn-1", "Hi"));
569
+ await waitFor(() => packets.some(({ packet }) => (packet as { kind?: string }).kind === "llm.done"));
570
+ // What actually reached TTS before the barge-in.
571
+ bus.push(Route.Main, {
572
+ kind: "tts.text",
573
+ contextId: "turn-1",
574
+ timestampMs: Date.now(),
575
+ text: "Full generated",
576
+ } satisfies TextToSpeechTextPacket);
577
+ await waitFor(() => packets.some(({ packet }) => (packet as { kind?: string }).kind === "tts.text"));
578
+ bus.push(Route.Critical, {
579
+ kind: "interrupt.llm",
580
+ contextId: "turn-1",
581
+ timestampMs: Date.now(),
582
+ } satisfies InterruptLlmPacket);
583
+ await waitFor(() =>
584
+ packets.some(({ packet }) => (packet as { name?: string }).name === "llm.history_truncated_to_spoken"),
585
+ );
586
+ bus.stop();
587
+ await drain;
588
+ await plugin.close();
589
+
590
+ expect(store.load("s1")).toEqual([
591
+ { role: "user", content: "Hi" },
592
+ { role: "assistant", content: "Full generated" },
593
+ ]);
594
+ });
595
+ });
596
+
397
597
  describe("ReasoningBridge suspend/resume", () => {
398
598
  it("clean suspend → resume: saves pointer, resumes with userText, discards on finish", async () => {
399
599
  const packets: Array<{ route: Route; packet: unknown }> = [];
@@ -593,6 +793,147 @@ function interruptLlm(contextId: string): InterruptLlmPacket {
593
793
  return { kind: "interrupt.llm", contextId, timestampMs: Date.now() };
594
794
  }
595
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
+
596
937
  function baseConfig(): Record<string, unknown> {
597
938
  return {
598
939
  api_key: "test-key",
@@ -617,6 +958,14 @@ function textDelta(text: string): TextStreamPart<ToolSet> {
617
958
  return { type: "text-delta", id: "0", text, providerMetadata: undefined } as TextStreamPart<ToolSet>;
618
959
  }
619
960
 
961
+ function toolCall(toolCallId: string, toolName: string, input: Record<string, unknown>): TextStreamPart<ToolSet> {
962
+ return { type: "tool-call", toolCallId, toolName, input } as TextStreamPart<ToolSet>;
963
+ }
964
+
965
+ function toolResult(toolCallId: string, toolName: string, output: unknown): TextStreamPart<ToolSet> {
966
+ return { type: "tool-result", toolCallId, toolName, input: {}, output } as TextStreamPart<ToolSet>;
967
+ }
968
+
620
969
  function finish(finishReason: FinishReason, rawFinishReason?: string): TextStreamPart<ToolSet> {
621
970
  return {
622
971
  type: "finish",
package/src/index.ts CHANGED
@@ -17,12 +17,14 @@ import {
17
17
  type VoicePlugin,
18
18
  type PluginConfig,
19
19
  type Reasoner,
20
+ type ReasonerSessionStore,
20
21
  type ReasonerTurn,
21
22
  type TtsWordTimestamp,
22
23
  categorizeLlmError,
23
24
  isRecoverable,
24
25
  readRetryConfig,
25
26
  waitForRetryDelay,
27
+ ErrorCategory,
26
28
  type RetryConfig,
27
29
  } from "@kuralle-syrinx/core";
28
30
 
@@ -51,12 +53,31 @@ export interface RunStore {
51
53
  discard(contextId: string): void | Promise<void>;
52
54
  }
53
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
+
54
68
  export class ReasoningBridge implements VoicePlugin {
55
69
  private bus: PipelineBus | null = null;
56
70
  private timeoutMs: number = 30_000;
57
71
  private maxHistoryTurns: number = 12;
58
72
  private history: Array<{ role: "system" | "user" | "assistant" | "tool"; content: string; toolCallId?: string }> = [];
59
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;
60
81
  private retryConfig: RetryConfig = readRetryConfig({});
61
82
  private disposers: Array<() => void> = [];
62
83
  // G2/G25: per-turn state so a barged-in turn is remembered as what the user HEARD,
@@ -82,7 +103,29 @@ export class ReasoningBridge implements VoicePlugin {
82
103
 
83
104
  constructor(
84
105
  private readonly reasoner: Reasoner,
85
- private readonly opts: { runStore?: RunStore; onResumeConflict?: "restart" | "replay" } = {},
106
+ private readonly opts: {
107
+ runStore?: RunStore;
108
+ onResumeConflict?: "restart" | "replay";
109
+ /**
110
+ * G4 durable session (RFC bimodel-delegate-seam): when set with `sessionId`, the
111
+ * bridge loads its conversation history from the store on initialize and persists
112
+ * the bounded snapshot after every committed (or interrupted-truncated) turn — a
113
+ * bridge re-created after host eviction resumes with the same context.
114
+ */
115
+ sessionStore?: ReasonerSessionStore;
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;
128
+ } = {},
86
129
  ) {
87
130
  if (this.opts.onResumeConflict === "replay") {
88
131
  throw new Error("onResumeConflict 'replay' not yet supported — use 'restart'");
@@ -95,6 +138,13 @@ export class ReasoningBridge implements VoicePlugin {
95
138
  this.maxHistoryTurns = readPositiveIntegerConfig(config["max_history_turns"], 12);
96
139
  this.retryConfig = readRetryConfig(config);
97
140
 
141
+ // G4: resume from durable history — the reasoner's next turn sees the same
142
+ // context as before the eviction/reconnect (R6). Load-only: nothing is spoken.
143
+ if (this.opts.sessionStore && this.opts.sessionId) {
144
+ const stored = await this.opts.sessionStore.load(this.opts.sessionId);
145
+ this.history = stored.map((message) => ({ ...message }));
146
+ }
147
+
98
148
  // Listen for EOS turn completions
99
149
  this.disposers.push(
100
150
  // Concurrent producer: a turn's LLM generation streams its own packets over
@@ -105,6 +155,26 @@ export class ReasoningBridge implements VoicePlugin {
105
155
  // still-in-flight generation (see below).
106
156
  bus.on("eos.turn_complete", async (pkt: unknown) => {
107
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();
108
178
  await this.processTurn(eos.text, eos.contextId);
109
179
  }, { concurrent: true }),
110
180
 
@@ -139,6 +209,7 @@ export class ReasoningBridge implements VoicePlugin {
139
209
  // said words the user never heard (nor amnesiac about the exchange).
140
210
  bus.on("interrupt.llm", (pkt: unknown) => {
141
211
  const contextId = (pkt as { contextId: string }).contextId;
212
+ if (this.speculativeDraft?.contextId === contextId) this.discardDraft();
142
213
  if (this.activeGeneration?.contextId === contextId) {
143
214
  this.activeGeneration.controller.abort();
144
215
  this.activeGeneration = null;
@@ -149,9 +220,45 @@ export class ReasoningBridge implements VoicePlugin {
149
220
  }
150
221
  }),
151
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);
152
247
  }
153
248
 
154
- 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> {
155
262
  if (!this.bus) return;
156
263
 
157
264
  this.turnUserText.set(contextId, userText);
@@ -159,18 +266,49 @@ export class ReasoningBridge implements VoicePlugin {
159
266
  // Handlers are concurrent, so a new turn can begin while a prior generation is
160
267
  // still in flight. Supersede it: abort the previous controller before starting.
161
268
  this.activeGeneration?.controller.abort();
162
- const controller = new AbortController();
269
+ const controller = presetController ?? new AbortController();
163
270
  this.activeGeneration = { contextId, controller };
164
271
  const signal = controller.signal;
165
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
+
166
289
  let reply = "";
167
290
  let emittedDelta = false;
168
291
  let committed = false;
292
+ let grounded = false;
293
+
294
+ // G2 observability: the turn's query is on its way to the reasoner (Background
295
+ // route, droppable — RFC bimodel-delegate-seam R4). Cascade turns have no
296
+ // front-model tool call, so toolId/toolName are absent.
297
+ const queryStartedMs = Date.now();
298
+ push(Route.Background, {
299
+ kind: "delegate.query",
300
+ contextId,
301
+ timestampMs: queryStartedMs,
302
+ query: userText,
303
+ });
169
304
 
170
305
  try {
171
306
  for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt += 1) {
307
+ grounded = false;
172
308
  try {
173
- 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
174
312
  ? await Promise.resolve(this.opts.runStore.takePending(contextId))
175
313
  : null;
176
314
  const resuming = pending !== null;
@@ -186,7 +324,7 @@ export class ReasoningBridge implements VoicePlugin {
186
324
  case "text-delta":
187
325
  reply += part.text;
188
326
  emittedDelta = true;
189
- this.bus.push(Route.Main, {
327
+ push(Route.Main, {
190
328
  kind: "llm.delta",
191
329
  contextId,
192
330
  timestampMs: Date.now(),
@@ -194,7 +332,7 @@ export class ReasoningBridge implements VoicePlugin {
194
332
  });
195
333
  break;
196
334
  case "tool-call":
197
- this.bus.push(Route.Main, {
335
+ push(Route.Main, {
198
336
  kind: "llm.tool_call",
199
337
  contextId,
200
338
  timestampMs: Date.now(),
@@ -204,7 +342,8 @@ export class ReasoningBridge implements VoicePlugin {
204
342
  });
205
343
  break;
206
344
  case "tool-result":
207
- this.bus.push(Route.Main, {
345
+ grounded = true;
346
+ push(Route.Main, {
208
347
  kind: "llm.tool_result",
209
348
  contextId,
210
349
  timestampMs: Date.now(),
@@ -216,12 +355,18 @@ export class ReasoningBridge implements VoicePlugin {
216
355
  case "error":
217
356
  throw part.cause;
218
357
  case "finish":
219
- 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
+ });
220
365
  finishReason = part.reason;
221
366
  break;
222
367
  case "suspended": {
223
368
  if (part.prompt && !emittedDelta) {
224
- this.bus.push(Route.Main, {
369
+ push(Route.Main, {
225
370
  kind: "llm.delta",
226
371
  contextId,
227
372
  timestampMs: Date.now(),
@@ -230,14 +375,14 @@ export class ReasoningBridge implements VoicePlugin {
230
375
  reply += part.prompt;
231
376
  }
232
377
  if (signal.aborted) return;
233
- this.bus.push(Route.Main, {
378
+ push(Route.Main, {
234
379
  kind: "llm.done",
235
380
  contextId,
236
381
  timestampMs: Date.now(),
237
382
  text: reply,
238
383
  });
239
- this.rememberTurn(userText, reply, contextId);
240
- this.bus.push(Route.Background, {
384
+ defer(() => this.rememberTurn(userText, reply, contextId));
385
+ push(Route.Background, {
241
386
  kind: "reasoning.suspended",
242
387
  contextId,
243
388
  timestampMs: Date.now(),
@@ -246,7 +391,13 @@ export class ReasoningBridge implements VoicePlugin {
246
391
  payload: part.payload,
247
392
  });
248
393
  if (this.opts.runStore) {
249
- 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
+ }
250
401
  }
251
402
  committed = true;
252
403
  return;
@@ -254,19 +405,57 @@ export class ReasoningBridge implements VoicePlugin {
254
405
  }
255
406
  }
256
407
 
257
- validateFinalFinishReason(finishReason);
408
+ // A non-"stop" finish must fail the TURN, never the call (L2). Killing the
409
+ // session on a token-cap or unfinished-tool-loop hangs up the caller
410
+ // mid-conversation. `length` = token cap: the streamed reply is truncated
411
+ // but usable, so accept it and continue (fall through to llm.done). Any
412
+ // other non-"stop" reason (tool loop ended, null) = fail the turn
413
+ // recoverably — the caller hears the graceful fallback, the call stays up.
414
+ if (finishReason !== "stop" && finishReason !== "length") {
415
+ if (signal.aborted) return;
416
+ push(Route.Critical, {
417
+ kind: "llm.error",
418
+ contextId,
419
+ timestampMs: Date.now(),
420
+ component: "bridge" as const,
421
+ category: ErrorCategory.InternalFault,
422
+ cause: new Error(`AI SDK turn ended on finishReason "${finishReason ?? "null"}"`),
423
+ isRecoverable: true,
424
+ });
425
+ return;
426
+ }
427
+ if (finishReason === "length") {
428
+ push(Route.Background, {
429
+ kind: "metric.conversation",
430
+ contextId,
431
+ timestampMs: Date.now(),
432
+ name: "llm.finish_length_truncated",
433
+ value: "1",
434
+ });
435
+ }
258
436
 
259
437
  // Interrupted as generation finished — the interrupt handler owns the history
260
438
  // for this turn (spoken prefix); don't commit the full reply or emit llm.done.
261
439
  if (signal.aborted) return;
262
440
 
263
- this.bus.push(Route.Main, {
441
+ const answeredMs = Date.now();
442
+ push(Route.Main, {
264
443
  kind: "llm.done",
265
444
  contextId,
266
- timestampMs: Date.now(),
445
+ timestampMs: answeredMs,
267
446
  text: reply,
268
447
  });
269
- this.rememberTurn(userText, reply, contextId);
448
+ // G2 observability: the reasoner produced the turn's final answer.
449
+ push(Route.Background, {
450
+ kind: "delegate.result",
451
+ contextId,
452
+ timestampMs: answeredMs,
453
+ query: userText,
454
+ answer: reply,
455
+ durationMs: answeredMs - queryStartedMs,
456
+ grounded,
457
+ });
458
+ defer(() => this.rememberTurn(userText, reply, contextId));
270
459
  if (this.opts.runStore && resuming) {
271
460
  await Promise.resolve(this.opts.runStore.discard(contextId));
272
461
  }
@@ -277,7 +466,7 @@ export class ReasoningBridge implements VoicePlugin {
277
466
  const category = categorizeLlmError(err);
278
467
  const recoverable = isRecoverable(category);
279
468
  if (!recoverable || emittedDelta || attempt >= this.retryConfig.maxAttempts) {
280
- this.bus.push(Route.Critical, {
469
+ push(Route.Critical, {
281
470
  kind: "llm.error",
282
471
  contextId,
283
472
  timestampMs: Date.now(),
@@ -289,7 +478,7 @@ export class ReasoningBridge implements VoicePlugin {
289
478
  return;
290
479
  }
291
480
 
292
- this.bus.push(Route.Background, {
481
+ push(Route.Background, {
293
482
  kind: "metric.conversation",
294
483
  contextId,
295
484
  timestampMs: Date.now(),
@@ -307,21 +496,8 @@ export class ReasoningBridge implements VoicePlugin {
307
496
  }
308
497
  }
309
498
 
310
- private recordFinishReason(
311
- contextId: string,
312
- name: string,
313
- finishReason: "stop" | "tool" | "length",
314
- ): void {
315
- this.bus?.push(Route.Background, {
316
- kind: "metric.conversation",
317
- contextId,
318
- timestampMs: Date.now(),
319
- name,
320
- value: finishReason,
321
- });
322
- }
323
-
324
499
  async close(): Promise<void> {
500
+ this.discardDraft();
325
501
  this.activeGeneration?.controller.abort();
326
502
  this.activeGeneration = null;
327
503
  for (const dispose of this.disposers.splice(0)) dispose();
@@ -338,6 +514,21 @@ export class ReasoningBridge implements VoicePlugin {
338
514
  this.history.push({ role: "user", content: userText }, assistantMsg);
339
515
  this.assistantMsgByContext.set(contextId, assistantMsg);
340
516
  this.trimHistory();
517
+ this.persistHistory();
518
+ }
519
+
520
+ /** G4: persist the bounded history snapshot, best-effort off the hot path. */
521
+ private persistHistory(): void {
522
+ const store = this.opts.sessionStore;
523
+ const sessionId = this.opts.sessionId;
524
+ if (!store || !sessionId) return;
525
+ try {
526
+ void Promise.resolve(store.save(sessionId, this.history.map((message) => ({ ...message })))).catch(
527
+ () => undefined,
528
+ );
529
+ } catch {
530
+ /* persistence must never fail the turn */
531
+ }
341
532
  }
342
533
 
343
534
  /**
@@ -390,6 +581,7 @@ export class ReasoningBridge implements VoicePlugin {
390
581
  name: "llm.history_truncated_to_spoken",
391
582
  value: String(spoken.length),
392
583
  });
584
+ this.persistHistory(); // G4: the durable snapshot reflects the heard prefix
393
585
  this.clearTurnState(contextId);
394
586
  }
395
587
 
@@ -413,17 +605,6 @@ export class ReasoningBridge implements VoicePlugin {
413
605
  }
414
606
  }
415
607
 
416
- function validateFinalFinishReason(finishReason: "stop" | "tool" | "length" | null): void {
417
- if (finishReason === null) {
418
- throw new Error("AI SDK stream ended without a provider finish reason");
419
- }
420
- if (finishReason === "length") {
421
- throw new Error("AI SDK provider reached token limit before completing");
422
- }
423
- if (finishReason !== "stop") {
424
- throw new Error("AI SDK provider did not complete normally");
425
- }
426
- }
427
608
 
428
609
  function readPositiveIntegerConfig(value: unknown, fallback: number): number {
429
610
  if (typeof value !== "number" || !Number.isFinite(value)) return fallback;