@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 +2 -2
- package/src/index.test.ts +353 -4
- package/src/index.ts +225 -44
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/aisdk",
|
|
3
|
-
"version": "
|
|
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": "
|
|
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
|
|
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:
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
441
|
+
const answeredMs = Date.now();
|
|
442
|
+
push(Route.Main, {
|
|
264
443
|
kind: "llm.done",
|
|
265
444
|
contextId,
|
|
266
|
-
timestampMs:
|
|
445
|
+
timestampMs: answeredMs,
|
|
267
446
|
text: reply,
|
|
268
447
|
});
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|