@kuralle-syrinx/aisdk 2.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/src/index.ts ADDED
@@ -0,0 +1,480 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — AI SDK Bridge Plugin
4
+ //
5
+ // Bridges the PipelineBus to Vercel AI SDK for LLM inference.
6
+ // Listens for EOS turn completions, calls LLM, pushes deltas + done + tool calls
7
+ // into the bus. Handles LLM interrupts via AbortController.
8
+
9
+ import type { PipelineBus } from "@kuralle-syrinx/core";
10
+ import {
11
+ type ModelMessage,
12
+ type TextStreamPart,
13
+ type ToolSet,
14
+ } from "ai";
15
+ import {
16
+ Route,
17
+ type VoicePlugin,
18
+ type PluginConfig,
19
+ type Reasoner,
20
+ type ReasonerTurn,
21
+ type TtsWordTimestamp,
22
+ categorizeLlmError,
23
+ isRecoverable,
24
+ readRetryConfig,
25
+ waitForRetryDelay,
26
+ type RetryConfig,
27
+ } from "@kuralle-syrinx/core";
28
+
29
+ export {
30
+ fromAiSdkAgent,
31
+ fromStreamText,
32
+ fromStreamFactory,
33
+ type AiSdkAgentLike,
34
+ type StreamTextConfig,
35
+ } from "./from-ai-sdk.js";
36
+
37
+ export type AISDKBridgeTools = ToolSet;
38
+ export type AISDKStreamFactory = (request: {
39
+ userText: string;
40
+ signal: AbortSignal;
41
+ messages: ModelMessage[];
42
+ }) => AsyncGenerator<TextStreamPart<ToolSet>>;
43
+
44
+ export interface RunPointer {
45
+ readonly runId: string;
46
+ }
47
+
48
+ export interface RunStore {
49
+ save(contextId: string, runId: string): void | Promise<void>;
50
+ takePending(contextId: string): RunPointer | null | Promise<RunPointer | null>;
51
+ discard(contextId: string): void | Promise<void>;
52
+ }
53
+
54
+ export class ReasoningBridge implements VoicePlugin {
55
+ private bus: PipelineBus | null = null;
56
+ private timeoutMs: number = 30_000;
57
+ private maxHistoryTurns: number = 12;
58
+ private history: Array<{ role: "system" | "user" | "assistant" | "tool"; content: string; toolCallId?: string }> = [];
59
+ private activeGeneration: { contextId: string; controller: AbortController } | null = null;
60
+ private retryConfig: RetryConfig = readRetryConfig({});
61
+ private disposers: Array<() => void> = [];
62
+ // G2/G25: per-turn state so a barged-in turn is remembered as what the user HEARD,
63
+ // not the full generated reply. Precision ladder:
64
+ // 1. Word timestamps (tts.word_timestamps) + playout position (tts.playout_progress)
65
+ // → exact spoken prefix at word boundaries.
66
+ // 2. Fallback: spokenByContext (text sent to TTS) — approximate; may include audio
67
+ // that was queued but not yet played out (TTS streams faster than realtime).
68
+ // `spokenByContext` accumulates tts.text; `assistantMsgByContext` holds the live
69
+ // history message object so it can be rewritten in place; `turnUserText` lets a
70
+ // mid-generation interrupt still record the turn.
71
+ private spokenByContext = new Map<string, string>();
72
+ private turnUserText = new Map<string, string>();
73
+ private assistantMsgByContext = new Map<string, { role: "assistant"; content: string }>();
74
+ // G25: word-level timestamps from TTS plugin (cumulative from context audio start).
75
+ private wordTimestampsByContext = new Map<string, TtsWordTimestamp[]>();
76
+ // G25: latest playout position (ms from context audio start) from the paced transport.
77
+ // Present whenever a paced transport is wired — this includes the browser WebSocket
78
+ // path (it routes through the shared paced playout pipeline + PlayoutProgressEmitter)
79
+ // as well as telnyx/twilio/smartpbx. Only headless-direct (no playout clock) falls
80
+ // back to spokenByContext.
81
+ private playedOutMsByContext = new Map<string, number>();
82
+
83
+ constructor(
84
+ private readonly reasoner: Reasoner,
85
+ private readonly opts: { runStore?: RunStore; onResumeConflict?: "restart" | "replay" } = {},
86
+ ) {
87
+ if (this.opts.onResumeConflict === "replay") {
88
+ throw new Error("onResumeConflict 'replay' not yet supported — use 'restart'");
89
+ }
90
+ }
91
+
92
+ async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
93
+ this.bus = bus;
94
+ this.timeoutMs = readPositiveIntegerConfig(config["timeout_ms"], 30_000);
95
+ this.maxHistoryTurns = readPositiveIntegerConfig(config["max_history_turns"], 12);
96
+ this.retryConfig = readRetryConfig(config);
97
+
98
+ // Listen for EOS turn completions
99
+ this.disposers.push(
100
+ // Concurrent producer: a turn's LLM generation streams its own packets over
101
+ // (potentially) several seconds. Running it fire-and-forget keeps the pipeline
102
+ // bus drain loop free, so the llm.delta -> tts.text streaming it produces is
103
+ // dispatched as it arrives (not deferred until generation ends), and Critical
104
+ // interrupts are handled promptly mid-generation. processTurn supersedes any
105
+ // still-in-flight generation (see below).
106
+ bus.on("eos.turn_complete", async (pkt: unknown) => {
107
+ const eos = pkt as { text: string; contextId: string };
108
+ await this.processTurn(eos.text, eos.contextId);
109
+ }, { concurrent: true }),
110
+
111
+ // Track what was actually sent to TTS (fallback spoken approximation), per turn.
112
+ bus.on("tts.text", (pkt: unknown) => {
113
+ const t = pkt as { contextId: string; text: string };
114
+ this.spokenByContext.set(t.contextId, (this.spokenByContext.get(t.contextId) ?? "") + t.text);
115
+ }),
116
+
117
+ // G25: accumulate word-level timestamps from the TTS plugin (Cartesia etc.).
118
+ // These arrive as cumulative offsets from the context audio start and enable
119
+ // word-boundary precision when computing the spoken prefix on barge-in.
120
+ bus.on("tts.word_timestamps", (pkt: unknown) => {
121
+ const t = pkt as { contextId: string; words: TtsWordTimestamp[] };
122
+ const existing = this.wordTimestampsByContext.get(t.contextId);
123
+ if (existing) {
124
+ for (const w of t.words) existing.push(w);
125
+ } else {
126
+ this.wordTimestampsByContext.set(t.contextId, [...t.words]);
127
+ }
128
+ }),
129
+
130
+ // G25: track realtime playout position from the paced transport. Absent on
131
+ // headless/browser paths; in that case we fall back to spokenByContext.
132
+ bus.on("tts.playout_progress", (pkt: unknown) => {
133
+ const p = pkt as { contextId: string; playedOutMs: number };
134
+ this.playedOutMsByContext.set(p.contextId, p.playedOutMs);
135
+ }),
136
+
137
+ // Listen for LLM interrupts. Abort generation AND rewrite the interrupted turn's
138
+ // history to the spoken prefix (G2/G25), so the model isn't left believing it
139
+ // said words the user never heard (nor amnesiac about the exchange).
140
+ bus.on("interrupt.llm", (pkt: unknown) => {
141
+ const contextId = (pkt as { contextId: string }).contextId;
142
+ if (this.activeGeneration?.contextId === contextId) {
143
+ this.activeGeneration.controller.abort();
144
+ this.activeGeneration = null;
145
+ }
146
+ this.commitInterruptedHistory(contextId);
147
+ if (this.opts.runStore && this.opts.onResumeConflict !== "replay") {
148
+ void Promise.resolve(this.opts.runStore.discard(contextId)).catch(() => undefined);
149
+ }
150
+ }),
151
+ );
152
+ }
153
+
154
+ private async processTurn(userText: string, contextId: string): Promise<void> {
155
+ if (!this.bus) return;
156
+
157
+ this.turnUserText.set(contextId, userText);
158
+
159
+ // Handlers are concurrent, so a new turn can begin while a prior generation is
160
+ // still in flight. Supersede it: abort the previous controller before starting.
161
+ this.activeGeneration?.controller.abort();
162
+ const controller = new AbortController();
163
+ this.activeGeneration = { contextId, controller };
164
+ const signal = controller.signal;
165
+
166
+ let reply = "";
167
+ let emittedDelta = false;
168
+ let committed = false;
169
+
170
+ try {
171
+ for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt += 1) {
172
+ try {
173
+ const pending = this.opts.runStore
174
+ ? await Promise.resolve(this.opts.runStore.takePending(contextId))
175
+ : null;
176
+ const resuming = pending !== null;
177
+ const turn: ReasonerTurn = pending
178
+ ? { userText, messages: this.history, signal, resume: { runId: pending.runId, data: userText } }
179
+ : { userText, messages: this.history, signal };
180
+
181
+ let finishReason: "stop" | "tool" | "length" | null = null;
182
+
183
+ for await (const part of withStreamIdleTimeout(this.reasoner.stream(turn), this.timeoutMs, signal)) {
184
+ if (signal.aborted) return;
185
+ switch (part.type) {
186
+ case "text-delta":
187
+ reply += part.text;
188
+ emittedDelta = true;
189
+ this.bus.push(Route.Main, {
190
+ kind: "llm.delta",
191
+ contextId,
192
+ timestampMs: Date.now(),
193
+ text: part.text,
194
+ });
195
+ break;
196
+ case "tool-call":
197
+ this.bus.push(Route.Main, {
198
+ kind: "llm.tool_call",
199
+ contextId,
200
+ timestampMs: Date.now(),
201
+ toolId: part.toolId,
202
+ toolName: part.toolName,
203
+ toolArgs: part.args,
204
+ });
205
+ break;
206
+ case "tool-result":
207
+ this.bus.push(Route.Main, {
208
+ kind: "llm.tool_result",
209
+ contextId,
210
+ timestampMs: Date.now(),
211
+ toolId: part.toolId,
212
+ toolName: part.toolName,
213
+ result: part.result,
214
+ });
215
+ break;
216
+ case "error":
217
+ throw part.cause;
218
+ case "finish":
219
+ this.recordFinishReason(contextId, "llm.finish_reason", part.reason);
220
+ finishReason = part.reason;
221
+ break;
222
+ case "suspended": {
223
+ if (part.prompt && !emittedDelta) {
224
+ this.bus.push(Route.Main, {
225
+ kind: "llm.delta",
226
+ contextId,
227
+ timestampMs: Date.now(),
228
+ text: part.prompt,
229
+ });
230
+ reply += part.prompt;
231
+ }
232
+ if (signal.aborted) return;
233
+ this.bus.push(Route.Main, {
234
+ kind: "llm.done",
235
+ contextId,
236
+ timestampMs: Date.now(),
237
+ text: reply,
238
+ });
239
+ this.rememberTurn(userText, reply, contextId);
240
+ this.bus.push(Route.Background, {
241
+ kind: "reasoning.suspended",
242
+ contextId,
243
+ timestampMs: Date.now(),
244
+ runId: part.runId,
245
+ prompt: part.prompt,
246
+ payload: part.payload,
247
+ });
248
+ if (this.opts.runStore) {
249
+ await Promise.resolve(this.opts.runStore.save(contextId, part.runId));
250
+ }
251
+ committed = true;
252
+ return;
253
+ }
254
+ }
255
+ }
256
+
257
+ validateFinalFinishReason(finishReason);
258
+
259
+ // Interrupted as generation finished — the interrupt handler owns the history
260
+ // for this turn (spoken prefix); don't commit the full reply or emit llm.done.
261
+ if (signal.aborted) return;
262
+
263
+ this.bus.push(Route.Main, {
264
+ kind: "llm.done",
265
+ contextId,
266
+ timestampMs: Date.now(),
267
+ text: reply,
268
+ });
269
+ this.rememberTurn(userText, reply, contextId);
270
+ if (this.opts.runStore && resuming) {
271
+ await Promise.resolve(this.opts.runStore.discard(contextId));
272
+ }
273
+ committed = true;
274
+ return;
275
+ } catch (err) {
276
+ if (signal.aborted) return;
277
+ const category = categorizeLlmError(err);
278
+ const recoverable = isRecoverable(category);
279
+ if (!recoverable || emittedDelta || attempt >= this.retryConfig.maxAttempts) {
280
+ this.bus.push(Route.Critical, {
281
+ kind: "llm.error",
282
+ contextId,
283
+ timestampMs: Date.now(),
284
+ component: "bridge" as const,
285
+ category,
286
+ cause: err instanceof Error ? err : new Error(String(err)),
287
+ isRecoverable: recoverable,
288
+ });
289
+ return;
290
+ }
291
+
292
+ this.bus.push(Route.Background, {
293
+ kind: "metric.conversation",
294
+ contextId,
295
+ timestampMs: Date.now(),
296
+ name: "llm.retry",
297
+ value: String(attempt + 1),
298
+ });
299
+ await waitForRetryDelay(attempt, this.retryConfig, signal);
300
+ }
301
+ }
302
+ } finally {
303
+ if (this.activeGeneration?.controller === controller) {
304
+ this.activeGeneration = null;
305
+ }
306
+ if (!committed) this.clearTurnState(contextId);
307
+ }
308
+ }
309
+
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
+ async close(): Promise<void> {
325
+ this.activeGeneration?.controller.abort();
326
+ this.activeGeneration = null;
327
+ for (const dispose of this.disposers.splice(0)) dispose();
328
+ this.spokenByContext.clear();
329
+ this.turnUserText.clear();
330
+ this.assistantMsgByContext.clear();
331
+ this.wordTimestampsByContext.clear();
332
+ this.playedOutMsByContext.clear();
333
+ this.bus = null;
334
+ }
335
+
336
+ private rememberTurn(userText: string, assistantText: string, contextId: string): void {
337
+ const assistantMsg = { role: "assistant" as const, content: assistantText };
338
+ this.history.push({ role: "user", content: userText }, assistantMsg);
339
+ this.assistantMsgByContext.set(contextId, assistantMsg);
340
+ this.trimHistory();
341
+ }
342
+
343
+ /**
344
+ * G25: compute the spoken prefix — the assistant text the user actually heard before
345
+ * the barge-in. Uses word timestamps + playout position when available (exact at word
346
+ * boundaries), otherwise falls back to the accumulated text sent to TTS (approximate).
347
+ */
348
+ private computeSpokenPrefix(contextId: string): string {
349
+ const words = this.wordTimestampsByContext.get(contextId);
350
+ const playedOutMs = this.playedOutMsByContext.get(contextId);
351
+ if (words && words.length > 0 && playedOutMs !== undefined && playedOutMs > 0) {
352
+ const heard = words.filter((w) => w.endMs <= playedOutMs);
353
+ return heard.map((w) => w.word).join(" ");
354
+ }
355
+ return (this.spokenByContext.get(contextId) ?? "").trim();
356
+ }
357
+
358
+ /**
359
+ * Barge-in: rewrite the interrupted turn's history to what the user actually HEARD,
360
+ * not the full generated reply. Precision ladder (G25):
361
+ * 1. Word timestamps + playout position → exact word-boundary prefix.
362
+ * 2. Fallback: text sent to TTS — approximate (may include unplayed audio since
363
+ * TTS streams faster than realtime; headless/browser paths have no playout clock).
364
+ * If the turn was committed (generation done before barge-in), truncates in place.
365
+ * If mid-generation (not yet committed), records what was sent. Either way the user
366
+ * utterance is preserved: neither divergent nor amnesiac.
367
+ */
368
+ private commitInterruptedHistory(contextId: string): void {
369
+ const spoken = this.computeSpokenPrefix(contextId);
370
+ const existing = this.assistantMsgByContext.get(contextId);
371
+ if (existing) {
372
+ if (spoken) {
373
+ existing.content = spoken;
374
+ } else {
375
+ const idx = this.history.indexOf(existing);
376
+ if (idx >= 0) this.history.splice(idx, 1);
377
+ }
378
+ } else {
379
+ const userText = this.turnUserText.get(contextId);
380
+ if (userText !== undefined) {
381
+ this.history.push({ role: "user", content: userText });
382
+ if (spoken) this.history.push({ role: "assistant", content: spoken });
383
+ this.trimHistory();
384
+ }
385
+ }
386
+ this.bus?.push(Route.Background, {
387
+ kind: "metric.conversation",
388
+ contextId,
389
+ timestampMs: Date.now(),
390
+ name: "llm.history_truncated_to_spoken",
391
+ value: String(spoken.length),
392
+ });
393
+ this.clearTurnState(contextId);
394
+ }
395
+
396
+ private trimHistory(): void {
397
+ const maxMessages = this.maxHistoryTurns * 2;
398
+ if (this.history.length > maxMessages) {
399
+ this.history = this.history.slice(this.history.length - maxMessages);
400
+ }
401
+ // Drop tracked per-turn state that has aged out of the history window.
402
+ for (const [ctx, msg] of this.assistantMsgByContext) {
403
+ if (!this.history.includes(msg)) this.clearTurnState(ctx);
404
+ }
405
+ }
406
+
407
+ private clearTurnState(contextId: string): void {
408
+ this.spokenByContext.delete(contextId);
409
+ this.turnUserText.delete(contextId);
410
+ this.assistantMsgByContext.delete(contextId);
411
+ this.wordTimestampsByContext.delete(contextId);
412
+ this.playedOutMsByContext.delete(contextId);
413
+ }
414
+ }
415
+
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
+
428
+ function readPositiveIntegerConfig(value: unknown, fallback: number): number {
429
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
430
+ const integer = Math.floor(value);
431
+ return integer > 0 ? integer : fallback;
432
+ }
433
+
434
+ async function* withStreamIdleTimeout<T>(
435
+ source: AsyncIterable<T>,
436
+ timeoutMs: number,
437
+ signal: AbortSignal,
438
+ ): AsyncGenerator<T> {
439
+ const iterator = source[Symbol.asyncIterator]();
440
+ for (;;) {
441
+ const next = await nextWithTimeout(iterator, timeoutMs, signal);
442
+ if (next.done === true) return;
443
+ yield next.value;
444
+ }
445
+ }
446
+
447
+ async function nextWithTimeout<T>(
448
+ iterator: AsyncIterator<T>,
449
+ timeoutMs: number,
450
+ signal: AbortSignal,
451
+ ): Promise<IteratorResult<T>> {
452
+ return await new Promise<IteratorResult<T>>((resolve, reject) => {
453
+ if (signal.aborted) {
454
+ reject(new Error("AI SDK stream aborted"));
455
+ return;
456
+ }
457
+ const timeout = setTimeout(() => {
458
+ void iterator.return?.(undefined);
459
+ reject(new Error(`AI SDK stream idle timeout after ${String(timeoutMs)}ms`));
460
+ }, timeoutMs);
461
+ const onAbort = (): void => {
462
+ clearTimeout(timeout);
463
+ void iterator.return?.(undefined);
464
+ reject(new Error("AI SDK stream aborted"));
465
+ };
466
+ signal.addEventListener("abort", onAbort, { once: true });
467
+ iterator.next().then(
468
+ (next) => {
469
+ clearTimeout(timeout);
470
+ signal.removeEventListener("abort", onAbort);
471
+ resolve(next);
472
+ },
473
+ (err: unknown) => {
474
+ clearTimeout(timeout);
475
+ signal.removeEventListener("abort", onAbort);
476
+ reject(err);
477
+ },
478
+ );
479
+ });
480
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "types": ["vitest/globals"],
10
+ "noEmit": true
11
+ },
12
+ "include": ["src/**/*.ts"]
13
+ }