@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/aisdk",
3
- "version": "3.0.0",
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": "3.0.0"
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 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 }> = [];
@@ -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: { runStore?: RunStore; onResumeConflict?: "restart" | "replay" } = {},
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
- validateFinalFinishReason(finishReason);
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: Date.now(),
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;