@kuralle-syrinx/aisdk 4.4.0 → 4.5.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/dist/from-ai-sdk.d.ts +35 -0
- package/dist/from-ai-sdk.js +200 -0
- package/dist/index.d.ts +109 -0
- package/dist/index.js +728 -0
- package/package.json +12 -5
- package/src/from-ai-sdk.test.ts +315 -0
- package/src/heard-prefix-commit.test.ts +291 -0
- package/src/index.test.ts +1184 -0
- package/src/speculative-on-ledger.test.ts +211 -0
- package/src/speculative-post-promotion.test.ts +86 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,728 @@
|
|
|
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
|
+
import { Route, categorizeLlmError, isRecoverable, readRetryConfig, waitForRetryDelay, ErrorCategory, InMemoryIuLedger, } from "@kuralle-syrinx/core";
|
|
9
|
+
export { fromAiSdkAgent, fromStreamText, fromStreamFactory, } from "./from-ai-sdk.js";
|
|
10
|
+
export class ReasoningBridge {
|
|
11
|
+
reasoner;
|
|
12
|
+
opts;
|
|
13
|
+
bus = null;
|
|
14
|
+
timeoutMs = 30_000;
|
|
15
|
+
maxHistoryTurns = 12;
|
|
16
|
+
history = [];
|
|
17
|
+
transientContextMessages = new Set();
|
|
18
|
+
activeGeneration = null;
|
|
19
|
+
// At most one speculative draft at a time; `hold` gates its side effects.
|
|
20
|
+
speculativeDraft = null;
|
|
21
|
+
iuLedger;
|
|
22
|
+
epochByContext = new Map();
|
|
23
|
+
turnEpochCounter = 0;
|
|
24
|
+
retryConfig = readRetryConfig({});
|
|
25
|
+
disposers = [];
|
|
26
|
+
// G2/G25: per-turn state so a barged-in turn is remembered as what the user HEARD,
|
|
27
|
+
// not the full generated reply. Precision ladder:
|
|
28
|
+
// 1. Word timestamps (tts.word_timestamps) + playout position (tts.playout_progress)
|
|
29
|
+
// → exact spoken prefix at word boundaries.
|
|
30
|
+
// 2. Fallback: spokenByContext (text sent to TTS) — approximate; may include audio
|
|
31
|
+
// that was queued but not yet played out (TTS streams faster than realtime).
|
|
32
|
+
// `spokenByContext` accumulates tts.text; `assistantMsgByContext` holds the live
|
|
33
|
+
// history message object so it can be rewritten in place; `turnUserText` lets a
|
|
34
|
+
// mid-generation interrupt still record the turn.
|
|
35
|
+
spokenByContext = new Map();
|
|
36
|
+
turnUserText = new Map();
|
|
37
|
+
assistantMsgByContext = new Map();
|
|
38
|
+
// G25: word-level timestamps from TTS plugin (cumulative from context audio start).
|
|
39
|
+
wordTimestampsByContext = new Map();
|
|
40
|
+
// G25: latest playout position (ms from context audio start) from the paced transport.
|
|
41
|
+
// Present whenever a paced transport is wired — this includes the browser WebSocket
|
|
42
|
+
// path (it routes through the shared paced playout pipeline + PlayoutProgressEmitter)
|
|
43
|
+
// as well as telnyx/twilio/smartpbx. Only headless-direct (no playout clock) falls
|
|
44
|
+
// back to spokenByContext.
|
|
45
|
+
playedOutMsByContext = new Map();
|
|
46
|
+
constructor(reasoner, opts = {}) {
|
|
47
|
+
this.reasoner = reasoner;
|
|
48
|
+
this.opts = opts;
|
|
49
|
+
if (this.opts.onResumeConflict === "replay") {
|
|
50
|
+
throw new Error("onResumeConflict 'replay' not yet supported — use 'restart'");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
injectContext(text) {
|
|
54
|
+
const message = { role: "system", content: text };
|
|
55
|
+
this.history.push(message);
|
|
56
|
+
this.transientContextMessages.add(message);
|
|
57
|
+
}
|
|
58
|
+
async initialize(bus, config) {
|
|
59
|
+
this.bus = bus;
|
|
60
|
+
this.iuLedger = new InMemoryIuLedger((a) => {
|
|
61
|
+
const detail = a.kind === "terminal_op"
|
|
62
|
+
? `${a.op} on ${a.state} IU`
|
|
63
|
+
: `${a.op} on unknown IU`;
|
|
64
|
+
this.bus?.push(Route.Background, {
|
|
65
|
+
kind: "llm.error",
|
|
66
|
+
contextId: a.id.contextId,
|
|
67
|
+
timestampMs: Date.now(),
|
|
68
|
+
component: "iu_ledger",
|
|
69
|
+
category: ErrorCategory.InternalFault,
|
|
70
|
+
cause: new Error(`iu_ledger anomaly: ${a.kind} ${detail}`),
|
|
71
|
+
isRecoverable: true,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
this.timeoutMs = readPositiveIntegerConfig(config["timeout_ms"], 30_000);
|
|
75
|
+
this.maxHistoryTurns = readPositiveIntegerConfig(config["max_history_turns"], 12);
|
|
76
|
+
this.retryConfig = readRetryConfig(config);
|
|
77
|
+
// G4: resume from durable history — the reasoner's next turn sees the same
|
|
78
|
+
// context as before the eviction/reconnect (R6). Load-only: nothing is spoken.
|
|
79
|
+
if (this.opts.sessionStore && this.opts.sessionId) {
|
|
80
|
+
const stored = await this.opts.sessionStore.load(this.opts.sessionId);
|
|
81
|
+
this.history = stored.map((message) => ({ ...message }));
|
|
82
|
+
}
|
|
83
|
+
// Listen for EOS turn completions
|
|
84
|
+
this.disposers.push(
|
|
85
|
+
// Concurrent producer: a turn's LLM generation streams its own packets over
|
|
86
|
+
// (potentially) several seconds. Running it fire-and-forget keeps the pipeline
|
|
87
|
+
// bus drain loop free, so the llm.delta -> tts.text streaming it produces is
|
|
88
|
+
// dispatched as it arrives (not deferred until generation ends), and Critical
|
|
89
|
+
// interrupts are handled promptly mid-generation. processTurn supersedes any
|
|
90
|
+
// still-in-flight generation (see below).
|
|
91
|
+
bus.on("eos.turn_complete", async (pkt) => {
|
|
92
|
+
const eos = pkt;
|
|
93
|
+
// R2: a draft for this exact transcript is already generating (or done) —
|
|
94
|
+
// promote it instead of paying a second LLM call. Flux guarantees the
|
|
95
|
+
// EndOfTurn transcript matches the preceding EagerEndOfTurn when no
|
|
96
|
+
// TurnResumed intervened, so commit-as-is is safe.
|
|
97
|
+
const draft = this.speculativeDraft;
|
|
98
|
+
if (draft &&
|
|
99
|
+
draft.contextId === eos.contextId &&
|
|
100
|
+
draft.userText === eos.text &&
|
|
101
|
+
!draft.controller.signal.aborted &&
|
|
102
|
+
this.iuLedger.get(draft.id)?.state === "hypothesized") {
|
|
103
|
+
this.iuLedger.commit(draft.id);
|
|
104
|
+
this.speculativeDraft = null;
|
|
105
|
+
this.metric(eos.contextId, "speculative.draft_promoted");
|
|
106
|
+
for (const flush of draft.hold.buffered.splice(0))
|
|
107
|
+
flush();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Stale, mismatched, or failed draft: its speculation was wrong — drop it
|
|
111
|
+
// and answer the confirmed transcript fresh.
|
|
112
|
+
this.discardDraft();
|
|
113
|
+
await this.processTurn(eos.text, eos.contextId);
|
|
114
|
+
}, { concurrent: true }),
|
|
115
|
+
// Track what was actually sent to TTS (fallback spoken approximation), per turn.
|
|
116
|
+
bus.on("tts.text", (pkt) => {
|
|
117
|
+
const t = pkt;
|
|
118
|
+
this.spokenByContext.set(t.contextId, (this.spokenByContext.get(t.contextId) ?? "") + t.text);
|
|
119
|
+
}),
|
|
120
|
+
// G25: accumulate word-level timestamps from the TTS plugin (Cartesia etc.).
|
|
121
|
+
// These arrive as cumulative offsets from the context audio start and enable
|
|
122
|
+
// word-boundary precision when computing the spoken prefix on barge-in.
|
|
123
|
+
bus.on("tts.word_timestamps", (pkt) => {
|
|
124
|
+
const t = pkt;
|
|
125
|
+
const existing = this.wordTimestampsByContext.get(t.contextId);
|
|
126
|
+
if (existing) {
|
|
127
|
+
for (const w of t.words)
|
|
128
|
+
existing.push(w);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
this.wordTimestampsByContext.set(t.contextId, [...t.words]);
|
|
132
|
+
}
|
|
133
|
+
}),
|
|
134
|
+
// G25: track realtime playout position from the paced transport. Absent on
|
|
135
|
+
// headless/browser paths; in that case we fall back to spokenByContext.
|
|
136
|
+
bus.on("tts.playout_progress", (pkt) => {
|
|
137
|
+
const p = pkt;
|
|
138
|
+
this.playedOutMsByContext.set(p.contextId, p.playedOutMs);
|
|
139
|
+
}),
|
|
140
|
+
// Listen for LLM interrupts. Abort generation AND rewrite the interrupted turn's
|
|
141
|
+
// history to the spoken prefix (G2/G25), so the model isn't left believing it
|
|
142
|
+
// said words the user never heard (nor amnesiac about the exchange).
|
|
143
|
+
bus.on("interrupt.llm", (pkt) => {
|
|
144
|
+
const contextId = pkt.contextId;
|
|
145
|
+
if (this.speculativeDraft?.contextId === contextId)
|
|
146
|
+
this.discardDraft();
|
|
147
|
+
if (this.activeGeneration?.contextId === contextId) {
|
|
148
|
+
this.activeGeneration.controller.abort();
|
|
149
|
+
this.activeGeneration = null;
|
|
150
|
+
}
|
|
151
|
+
this.commitInterruptedHistory(contextId);
|
|
152
|
+
if (this.opts.runStore && this.opts.onResumeConflict !== "replay") {
|
|
153
|
+
void Promise.resolve(this.opts.runStore.discard(contextId)).catch(() => undefined);
|
|
154
|
+
}
|
|
155
|
+
}));
|
|
156
|
+
if (this.opts.speculative) {
|
|
157
|
+
this.disposers.push(bus.on("eos.interim", async (pkt) => {
|
|
158
|
+
const interim = pkt;
|
|
159
|
+
const text = (interim.text ?? "").trim();
|
|
160
|
+
if (!text)
|
|
161
|
+
return;
|
|
162
|
+
await this.runDraft(text, interim.contextId);
|
|
163
|
+
}, { concurrent: true }), bus.on("eos.retracted", (pkt) => {
|
|
164
|
+
const contextId = pkt.contextId;
|
|
165
|
+
if (this.speculativeDraft?.contextId === contextId)
|
|
166
|
+
this.discardDraft();
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Start (or restart, if a newer eager endpoint supersedes) the speculative draft. */
|
|
171
|
+
async runDraft(userText, contextId) {
|
|
172
|
+
this.discardDraft();
|
|
173
|
+
this.metric(contextId, "speculative.draft_started");
|
|
174
|
+
const id = this.iuIdFor(contextId);
|
|
175
|
+
this.iuLedger.add({ id, kind: "user_turn", state: "hypothesized" });
|
|
176
|
+
const controller = new AbortController();
|
|
177
|
+
const hold = { buffered: [] };
|
|
178
|
+
this.speculativeDraft = { contextId, userText, controller, hold, id };
|
|
179
|
+
await this.processTurn(userText, contextId, hold, controller, id);
|
|
180
|
+
}
|
|
181
|
+
iuIdFor(contextId) {
|
|
182
|
+
let epoch = this.epochByContext.get(contextId);
|
|
183
|
+
if (epoch === undefined) {
|
|
184
|
+
epoch = ++this.turnEpochCounter;
|
|
185
|
+
this.epochByContext.set(contextId, epoch);
|
|
186
|
+
}
|
|
187
|
+
return { contextId, iuId: contextId, epoch };
|
|
188
|
+
}
|
|
189
|
+
assistantIuIdFor(contextId) {
|
|
190
|
+
let epoch = this.epochByContext.get(contextId);
|
|
191
|
+
if (epoch === undefined) {
|
|
192
|
+
epoch = ++this.turnEpochCounter;
|
|
193
|
+
this.epochByContext.set(contextId, epoch);
|
|
194
|
+
}
|
|
195
|
+
return { contextId, iuId: `${contextId}#assistant`, epoch };
|
|
196
|
+
}
|
|
197
|
+
discardDraft() {
|
|
198
|
+
const draft = this.speculativeDraft;
|
|
199
|
+
if (!draft)
|
|
200
|
+
return;
|
|
201
|
+
this.speculativeDraft = null;
|
|
202
|
+
const committed = this.iuLedger.get(draft.id)?.state === "committed";
|
|
203
|
+
if (!committed) {
|
|
204
|
+
this.metric(draft.contextId, "speculative.draft_discarded");
|
|
205
|
+
this.iuLedger.revoke(draft.id);
|
|
206
|
+
draft.controller.abort();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
metric(contextId, name, value = "1") {
|
|
210
|
+
this.bus?.push(Route.Background, {
|
|
211
|
+
kind: "metric.conversation",
|
|
212
|
+
contextId,
|
|
213
|
+
timestampMs: Date.now(),
|
|
214
|
+
name,
|
|
215
|
+
value,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
async processTurn(userText, contextId, hold, presetController, iuId) {
|
|
219
|
+
if (!this.bus)
|
|
220
|
+
return;
|
|
221
|
+
this.turnUserText.set(contextId, userText);
|
|
222
|
+
// Handlers are concurrent, so a new turn can begin while a prior generation is
|
|
223
|
+
// still in flight. Supersede it: abort the previous controller before starting.
|
|
224
|
+
this.activeGeneration?.controller.abort();
|
|
225
|
+
const controller = presetController ?? new AbortController();
|
|
226
|
+
this.activeGeneration = { contextId, controller };
|
|
227
|
+
const aid = this.assistantIuIdFor(contextId);
|
|
228
|
+
this.iuLedger.add({ id: aid, kind: "assistant_response", state: "hypothesized" });
|
|
229
|
+
const signal = controller.signal;
|
|
230
|
+
// R2: while a speculative hold is unpromoted, every push/mutation buffers.
|
|
231
|
+
// Packets are constructed eagerly (their timestamps are event time); only
|
|
232
|
+
// delivery is deferred. Promotion replays in order, then later effects run live.
|
|
233
|
+
const isBuffering = () => hold !== undefined &&
|
|
234
|
+
iuId !== undefined &&
|
|
235
|
+
this.iuLedger.get(iuId)?.state !== "committed";
|
|
236
|
+
const push = (route, packet) => {
|
|
237
|
+
if (isBuffering()) {
|
|
238
|
+
if (packet.kind === "llm.error" && iuId)
|
|
239
|
+
this.iuLedger.revoke(iuId);
|
|
240
|
+
hold.buffered.push(() => this.bus?.push(route, packet));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
this.bus?.push(route, packet);
|
|
244
|
+
};
|
|
245
|
+
const defer = (fn) => {
|
|
246
|
+
if (isBuffering())
|
|
247
|
+
hold.buffered.push(fn);
|
|
248
|
+
else
|
|
249
|
+
fn();
|
|
250
|
+
};
|
|
251
|
+
let reply = "";
|
|
252
|
+
let emittedDelta = false;
|
|
253
|
+
let committed = false;
|
|
254
|
+
let grounded = false;
|
|
255
|
+
let passStartedMs = 0;
|
|
256
|
+
let passTtftRecorded = false;
|
|
257
|
+
const recordPassTtft = () => {
|
|
258
|
+
if (passTtftRecorded)
|
|
259
|
+
return;
|
|
260
|
+
passTtftRecorded = true;
|
|
261
|
+
const firstOutputMs = Date.now();
|
|
262
|
+
push(Route.Main, {
|
|
263
|
+
kind: "metric.conversation",
|
|
264
|
+
contextId,
|
|
265
|
+
timestampMs: firstOutputMs,
|
|
266
|
+
name: "llm.pass_ttft_ms",
|
|
267
|
+
value: String(firstOutputMs - passStartedMs),
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
// G2 observability: the turn's query is on its way to the reasoner (Background
|
|
271
|
+
// route, droppable — RFC bimodel-delegate-seam R4). Cascade turns have no
|
|
272
|
+
// front-model tool call, so toolId/toolName are absent.
|
|
273
|
+
const queryStartedMs = Date.now();
|
|
274
|
+
push(Route.Background, {
|
|
275
|
+
kind: "delegate.query",
|
|
276
|
+
contextId,
|
|
277
|
+
timestampMs: queryStartedMs,
|
|
278
|
+
query: userText,
|
|
279
|
+
});
|
|
280
|
+
try {
|
|
281
|
+
for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt += 1) {
|
|
282
|
+
grounded = false;
|
|
283
|
+
passStartedMs = Date.now();
|
|
284
|
+
passTtftRecorded = false;
|
|
285
|
+
push(Route.Main, {
|
|
286
|
+
kind: "metric.conversation",
|
|
287
|
+
contextId,
|
|
288
|
+
timestampMs: passStartedMs,
|
|
289
|
+
name: "llm.call_started",
|
|
290
|
+
value: "1",
|
|
291
|
+
});
|
|
292
|
+
try {
|
|
293
|
+
// Drafts never consume a suspended-run pointer: takePending mutates the
|
|
294
|
+
// store, and a retracted draft would silently lose the resume.
|
|
295
|
+
const pending = this.opts.runStore && !hold
|
|
296
|
+
? await Promise.resolve(this.opts.runStore.takePending(contextId))
|
|
297
|
+
: null;
|
|
298
|
+
const resuming = pending !== null;
|
|
299
|
+
const turn = pending
|
|
300
|
+
? { userText, messages: this.history, signal, resume: { runId: pending.runId, data: userText } }
|
|
301
|
+
: { userText, messages: this.history, signal };
|
|
302
|
+
let finishReason = null;
|
|
303
|
+
for await (const part of withStreamIdleTimeout(this.reasoner.stream(turn), this.timeoutMs, signal)) {
|
|
304
|
+
if (signal.aborted)
|
|
305
|
+
return;
|
|
306
|
+
switch (part.type) {
|
|
307
|
+
case "text-delta":
|
|
308
|
+
reply += part.text;
|
|
309
|
+
emittedDelta = true;
|
|
310
|
+
recordPassTtft();
|
|
311
|
+
push(Route.Main, {
|
|
312
|
+
kind: "llm.delta",
|
|
313
|
+
contextId,
|
|
314
|
+
timestampMs: Date.now(),
|
|
315
|
+
text: part.text,
|
|
316
|
+
});
|
|
317
|
+
break;
|
|
318
|
+
case "tool-call":
|
|
319
|
+
recordPassTtft();
|
|
320
|
+
push(Route.Main, {
|
|
321
|
+
kind: "llm.tool_call",
|
|
322
|
+
contextId,
|
|
323
|
+
timestampMs: Date.now(),
|
|
324
|
+
toolId: part.toolId,
|
|
325
|
+
toolName: part.toolName,
|
|
326
|
+
toolArgs: part.args,
|
|
327
|
+
});
|
|
328
|
+
break;
|
|
329
|
+
case "tool-result":
|
|
330
|
+
grounded = true;
|
|
331
|
+
push(Route.Main, {
|
|
332
|
+
kind: "llm.tool_result",
|
|
333
|
+
contextId,
|
|
334
|
+
timestampMs: Date.now(),
|
|
335
|
+
toolId: part.toolId,
|
|
336
|
+
toolName: part.toolName,
|
|
337
|
+
result: part.result,
|
|
338
|
+
});
|
|
339
|
+
passStartedMs = Date.now();
|
|
340
|
+
passTtftRecorded = false;
|
|
341
|
+
push(Route.Main, {
|
|
342
|
+
kind: "metric.conversation",
|
|
343
|
+
contextId,
|
|
344
|
+
timestampMs: passStartedMs,
|
|
345
|
+
name: "llm.call_started",
|
|
346
|
+
value: "1",
|
|
347
|
+
});
|
|
348
|
+
break;
|
|
349
|
+
case "control":
|
|
350
|
+
push(Route.Background, {
|
|
351
|
+
kind: "delegate.result",
|
|
352
|
+
contextId,
|
|
353
|
+
timestampMs: Date.now(),
|
|
354
|
+
query: userText,
|
|
355
|
+
answer: reply,
|
|
356
|
+
durationMs: Date.now() - queryStartedMs,
|
|
357
|
+
grounded,
|
|
358
|
+
control: {
|
|
359
|
+
name: part.name,
|
|
360
|
+
payload: part.payload,
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
break;
|
|
364
|
+
case "blocked": {
|
|
365
|
+
if (signal.aborted)
|
|
366
|
+
return;
|
|
367
|
+
const safeMessage = part.userFacingMessage;
|
|
368
|
+
const blockedMs = Date.now();
|
|
369
|
+
push(Route.Main, {
|
|
370
|
+
kind: "llm.delta",
|
|
371
|
+
contextId,
|
|
372
|
+
timestampMs: blockedMs,
|
|
373
|
+
text: safeMessage,
|
|
374
|
+
});
|
|
375
|
+
push(Route.Main, {
|
|
376
|
+
kind: "llm.done",
|
|
377
|
+
contextId,
|
|
378
|
+
timestampMs: blockedMs,
|
|
379
|
+
text: safeMessage,
|
|
380
|
+
});
|
|
381
|
+
push(Route.Background, {
|
|
382
|
+
kind: "delegate.result",
|
|
383
|
+
contextId,
|
|
384
|
+
timestampMs: blockedMs,
|
|
385
|
+
query: userText,
|
|
386
|
+
answer: safeMessage,
|
|
387
|
+
durationMs: blockedMs - queryStartedMs,
|
|
388
|
+
grounded,
|
|
389
|
+
blocked: {
|
|
390
|
+
userFacingMessage: safeMessage,
|
|
391
|
+
payload: part.payload,
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
defer(() => this.rememberTurn(userText, safeMessage, contextId));
|
|
395
|
+
committed = true;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
case "error":
|
|
399
|
+
throw part.cause;
|
|
400
|
+
case "finish":
|
|
401
|
+
push(Route.Background, {
|
|
402
|
+
kind: "metric.conversation",
|
|
403
|
+
contextId,
|
|
404
|
+
timestampMs: Date.now(),
|
|
405
|
+
name: "llm.finish_reason",
|
|
406
|
+
value: part.reason,
|
|
407
|
+
});
|
|
408
|
+
// Record billable token usage — the field the bridge used to drop.
|
|
409
|
+
// A turn with tool calls produces several finish parts; the session
|
|
410
|
+
// accumulator sums them, so emit per-finish rather than once per turn.
|
|
411
|
+
if (part.usage) {
|
|
412
|
+
push(Route.Background, {
|
|
413
|
+
kind: "usage.recorded",
|
|
414
|
+
contextId,
|
|
415
|
+
timestampMs: Date.now(),
|
|
416
|
+
stage: "llm",
|
|
417
|
+
...part.usage,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
finishReason = part.reason;
|
|
421
|
+
break;
|
|
422
|
+
case "suspended": {
|
|
423
|
+
if (part.prompt && !emittedDelta) {
|
|
424
|
+
push(Route.Main, {
|
|
425
|
+
kind: "llm.delta",
|
|
426
|
+
contextId,
|
|
427
|
+
timestampMs: Date.now(),
|
|
428
|
+
text: part.prompt,
|
|
429
|
+
});
|
|
430
|
+
reply += part.prompt;
|
|
431
|
+
}
|
|
432
|
+
if (signal.aborted)
|
|
433
|
+
return;
|
|
434
|
+
push(Route.Main, {
|
|
435
|
+
kind: "llm.done",
|
|
436
|
+
contextId,
|
|
437
|
+
timestampMs: Date.now(),
|
|
438
|
+
text: reply,
|
|
439
|
+
});
|
|
440
|
+
defer(() => this.rememberTurn(userText, reply, contextId));
|
|
441
|
+
push(Route.Background, {
|
|
442
|
+
kind: "reasoning.suspended",
|
|
443
|
+
contextId,
|
|
444
|
+
timestampMs: Date.now(),
|
|
445
|
+
runId: part.runId,
|
|
446
|
+
prompt: part.prompt,
|
|
447
|
+
payload: part.payload,
|
|
448
|
+
});
|
|
449
|
+
if (this.opts.runStore) {
|
|
450
|
+
const store = this.opts.runStore;
|
|
451
|
+
const runId = part.runId;
|
|
452
|
+
if (isBuffering()) {
|
|
453
|
+
hold.buffered.push(() => void Promise.resolve(store.save(contextId, runId)).catch(() => undefined));
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
await Promise.resolve(store.save(contextId, runId));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
committed = true;
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// A non-"stop" finish must fail the TURN, never the call (L2). Killing the
|
|
465
|
+
// session on a token-cap or unfinished-tool-loop hangs up the caller
|
|
466
|
+
// mid-conversation. `length` = token cap: the streamed reply is truncated
|
|
467
|
+
// but usable, so accept it and continue (fall through to llm.done). Any
|
|
468
|
+
// other non-"stop" reason (tool loop ended, null) = fail the turn
|
|
469
|
+
// recoverably — the caller hears the graceful fallback, the call stays up.
|
|
470
|
+
if (finishReason !== "stop" && finishReason !== "length") {
|
|
471
|
+
if (signal.aborted)
|
|
472
|
+
return;
|
|
473
|
+
push(Route.Critical, {
|
|
474
|
+
kind: "llm.error",
|
|
475
|
+
contextId,
|
|
476
|
+
timestampMs: Date.now(),
|
|
477
|
+
component: "bridge",
|
|
478
|
+
category: ErrorCategory.InternalFault,
|
|
479
|
+
cause: new Error(`AI SDK turn ended on finishReason "${finishReason ?? "null"}"`),
|
|
480
|
+
isRecoverable: true,
|
|
481
|
+
});
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (finishReason === "length") {
|
|
485
|
+
push(Route.Background, {
|
|
486
|
+
kind: "metric.conversation",
|
|
487
|
+
contextId,
|
|
488
|
+
timestampMs: Date.now(),
|
|
489
|
+
name: "llm.finish_length_truncated",
|
|
490
|
+
value: "1",
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
// Interrupted as generation finished — the interrupt handler owns the history
|
|
494
|
+
// for this turn (spoken prefix); don't commit the full reply or emit llm.done.
|
|
495
|
+
if (signal.aborted)
|
|
496
|
+
return;
|
|
497
|
+
const answeredMs = Date.now();
|
|
498
|
+
push(Route.Main, {
|
|
499
|
+
kind: "llm.done",
|
|
500
|
+
contextId,
|
|
501
|
+
timestampMs: answeredMs,
|
|
502
|
+
text: reply,
|
|
503
|
+
});
|
|
504
|
+
// G2 observability: the reasoner produced the turn's final answer.
|
|
505
|
+
push(Route.Background, {
|
|
506
|
+
kind: "delegate.result",
|
|
507
|
+
contextId,
|
|
508
|
+
timestampMs: answeredMs,
|
|
509
|
+
query: userText,
|
|
510
|
+
answer: reply,
|
|
511
|
+
durationMs: answeredMs - queryStartedMs,
|
|
512
|
+
grounded,
|
|
513
|
+
});
|
|
514
|
+
defer(() => this.rememberTurn(userText, reply, contextId));
|
|
515
|
+
if (this.opts.runStore && resuming) {
|
|
516
|
+
await Promise.resolve(this.opts.runStore.discard(contextId));
|
|
517
|
+
}
|
|
518
|
+
committed = true;
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
catch (err) {
|
|
522
|
+
if (signal.aborted)
|
|
523
|
+
return;
|
|
524
|
+
const category = categorizeLlmError(err);
|
|
525
|
+
const recoverable = isRecoverable(category);
|
|
526
|
+
if (!recoverable || emittedDelta || attempt >= this.retryConfig.maxAttempts) {
|
|
527
|
+
push(Route.Critical, {
|
|
528
|
+
kind: "llm.error",
|
|
529
|
+
contextId,
|
|
530
|
+
timestampMs: Date.now(),
|
|
531
|
+
component: "bridge",
|
|
532
|
+
category,
|
|
533
|
+
cause: err instanceof Error ? err : new Error(String(err)),
|
|
534
|
+
isRecoverable: recoverable,
|
|
535
|
+
});
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
push(Route.Background, {
|
|
539
|
+
kind: "metric.conversation",
|
|
540
|
+
contextId,
|
|
541
|
+
timestampMs: Date.now(),
|
|
542
|
+
name: "llm.retry",
|
|
543
|
+
value: String(attempt + 1),
|
|
544
|
+
});
|
|
545
|
+
await waitForRetryDelay(attempt, this.retryConfig, signal);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
finally {
|
|
550
|
+
if (this.activeGeneration?.controller === controller) {
|
|
551
|
+
this.activeGeneration = null;
|
|
552
|
+
}
|
|
553
|
+
if (!committed)
|
|
554
|
+
this.clearTurnState(contextId);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
async close() {
|
|
558
|
+
this.discardDraft();
|
|
559
|
+
this.activeGeneration?.controller.abort();
|
|
560
|
+
this.activeGeneration = null;
|
|
561
|
+
for (const dispose of this.disposers.splice(0))
|
|
562
|
+
dispose();
|
|
563
|
+
this.spokenByContext.clear();
|
|
564
|
+
this.turnUserText.clear();
|
|
565
|
+
this.assistantMsgByContext.clear();
|
|
566
|
+
this.wordTimestampsByContext.clear();
|
|
567
|
+
this.playedOutMsByContext.clear();
|
|
568
|
+
this.transientContextMessages.clear();
|
|
569
|
+
this.bus = null;
|
|
570
|
+
}
|
|
571
|
+
rememberTurn(userText, assistantText, contextId) {
|
|
572
|
+
const assistantMsg = { role: "assistant", content: assistantText };
|
|
573
|
+
this.history.push({ role: "user", content: userText }, assistantMsg);
|
|
574
|
+
this.assistantMsgByContext.set(contextId, assistantMsg);
|
|
575
|
+
this.iuLedger.commit(this.assistantIuIdFor(contextId));
|
|
576
|
+
this.trimHistory();
|
|
577
|
+
this.persistHistory();
|
|
578
|
+
}
|
|
579
|
+
/** G4: persist the bounded history snapshot, best-effort off the hot path. */
|
|
580
|
+
persistHistory() {
|
|
581
|
+
const store = this.opts.sessionStore;
|
|
582
|
+
const sessionId = this.opts.sessionId;
|
|
583
|
+
if (!store || !sessionId)
|
|
584
|
+
return;
|
|
585
|
+
try {
|
|
586
|
+
void Promise.resolve(store.save(sessionId, this.history
|
|
587
|
+
.filter((message) => !this.transientContextMessages.has(message))
|
|
588
|
+
.map((message) => ({ ...message })))).catch(() => undefined);
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
/* persistence must never fail the turn */
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* G25: compute the spoken prefix — the assistant text the user actually heard before
|
|
596
|
+
* the barge-in. Uses word timestamps + playout position when available (exact at word
|
|
597
|
+
* boundaries), otherwise falls back to the accumulated text sent to TTS (approximate).
|
|
598
|
+
*/
|
|
599
|
+
computeSpokenPrefix(contextId) {
|
|
600
|
+
const words = this.wordTimestampsByContext.get(contextId);
|
|
601
|
+
const playedOutMs = this.playedOutMsByContext.get(contextId);
|
|
602
|
+
if (words && words.length > 0 && playedOutMs !== undefined && playedOutMs > 0) {
|
|
603
|
+
const heard = words.filter((w) => w.endMs <= playedOutMs);
|
|
604
|
+
return heard.map((w) => w.word).join(" ");
|
|
605
|
+
}
|
|
606
|
+
return (this.spokenByContext.get(contextId) ?? "").trim();
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Barge-in: rewrite the interrupted turn's history to what the user actually HEARD,
|
|
610
|
+
* not the full generated reply. Precision ladder (G25):
|
|
611
|
+
* 1. Word timestamps + playout position → exact word-boundary prefix.
|
|
612
|
+
* 2. Fallback: text sent to TTS — approximate (may include unplayed audio since
|
|
613
|
+
* TTS streams faster than realtime; headless/browser paths have no playout clock).
|
|
614
|
+
* If the turn was committed (generation done before barge-in), truncates in place.
|
|
615
|
+
* If mid-generation (not yet committed), records what was sent. Either way the user
|
|
616
|
+
* utterance is preserved: neither divergent nor amnesiac.
|
|
617
|
+
*/
|
|
618
|
+
commitInterruptedHistory(contextId) {
|
|
619
|
+
const spoken = this.computeSpokenPrefix(contextId);
|
|
620
|
+
const aid = this.assistantIuIdFor(contextId);
|
|
621
|
+
const ms = this.playedOutMsByContext.get(contextId);
|
|
622
|
+
const prefix = { chars: spoken.length, ms };
|
|
623
|
+
const assistantIu = this.iuLedger.get(aid);
|
|
624
|
+
if (assistantIu?.state === "hypothesized") {
|
|
625
|
+
this.iuLedger.commit(aid, prefix);
|
|
626
|
+
}
|
|
627
|
+
else if (assistantIu?.state === "committed") {
|
|
628
|
+
assistantIu.committedPrefix = prefix;
|
|
629
|
+
}
|
|
630
|
+
const existing = this.assistantMsgByContext.get(contextId);
|
|
631
|
+
if (existing) {
|
|
632
|
+
if (spoken) {
|
|
633
|
+
existing.content = spoken;
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
const idx = this.history.indexOf(existing);
|
|
637
|
+
if (idx >= 0)
|
|
638
|
+
this.history.splice(idx, 1);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
else {
|
|
642
|
+
const userText = this.turnUserText.get(contextId);
|
|
643
|
+
if (userText !== undefined) {
|
|
644
|
+
this.history.push({ role: "user", content: userText });
|
|
645
|
+
if (spoken)
|
|
646
|
+
this.history.push({ role: "assistant", content: spoken });
|
|
647
|
+
this.trimHistory();
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
this.bus?.push(Route.Background, {
|
|
651
|
+
kind: "metric.conversation",
|
|
652
|
+
contextId,
|
|
653
|
+
timestampMs: Date.now(),
|
|
654
|
+
name: "llm.history_truncated_to_spoken",
|
|
655
|
+
value: String(spoken.length),
|
|
656
|
+
});
|
|
657
|
+
this.persistHistory(); // G4: the durable snapshot reflects the heard prefix
|
|
658
|
+
this.clearTurnState(contextId);
|
|
659
|
+
}
|
|
660
|
+
trimHistory() {
|
|
661
|
+
const maxMessages = this.maxHistoryTurns * 2;
|
|
662
|
+
if (this.history.length > maxMessages) {
|
|
663
|
+
this.history = this.history.slice(this.history.length - maxMessages);
|
|
664
|
+
}
|
|
665
|
+
// Drop tracked per-turn state that has aged out of the history window.
|
|
666
|
+
for (const [ctx, msg] of this.assistantMsgByContext) {
|
|
667
|
+
if (!this.history.includes(msg))
|
|
668
|
+
this.clearTurnState(ctx);
|
|
669
|
+
}
|
|
670
|
+
for (const message of this.transientContextMessages) {
|
|
671
|
+
if (!this.history.includes(message))
|
|
672
|
+
this.transientContextMessages.delete(message);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
clearTurnState(contextId) {
|
|
676
|
+
const aid = this.assistantIuIdFor(contextId);
|
|
677
|
+
if (this.iuLedger.get(aid)?.state === "hypothesized") {
|
|
678
|
+
this.iuLedger.revoke(aid);
|
|
679
|
+
}
|
|
680
|
+
this.spokenByContext.delete(contextId);
|
|
681
|
+
this.turnUserText.delete(contextId);
|
|
682
|
+
this.assistantMsgByContext.delete(contextId);
|
|
683
|
+
this.wordTimestampsByContext.delete(contextId);
|
|
684
|
+
this.playedOutMsByContext.delete(contextId);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
function readPositiveIntegerConfig(value, fallback) {
|
|
688
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
689
|
+
return fallback;
|
|
690
|
+
const integer = Math.floor(value);
|
|
691
|
+
return integer > 0 ? integer : fallback;
|
|
692
|
+
}
|
|
693
|
+
async function* withStreamIdleTimeout(source, timeoutMs, signal) {
|
|
694
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
695
|
+
for (;;) {
|
|
696
|
+
const next = await nextWithTimeout(iterator, timeoutMs, signal);
|
|
697
|
+
if (next.done === true)
|
|
698
|
+
return;
|
|
699
|
+
yield next.value;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
async function nextWithTimeout(iterator, timeoutMs, signal) {
|
|
703
|
+
return await new Promise((resolve, reject) => {
|
|
704
|
+
if (signal.aborted) {
|
|
705
|
+
reject(new Error("AI SDK stream aborted"));
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
const timeout = setTimeout(() => {
|
|
709
|
+
void iterator.return?.(undefined);
|
|
710
|
+
reject(new Error(`AI SDK stream idle timeout after ${String(timeoutMs)}ms`));
|
|
711
|
+
}, timeoutMs);
|
|
712
|
+
const onAbort = () => {
|
|
713
|
+
clearTimeout(timeout);
|
|
714
|
+
void iterator.return?.(undefined);
|
|
715
|
+
reject(new Error("AI SDK stream aborted"));
|
|
716
|
+
};
|
|
717
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
718
|
+
iterator.next().then((next) => {
|
|
719
|
+
clearTimeout(timeout);
|
|
720
|
+
signal.removeEventListener("abort", onAbort);
|
|
721
|
+
resolve(next);
|
|
722
|
+
}, (err) => {
|
|
723
|
+
clearTimeout(timeout);
|
|
724
|
+
signal.removeEventListener("abort", onAbort);
|
|
725
|
+
reject(err);
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
}
|