@kuralle-syrinx/aisdk 4.1.0 → 4.3.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 +22 -4
- package/src/from-ai-sdk.ts +40 -1
- package/src/index.ts +222 -15
- package/src/from-ai-sdk.test.ts +0 -276
- package/src/index.test.ts +0 -989
- package/tsconfig.json +0 -13
package/package.json
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/aisdk",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"
|
|
5
|
+
"description": "Vercel AI SDK bridge for Syrinx — drive any ai@6 model or agent as the voice pipeline's reasoner, with opt-in speculative generation",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"voice",
|
|
8
|
+
"voice-agent",
|
|
9
|
+
"speech",
|
|
10
|
+
"syrinx",
|
|
11
|
+
"ai-sdk",
|
|
12
|
+
"vercel-ai"
|
|
13
|
+
],
|
|
6
14
|
"license": "MIT",
|
|
15
|
+
"homepage": "https://github.com/kuralle/syrinx#readme",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/kuralle/syrinx.git",
|
|
19
|
+
"directory": "packages/aisdk"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/kuralle/syrinx/issues"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
7
25
|
"main": "./src/index.ts",
|
|
8
26
|
"types": "./src/index.ts",
|
|
9
27
|
"dependencies": {
|
|
10
28
|
"@ai-sdk/openai": "^3.0.67",
|
|
11
29
|
"ai": "^6.0.0",
|
|
12
30
|
"zod": "^4.1.8",
|
|
13
|
-
"@kuralle-syrinx/core": "4.
|
|
31
|
+
"@kuralle-syrinx/core": "4.3.0"
|
|
14
32
|
},
|
|
15
33
|
"devDependencies": {
|
|
16
34
|
"typescript": "^5.7.0",
|
|
17
|
-
"vitest": "^2.
|
|
35
|
+
"vitest": "^3.2.6"
|
|
18
36
|
},
|
|
19
37
|
"scripts": {
|
|
20
38
|
"typecheck": "tsc --noEmit",
|
package/src/from-ai-sdk.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import {
|
|
9
9
|
streamText,
|
|
10
10
|
type FinishReason,
|
|
11
|
+
type LanguageModelUsage,
|
|
11
12
|
type ModelMessage,
|
|
12
13
|
type TextStreamPart,
|
|
13
14
|
type ToolChoice,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
type Reasoner,
|
|
20
21
|
type ReasonerMessage,
|
|
21
22
|
type ReasonerTurn,
|
|
23
|
+
type ReasonerUsage,
|
|
22
24
|
type ReasoningPart,
|
|
23
25
|
} from "@kuralle-syrinx/core";
|
|
24
26
|
import type { AISDKStreamFactory } from "./index.js";
|
|
@@ -79,7 +81,7 @@ async function* streamFromStreamText(config: StreamTextConfig, turn: ReasonerTur
|
|
|
79
81
|
messages,
|
|
80
82
|
abortSignal: turn.signal,
|
|
81
83
|
});
|
|
82
|
-
yield* mapTextStreamParts(result.fullStream);
|
|
84
|
+
yield* mapTextStreamParts(result.fullStream, modelIdentity(config.model));
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
async function* streamFromFactory(factory: AISDKStreamFactory, turn: ReasonerTurn): AsyncGenerator<ReasoningPart> {
|
|
@@ -112,6 +114,7 @@ function mapMessages(messages: readonly ReasonerMessage[]): ModelMessage[] {
|
|
|
112
114
|
|
|
113
115
|
async function* mapTextStreamParts(
|
|
114
116
|
source: AsyncIterable<TextStreamPart<ToolSet>>,
|
|
117
|
+
identity: { provider?: string; model?: string } = {},
|
|
115
118
|
): AsyncGenerator<ReasoningPart> {
|
|
116
119
|
let accumulatedText = "";
|
|
117
120
|
let sawFinish = false;
|
|
@@ -150,7 +153,12 @@ async function* mapTextStreamParts(
|
|
|
150
153
|
return;
|
|
151
154
|
}
|
|
152
155
|
case "abort": {
|
|
156
|
+
// An abort is a benign cancellation (barge-in aborted the reasoner), NOT an error.
|
|
157
|
+
// Carry the web/Node standard `name === "AbortError"` so downstream `isAbortError`
|
|
158
|
+
// guards (realtime-bridge runDelegate, ReasoningBridge) swallow it instead of
|
|
159
|
+
// surfacing a fatal `bridge.error/internal_fault`.
|
|
153
160
|
const cause = new Error(part.reason ?? "AI SDK stream aborted");
|
|
161
|
+
cause.name = "AbortError";
|
|
154
162
|
yield toErrorPart(cause);
|
|
155
163
|
return;
|
|
156
164
|
}
|
|
@@ -171,6 +179,11 @@ async function* mapTextStreamParts(
|
|
|
171
179
|
type: "finish",
|
|
172
180
|
reason: mapFinishReason(part.finishReason),
|
|
173
181
|
text: accumulatedText,
|
|
182
|
+
// The AI SDK finish part carries totalUsage; forward it so the bridge can
|
|
183
|
+
// record cost. Omit entirely when the provider reported nothing.
|
|
184
|
+
...(part.totalUsage
|
|
185
|
+
? { usage: { ...identity, ...toReasonerUsage(part.totalUsage) } }
|
|
186
|
+
: {}),
|
|
174
187
|
};
|
|
175
188
|
return;
|
|
176
189
|
}
|
|
@@ -198,6 +211,32 @@ async function* mapTextStreamParts(
|
|
|
198
211
|
}
|
|
199
212
|
}
|
|
200
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Extract provider/model for cost attribution. The AI SDK model is either a bare id
|
|
216
|
+
* string (`"openai/gpt-4.1-mini"`) or a model object exposing `.provider` / `.modelId`.
|
|
217
|
+
* Without this, usage counters are tagged with empty provider/model and spend cannot be
|
|
218
|
+
* attributed to a model — the whole point of the low-cardinality tags.
|
|
219
|
+
*/
|
|
220
|
+
export function modelIdentity(model: StreamTextConfig["model"]): { provider?: string; model?: string } {
|
|
221
|
+
if (typeof model === "string") return { model };
|
|
222
|
+
const m = model as { provider?: unknown; modelId?: unknown };
|
|
223
|
+
return {
|
|
224
|
+
...(typeof m.provider === "string" ? { provider: m.provider } : {}),
|
|
225
|
+
...(typeof m.modelId === "string" ? { model: m.modelId } : {}),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Copy only the token fields the SDK actually populated (all are `number | undefined`). */
|
|
230
|
+
function toReasonerUsage(u: LanguageModelUsage): ReasonerUsage {
|
|
231
|
+
return {
|
|
232
|
+
...(u.inputTokens !== undefined ? { inputTokens: u.inputTokens } : {}),
|
|
233
|
+
...(u.outputTokens !== undefined ? { outputTokens: u.outputTokens } : {}),
|
|
234
|
+
...(u.totalTokens !== undefined ? { totalTokens: u.totalTokens } : {}),
|
|
235
|
+
...(u.cachedInputTokens !== undefined ? { cachedInputTokens: u.cachedInputTokens } : {}),
|
|
236
|
+
...(u.reasoningTokens !== undefined ? { reasoningTokens: u.reasoningTokens } : {}),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
201
240
|
function mapFinishReason(finishReason: FinishReason): "stop" | "tool" | "length" {
|
|
202
241
|
if (finishReason === "tool-calls") return "tool";
|
|
203
242
|
if (finishReason === "length") return "length";
|
package/src/index.ts
CHANGED
|
@@ -20,12 +20,14 @@ import {
|
|
|
20
20
|
type ReasonerSessionStore,
|
|
21
21
|
type ReasonerTurn,
|
|
22
22
|
type TtsWordTimestamp,
|
|
23
|
+
type IncrementalUnitId,
|
|
23
24
|
categorizeLlmError,
|
|
24
25
|
isRecoverable,
|
|
25
26
|
readRetryConfig,
|
|
26
27
|
waitForRetryDelay,
|
|
27
28
|
ErrorCategory,
|
|
28
29
|
type RetryConfig,
|
|
30
|
+
InMemoryIuLedger,
|
|
29
31
|
} from "@kuralle-syrinx/core";
|
|
30
32
|
|
|
31
33
|
export {
|
|
@@ -60,8 +62,6 @@ export interface RunStore {
|
|
|
60
62
|
* simply dropped — the generation was never observable.
|
|
61
63
|
*/
|
|
62
64
|
interface SpeculativeHold {
|
|
63
|
-
promoted: boolean;
|
|
64
|
-
failed: boolean;
|
|
65
65
|
buffered: Array<() => void>;
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -70,6 +70,10 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
70
70
|
private timeoutMs: number = 30_000;
|
|
71
71
|
private maxHistoryTurns: number = 12;
|
|
72
72
|
private history: Array<{ role: "system" | "user" | "assistant" | "tool"; content: string; toolCallId?: string }> = [];
|
|
73
|
+
private readonly transientContextMessages = new Set<{
|
|
74
|
+
role: "system";
|
|
75
|
+
content: string;
|
|
76
|
+
}>();
|
|
73
77
|
private activeGeneration: { contextId: string; controller: AbortController } | null = null;
|
|
74
78
|
// At most one speculative draft at a time; `hold` gates its side effects.
|
|
75
79
|
private speculativeDraft: {
|
|
@@ -77,7 +81,11 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
77
81
|
userText: string;
|
|
78
82
|
controller: AbortController;
|
|
79
83
|
hold: SpeculativeHold;
|
|
84
|
+
id: IncrementalUnitId;
|
|
80
85
|
} | null = null;
|
|
86
|
+
private iuLedger!: InMemoryIuLedger;
|
|
87
|
+
private readonly epochByContext = new Map<string, number>();
|
|
88
|
+
private turnEpochCounter = 0;
|
|
81
89
|
private retryConfig: RetryConfig = readRetryConfig({});
|
|
82
90
|
private disposers: Array<() => void> = [];
|
|
83
91
|
// G2/G25: per-turn state so a barged-in turn is remembered as what the user HEARD,
|
|
@@ -123,6 +131,21 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
123
131
|
* unconfirmed endpoints cost extra LLM calls (Deepgram measures +50–70% at
|
|
124
132
|
* eager thresholds 0.3–0.5). Drafts never consume a suspended-run pointer —
|
|
125
133
|
* `runStore` resume stays confirmed-turn-only.
|
|
134
|
+
*
|
|
135
|
+
* **Only enable this with a confidence-gated eager endpointer (Deepgram Flux).**
|
|
136
|
+
* Promotion requires `draft.userText === eos.text` — exact equality. Flux
|
|
137
|
+
* guarantees the EndOfTurn transcript matches the preceding EagerEndOfTurn when
|
|
138
|
+
* no TurnResumed intervened, so drafts promote. `PipecatEOSPlugin` instead pushes
|
|
139
|
+
* `eos.interim` on EVERY non-empty STT interim, and each interim discards the prior
|
|
140
|
+
* draft and starts a new call, so the surviving draft is built on an interim
|
|
141
|
+
* transcript that rarely matches the final one.
|
|
142
|
+
*
|
|
143
|
+
* Measured live on smart-turn, one turn, university fixture:
|
|
144
|
+
* ON — started 13, discarded 13, promoted 0; ttfa 1724ms, llmTtft 1269ms
|
|
145
|
+
* OFF — started 0, discarded 0, promoted 0; ttfa 1302ms, llmTtft 1025ms
|
|
146
|
+
*
|
|
147
|
+
* Thirteen wasted LLM calls, zero promotions, and latency got *worse*. The lever
|
|
148
|
+
* is Flux-specific; on a per-interim endpointer it is pure cost.
|
|
126
149
|
*/
|
|
127
150
|
speculative?: boolean;
|
|
128
151
|
} = {},
|
|
@@ -132,8 +155,29 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
132
155
|
}
|
|
133
156
|
}
|
|
134
157
|
|
|
158
|
+
injectContext(text: string): void {
|
|
159
|
+
const message = { role: "system" as const, content: text };
|
|
160
|
+
this.history.push(message);
|
|
161
|
+
this.transientContextMessages.add(message);
|
|
162
|
+
}
|
|
163
|
+
|
|
135
164
|
async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
|
|
136
165
|
this.bus = bus;
|
|
166
|
+
this.iuLedger = new InMemoryIuLedger((a) => {
|
|
167
|
+
const detail =
|
|
168
|
+
a.kind === "terminal_op"
|
|
169
|
+
? `${a.op} on ${a.state} IU`
|
|
170
|
+
: `${a.op} on unknown IU`;
|
|
171
|
+
this.bus?.push(Route.Background, {
|
|
172
|
+
kind: "llm.error",
|
|
173
|
+
contextId: a.id.contextId,
|
|
174
|
+
timestampMs: Date.now(),
|
|
175
|
+
component: "iu_ledger",
|
|
176
|
+
category: ErrorCategory.InternalFault,
|
|
177
|
+
cause: new Error(`iu_ledger anomaly: ${a.kind} ${detail}`),
|
|
178
|
+
isRecoverable: true,
|
|
179
|
+
});
|
|
180
|
+
});
|
|
137
181
|
this.timeoutMs = readPositiveIntegerConfig(config["timeout_ms"], 30_000);
|
|
138
182
|
this.maxHistoryTurns = readPositiveIntegerConfig(config["max_history_turns"], 12);
|
|
139
183
|
this.retryConfig = readRetryConfig(config);
|
|
@@ -165,10 +209,11 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
165
209
|
draft.contextId === eos.contextId &&
|
|
166
210
|
draft.userText === eos.text &&
|
|
167
211
|
!draft.controller.signal.aborted &&
|
|
168
|
-
|
|
212
|
+
this.iuLedger.get(draft.id)?.state === "hypothesized"
|
|
169
213
|
) {
|
|
214
|
+
this.iuLedger.commit(draft.id);
|
|
170
215
|
this.speculativeDraft = null;
|
|
171
|
-
|
|
216
|
+
this.metric(eos.contextId, "speculative.draft_promoted");
|
|
172
217
|
for (const flush of draft.hold.buffered.splice(0)) flush();
|
|
173
218
|
return;
|
|
174
219
|
}
|
|
@@ -240,17 +285,53 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
240
285
|
/** Start (or restart, if a newer eager endpoint supersedes) the speculative draft. */
|
|
241
286
|
private async runDraft(userText: string, contextId: string): Promise<void> {
|
|
242
287
|
this.discardDraft();
|
|
288
|
+
this.metric(contextId, "speculative.draft_started");
|
|
289
|
+
const id = this.iuIdFor(contextId);
|
|
290
|
+
this.iuLedger.add({ id, kind: "user_turn", state: "hypothesized" });
|
|
243
291
|
const controller = new AbortController();
|
|
244
|
-
const hold: SpeculativeHold = {
|
|
245
|
-
this.speculativeDraft = { contextId, userText, controller, hold };
|
|
246
|
-
await this.processTurn(userText, contextId, hold, controller);
|
|
292
|
+
const hold: SpeculativeHold = { buffered: [] };
|
|
293
|
+
this.speculativeDraft = { contextId, userText, controller, hold, id };
|
|
294
|
+
await this.processTurn(userText, contextId, hold, controller, id);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private iuIdFor(contextId: string): IncrementalUnitId {
|
|
298
|
+
let epoch = this.epochByContext.get(contextId);
|
|
299
|
+
if (epoch === undefined) {
|
|
300
|
+
epoch = ++this.turnEpochCounter;
|
|
301
|
+
this.epochByContext.set(contextId, epoch);
|
|
302
|
+
}
|
|
303
|
+
return { contextId, iuId: contextId, epoch };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private assistantIuIdFor(contextId: string): IncrementalUnitId {
|
|
307
|
+
let epoch = this.epochByContext.get(contextId);
|
|
308
|
+
if (epoch === undefined) {
|
|
309
|
+
epoch = ++this.turnEpochCounter;
|
|
310
|
+
this.epochByContext.set(contextId, epoch);
|
|
311
|
+
}
|
|
312
|
+
return { contextId, iuId: `${contextId}#assistant`, epoch };
|
|
247
313
|
}
|
|
248
314
|
|
|
249
315
|
private discardDraft(): void {
|
|
250
316
|
const draft = this.speculativeDraft;
|
|
251
317
|
if (!draft) return;
|
|
252
318
|
this.speculativeDraft = null;
|
|
253
|
-
|
|
319
|
+
const committed = this.iuLedger.get(draft.id)?.state === "committed";
|
|
320
|
+
if (!committed) {
|
|
321
|
+
this.metric(draft.contextId, "speculative.draft_discarded");
|
|
322
|
+
this.iuLedger.revoke(draft.id);
|
|
323
|
+
draft.controller.abort();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private metric(contextId: string, name: string, value = "1"): void {
|
|
328
|
+
this.bus?.push(Route.Background, {
|
|
329
|
+
kind: "metric.conversation",
|
|
330
|
+
contextId,
|
|
331
|
+
timestampMs: Date.now(),
|
|
332
|
+
name,
|
|
333
|
+
value,
|
|
334
|
+
});
|
|
254
335
|
}
|
|
255
336
|
|
|
256
337
|
private async processTurn(
|
|
@@ -258,6 +339,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
258
339
|
contextId: string,
|
|
259
340
|
hold?: SpeculativeHold,
|
|
260
341
|
presetController?: AbortController,
|
|
342
|
+
iuId?: IncrementalUnitId,
|
|
261
343
|
): Promise<void> {
|
|
262
344
|
if (!this.bus) return;
|
|
263
345
|
|
|
@@ -268,21 +350,27 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
268
350
|
this.activeGeneration?.controller.abort();
|
|
269
351
|
const controller = presetController ?? new AbortController();
|
|
270
352
|
this.activeGeneration = { contextId, controller };
|
|
353
|
+
const aid = this.assistantIuIdFor(contextId);
|
|
354
|
+
this.iuLedger.add({ id: aid, kind: "assistant_response", state: "hypothesized" });
|
|
271
355
|
const signal = controller.signal;
|
|
272
356
|
|
|
273
357
|
// R2: while a speculative hold is unpromoted, every push/mutation buffers.
|
|
274
358
|
// Packets are constructed eagerly (their timestamps are event time); only
|
|
275
359
|
// delivery is deferred. Promotion replays in order, then later effects run live.
|
|
360
|
+
const isBuffering = (): boolean =>
|
|
361
|
+
hold !== undefined &&
|
|
362
|
+
iuId !== undefined &&
|
|
363
|
+
this.iuLedger.get(iuId)?.state !== "committed";
|
|
276
364
|
const push = <T extends Parameters<PipelineBus["push"]>[1]>(route: Route, packet: T): void => {
|
|
277
|
-
if (
|
|
278
|
-
if ((packet as { kind?: string }).kind === "llm.error")
|
|
279
|
-
hold
|
|
365
|
+
if (isBuffering()) {
|
|
366
|
+
if ((packet as { kind?: string }).kind === "llm.error" && iuId) this.iuLedger.revoke(iuId);
|
|
367
|
+
hold!.buffered.push(() => this.bus?.push(route, packet));
|
|
280
368
|
return;
|
|
281
369
|
}
|
|
282
370
|
this.bus?.push(route, packet);
|
|
283
371
|
};
|
|
284
372
|
const defer = (fn: () => void): void => {
|
|
285
|
-
if (
|
|
373
|
+
if (isBuffering()) hold!.buffered.push(fn);
|
|
286
374
|
else fn();
|
|
287
375
|
};
|
|
288
376
|
|
|
@@ -290,6 +378,20 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
290
378
|
let emittedDelta = false;
|
|
291
379
|
let committed = false;
|
|
292
380
|
let grounded = false;
|
|
381
|
+
let passStartedMs = 0;
|
|
382
|
+
let passTtftRecorded = false;
|
|
383
|
+
const recordPassTtft = (): void => {
|
|
384
|
+
if (passTtftRecorded) return;
|
|
385
|
+
passTtftRecorded = true;
|
|
386
|
+
const firstOutputMs = Date.now();
|
|
387
|
+
push(Route.Main, {
|
|
388
|
+
kind: "metric.conversation",
|
|
389
|
+
contextId,
|
|
390
|
+
timestampMs: firstOutputMs,
|
|
391
|
+
name: "llm.pass_ttft_ms",
|
|
392
|
+
value: String(firstOutputMs - passStartedMs),
|
|
393
|
+
});
|
|
394
|
+
};
|
|
293
395
|
|
|
294
396
|
// G2 observability: the turn's query is on its way to the reasoner (Background
|
|
295
397
|
// route, droppable — RFC bimodel-delegate-seam R4). Cascade turns have no
|
|
@@ -305,6 +407,15 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
305
407
|
try {
|
|
306
408
|
for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt += 1) {
|
|
307
409
|
grounded = false;
|
|
410
|
+
passStartedMs = Date.now();
|
|
411
|
+
passTtftRecorded = false;
|
|
412
|
+
push(Route.Main, {
|
|
413
|
+
kind: "metric.conversation",
|
|
414
|
+
contextId,
|
|
415
|
+
timestampMs: passStartedMs,
|
|
416
|
+
name: "llm.call_started",
|
|
417
|
+
value: "1",
|
|
418
|
+
});
|
|
308
419
|
try {
|
|
309
420
|
// Drafts never consume a suspended-run pointer: takePending mutates the
|
|
310
421
|
// store, and a retracted draft would silently lose the resume.
|
|
@@ -324,6 +435,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
324
435
|
case "text-delta":
|
|
325
436
|
reply += part.text;
|
|
326
437
|
emittedDelta = true;
|
|
438
|
+
recordPassTtft();
|
|
327
439
|
push(Route.Main, {
|
|
328
440
|
kind: "llm.delta",
|
|
329
441
|
contextId,
|
|
@@ -332,6 +444,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
332
444
|
});
|
|
333
445
|
break;
|
|
334
446
|
case "tool-call":
|
|
447
|
+
recordPassTtft();
|
|
335
448
|
push(Route.Main, {
|
|
336
449
|
kind: "llm.tool_call",
|
|
337
450
|
contextId,
|
|
@@ -351,7 +464,64 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
351
464
|
toolName: part.toolName,
|
|
352
465
|
result: part.result,
|
|
353
466
|
});
|
|
467
|
+
passStartedMs = Date.now();
|
|
468
|
+
passTtftRecorded = false;
|
|
469
|
+
push(Route.Main, {
|
|
470
|
+
kind: "metric.conversation",
|
|
471
|
+
contextId,
|
|
472
|
+
timestampMs: passStartedMs,
|
|
473
|
+
name: "llm.call_started",
|
|
474
|
+
value: "1",
|
|
475
|
+
});
|
|
354
476
|
break;
|
|
477
|
+
case "control":
|
|
478
|
+
push(Route.Background, {
|
|
479
|
+
kind: "delegate.result",
|
|
480
|
+
contextId,
|
|
481
|
+
timestampMs: Date.now(),
|
|
482
|
+
query: userText,
|
|
483
|
+
answer: reply,
|
|
484
|
+
durationMs: Date.now() - queryStartedMs,
|
|
485
|
+
grounded,
|
|
486
|
+
control: {
|
|
487
|
+
name: part.name,
|
|
488
|
+
payload: part.payload,
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
break;
|
|
492
|
+
case "blocked": {
|
|
493
|
+
if (signal.aborted) return;
|
|
494
|
+
const safeMessage = part.userFacingMessage;
|
|
495
|
+
const blockedMs = Date.now();
|
|
496
|
+
push(Route.Main, {
|
|
497
|
+
kind: "llm.delta",
|
|
498
|
+
contextId,
|
|
499
|
+
timestampMs: blockedMs,
|
|
500
|
+
text: safeMessage,
|
|
501
|
+
});
|
|
502
|
+
push(Route.Main, {
|
|
503
|
+
kind: "llm.done",
|
|
504
|
+
contextId,
|
|
505
|
+
timestampMs: blockedMs,
|
|
506
|
+
text: safeMessage,
|
|
507
|
+
});
|
|
508
|
+
push(Route.Background, {
|
|
509
|
+
kind: "delegate.result",
|
|
510
|
+
contextId,
|
|
511
|
+
timestampMs: blockedMs,
|
|
512
|
+
query: userText,
|
|
513
|
+
answer: safeMessage,
|
|
514
|
+
durationMs: blockedMs - queryStartedMs,
|
|
515
|
+
grounded,
|
|
516
|
+
blocked: {
|
|
517
|
+
userFacingMessage: safeMessage,
|
|
518
|
+
payload: part.payload,
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
defer(() => this.rememberTurn(userText, safeMessage, contextId));
|
|
522
|
+
committed = true;
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
355
525
|
case "error":
|
|
356
526
|
throw part.cause;
|
|
357
527
|
case "finish":
|
|
@@ -362,6 +532,18 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
362
532
|
name: "llm.finish_reason",
|
|
363
533
|
value: part.reason,
|
|
364
534
|
});
|
|
535
|
+
// Record billable token usage — the field the bridge used to drop.
|
|
536
|
+
// A turn with tool calls produces several finish parts; the session
|
|
537
|
+
// accumulator sums them, so emit per-finish rather than once per turn.
|
|
538
|
+
if (part.usage) {
|
|
539
|
+
push(Route.Background, {
|
|
540
|
+
kind: "usage.recorded",
|
|
541
|
+
contextId,
|
|
542
|
+
timestampMs: Date.now(),
|
|
543
|
+
stage: "llm",
|
|
544
|
+
...part.usage,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
365
547
|
finishReason = part.reason;
|
|
366
548
|
break;
|
|
367
549
|
case "suspended": {
|
|
@@ -393,8 +575,8 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
393
575
|
if (this.opts.runStore) {
|
|
394
576
|
const store = this.opts.runStore;
|
|
395
577
|
const runId = part.runId;
|
|
396
|
-
if (
|
|
397
|
-
hold
|
|
578
|
+
if (isBuffering()) {
|
|
579
|
+
hold!.buffered.push(() => void Promise.resolve(store.save(contextId, runId)).catch(() => undefined));
|
|
398
580
|
} else {
|
|
399
581
|
await Promise.resolve(store.save(contextId, runId));
|
|
400
582
|
}
|
|
@@ -506,6 +688,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
506
688
|
this.assistantMsgByContext.clear();
|
|
507
689
|
this.wordTimestampsByContext.clear();
|
|
508
690
|
this.playedOutMsByContext.clear();
|
|
691
|
+
this.transientContextMessages.clear();
|
|
509
692
|
this.bus = null;
|
|
510
693
|
}
|
|
511
694
|
|
|
@@ -513,6 +696,7 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
513
696
|
const assistantMsg = { role: "assistant" as const, content: assistantText };
|
|
514
697
|
this.history.push({ role: "user", content: userText }, assistantMsg);
|
|
515
698
|
this.assistantMsgByContext.set(contextId, assistantMsg);
|
|
699
|
+
this.iuLedger.commit(this.assistantIuIdFor(contextId));
|
|
516
700
|
this.trimHistory();
|
|
517
701
|
this.persistHistory();
|
|
518
702
|
}
|
|
@@ -523,7 +707,14 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
523
707
|
const sessionId = this.opts.sessionId;
|
|
524
708
|
if (!store || !sessionId) return;
|
|
525
709
|
try {
|
|
526
|
-
void Promise.resolve(
|
|
710
|
+
void Promise.resolve(
|
|
711
|
+
store.save(
|
|
712
|
+
sessionId,
|
|
713
|
+
this.history
|
|
714
|
+
.filter((message) => !this.transientContextMessages.has(message as { role: "system"; content: string }))
|
|
715
|
+
.map((message) => ({ ...message })),
|
|
716
|
+
),
|
|
717
|
+
).catch(
|
|
527
718
|
() => undefined,
|
|
528
719
|
);
|
|
529
720
|
} catch {
|
|
@@ -558,6 +749,15 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
558
749
|
*/
|
|
559
750
|
private commitInterruptedHistory(contextId: string): void {
|
|
560
751
|
const spoken = this.computeSpokenPrefix(contextId);
|
|
752
|
+
const aid = this.assistantIuIdFor(contextId);
|
|
753
|
+
const ms = this.playedOutMsByContext.get(contextId);
|
|
754
|
+
const prefix = { chars: spoken.length, ms };
|
|
755
|
+
const assistantIu = this.iuLedger.get(aid);
|
|
756
|
+
if (assistantIu?.state === "hypothesized") {
|
|
757
|
+
this.iuLedger.commit(aid, prefix);
|
|
758
|
+
} else if (assistantIu?.state === "committed") {
|
|
759
|
+
assistantIu.committedPrefix = prefix;
|
|
760
|
+
}
|
|
561
761
|
const existing = this.assistantMsgByContext.get(contextId);
|
|
562
762
|
if (existing) {
|
|
563
763
|
if (spoken) {
|
|
@@ -594,9 +794,16 @@ export class ReasoningBridge implements VoicePlugin {
|
|
|
594
794
|
for (const [ctx, msg] of this.assistantMsgByContext) {
|
|
595
795
|
if (!this.history.includes(msg)) this.clearTurnState(ctx);
|
|
596
796
|
}
|
|
797
|
+
for (const message of this.transientContextMessages) {
|
|
798
|
+
if (!this.history.includes(message)) this.transientContextMessages.delete(message);
|
|
799
|
+
}
|
|
597
800
|
}
|
|
598
801
|
|
|
599
802
|
private clearTurnState(contextId: string): void {
|
|
803
|
+
const aid = this.assistantIuIdFor(contextId);
|
|
804
|
+
if (this.iuLedger.get(aid)?.state === "hypothesized") {
|
|
805
|
+
this.iuLedger.revoke(aid);
|
|
806
|
+
}
|
|
600
807
|
this.spokenByContext.delete(contextId);
|
|
601
808
|
this.turnUserText.delete(contextId);
|
|
602
809
|
this.assistantMsgByContext.delete(contextId);
|