@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 +2 -2
- package/src/index.test.ts +141 -0
- package/src/index.ts +138 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/aisdk",
|
|
3
|
-
"version": "4.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|