@kuralle-syrinx/aisdk 3.0.0 → 4.0.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 +212 -4
- package/src/index.ts +91 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/aisdk",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.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.0.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 }> = [];
|
|
@@ -617,6 +817,14 @@ function textDelta(text: string): TextStreamPart<ToolSet> {
|
|
|
617
817
|
return { type: "text-delta", id: "0", text, providerMetadata: undefined } as TextStreamPart<ToolSet>;
|
|
618
818
|
}
|
|
619
819
|
|
|
820
|
+
function toolCall(toolCallId: string, toolName: string, input: Record<string, unknown>): TextStreamPart<ToolSet> {
|
|
821
|
+
return { type: "tool-call", toolCallId, toolName, input } as TextStreamPart<ToolSet>;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function toolResult(toolCallId: string, toolName: string, output: unknown): TextStreamPart<ToolSet> {
|
|
825
|
+
return { type: "tool-result", toolCallId, toolName, input: {}, output } as TextStreamPart<ToolSet>;
|
|
826
|
+
}
|
|
827
|
+
|
|
620
828
|
function finish(finishReason: FinishReason, rawFinishReason?: string): TextStreamPart<ToolSet> {
|
|
621
829
|
return {
|
|
622
830
|
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
|
|
|
@@ -82,7 +84,18 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
82
84
|
|
|
83
85
|
constructor(
|
|
84
86
|
private readonly reasoner: Reasoner,
|
|
85
|
-
private readonly opts: {
|
|
87
|
+
private readonly opts: {
|
|
88
|
+
runStore?: RunStore;
|
|
89
|
+
onResumeConflict?: "restart" | "replay";
|
|
90
|
+
/**
|
|
91
|
+
* G4 durable session (RFC bimodel-delegate-seam): when set with `sessionId`, the
|
|
92
|
+
* bridge loads its conversation history from the store on initialize and persists
|
|
93
|
+
* the bounded snapshot after every committed (or interrupted-truncated) turn — a
|
|
94
|
+
* bridge re-created after host eviction resumes with the same context.
|
|
95
|
+
*/
|
|
96
|
+
sessionStore?: ReasonerSessionStore;
|
|
97
|
+
sessionId?: string;
|
|
98
|
+
} = {},
|
|
86
99
|
) {
|
|
87
100
|
if (this.opts.onResumeConflict === "replay") {
|
|
88
101
|
throw new Error("onResumeConflict 'replay' not yet supported — use 'restart'");
|
|
@@ -95,6 +108,13 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
95
108
|
this.maxHistoryTurns = readPositiveIntegerConfig(config["max_history_turns"], 12);
|
|
96
109
|
this.retryConfig = readRetryConfig(config);
|
|
97
110
|
|
|
111
|
+
// G4: resume from durable history — the reasoner's next turn sees the same
|
|
112
|
+
// context as before the eviction/reconnect (R6). Load-only: nothing is spoken.
|
|
113
|
+
if (this.opts.sessionStore && this.opts.sessionId) {
|
|
114
|
+
const stored = await this.opts.sessionStore.load(this.opts.sessionId);
|
|
115
|
+
this.history = stored.map((message) => ({ ...message }));
|
|
116
|
+
}
|
|
117
|
+
|
|
98
118
|
// Listen for EOS turn completions
|
|
99
119
|
this.disposers.push(
|
|
100
120
|
// Concurrent producer: a turn's LLM generation streams its own packets over
|
|
@@ -166,9 +186,22 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
166
186
|
let reply = "";
|
|
167
187
|
let emittedDelta = false;
|
|
168
188
|
let committed = false;
|
|
189
|
+
let grounded = false;
|
|
190
|
+
|
|
191
|
+
// G2 observability: the turn's query is on its way to the reasoner (Background
|
|
192
|
+
// route, droppable — RFC bimodel-delegate-seam R4). Cascade turns have no
|
|
193
|
+
// front-model tool call, so toolId/toolName are absent.
|
|
194
|
+
const queryStartedMs = Date.now();
|
|
195
|
+
this.bus.push(Route.Background, {
|
|
196
|
+
kind: "delegate.query",
|
|
197
|
+
contextId,
|
|
198
|
+
timestampMs: queryStartedMs,
|
|
199
|
+
query: userText,
|
|
200
|
+
});
|
|
169
201
|
|
|
170
202
|
try {
|
|
171
203
|
for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt += 1) {
|
|
204
|
+
grounded = false;
|
|
172
205
|
try {
|
|
173
206
|
const pending = this.opts.runStore
|
|
174
207
|
? await Promise.resolve(this.opts.runStore.takePending(contextId))
|
|
@@ -204,6 +237,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
204
237
|
});
|
|
205
238
|
break;
|
|
206
239
|
case "tool-result":
|
|
240
|
+
grounded = true;
|
|
207
241
|
this.bus.push(Route.Main, {
|
|
208
242
|
kind: "llm.tool_result",
|
|
209
243
|
contextId,
|
|
@@ -254,18 +288,56 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
254
288
|
}
|
|
255
289
|
}
|
|
256
290
|
|
|
257
|
-
|
|
291
|
+
// A non-"stop" finish must fail the TURN, never the call (L2). Killing the
|
|
292
|
+
// session on a token-cap or unfinished-tool-loop hangs up the caller
|
|
293
|
+
// mid-conversation. `length` = token cap: the streamed reply is truncated
|
|
294
|
+
// but usable, so accept it and continue (fall through to llm.done). Any
|
|
295
|
+
// other non-"stop" reason (tool loop ended, null) = fail the turn
|
|
296
|
+
// recoverably — the caller hears the graceful fallback, the call stays up.
|
|
297
|
+
if (finishReason !== "stop" && finishReason !== "length") {
|
|
298
|
+
if (signal.aborted) return;
|
|
299
|
+
this.bus.push(Route.Critical, {
|
|
300
|
+
kind: "llm.error",
|
|
301
|
+
contextId,
|
|
302
|
+
timestampMs: Date.now(),
|
|
303
|
+
component: "bridge" as const,
|
|
304
|
+
category: ErrorCategory.InternalFault,
|
|
305
|
+
cause: new Error(`AI SDK turn ended on finishReason "${finishReason ?? "null"}"`),
|
|
306
|
+
isRecoverable: true,
|
|
307
|
+
});
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (finishReason === "length") {
|
|
311
|
+
this.bus.push(Route.Background, {
|
|
312
|
+
kind: "metric.conversation",
|
|
313
|
+
contextId,
|
|
314
|
+
timestampMs: Date.now(),
|
|
315
|
+
name: "llm.finish_length_truncated",
|
|
316
|
+
value: "1",
|
|
317
|
+
});
|
|
318
|
+
}
|
|
258
319
|
|
|
259
320
|
// Interrupted as generation finished — the interrupt handler owns the history
|
|
260
321
|
// for this turn (spoken prefix); don't commit the full reply or emit llm.done.
|
|
261
322
|
if (signal.aborted) return;
|
|
262
323
|
|
|
324
|
+
const answeredMs = Date.now();
|
|
263
325
|
this.bus.push(Route.Main, {
|
|
264
326
|
kind: "llm.done",
|
|
265
327
|
contextId,
|
|
266
|
-
timestampMs:
|
|
328
|
+
timestampMs: answeredMs,
|
|
267
329
|
text: reply,
|
|
268
330
|
});
|
|
331
|
+
// G2 observability: the reasoner produced the turn's final answer.
|
|
332
|
+
this.bus.push(Route.Background, {
|
|
333
|
+
kind: "delegate.result",
|
|
334
|
+
contextId,
|
|
335
|
+
timestampMs: answeredMs,
|
|
336
|
+
query: userText,
|
|
337
|
+
answer: reply,
|
|
338
|
+
durationMs: answeredMs - queryStartedMs,
|
|
339
|
+
grounded,
|
|
340
|
+
});
|
|
269
341
|
this.rememberTurn(userText, reply, contextId);
|
|
270
342
|
if (this.opts.runStore && resuming) {
|
|
271
343
|
await Promise.resolve(this.opts.runStore.discard(contextId));
|
|
@@ -338,6 +410,21 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
338
410
|
this.history.push({ role: "user", content: userText }, assistantMsg);
|
|
339
411
|
this.assistantMsgByContext.set(contextId, assistantMsg);
|
|
340
412
|
this.trimHistory();
|
|
413
|
+
this.persistHistory();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** G4: persist the bounded history snapshot, best-effort off the hot path. */
|
|
417
|
+
private persistHistory(): void {
|
|
418
|
+
const store = this.opts.sessionStore;
|
|
419
|
+
const sessionId = this.opts.sessionId;
|
|
420
|
+
if (!store || !sessionId) return;
|
|
421
|
+
try {
|
|
422
|
+
void Promise.resolve(store.save(sessionId, this.history.map((message) => ({ ...message })))).catch(
|
|
423
|
+
() => undefined,
|
|
424
|
+
);
|
|
425
|
+
} catch {
|
|
426
|
+
/* persistence must never fail the turn */
|
|
427
|
+
}
|
|
341
428
|
}
|
|
342
429
|
|
|
343
430
|
/**
|
|
@@ -390,6 +477,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
390
477
|
name: "llm.history_truncated_to_spoken",
|
|
391
478
|
value: String(spoken.length),
|
|
392
479
|
});
|
|
480
|
+
this.persistHistory(); // G4: the durable snapshot reflects the heard prefix
|
|
393
481
|
this.clearTurnState(contextId);
|
|
394
482
|
}
|
|
395
483
|
|
|
@@ -413,17 +501,6 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
413
501
|
}
|
|
414
502
|
}
|
|
415
503
|
|
|
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
504
|
|
|
428
505
|
function readPositiveIntegerConfig(value: unknown, fallback: number): number {
|
|
429
506
|
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|