@mlx-node/lm 0.0.6 → 0.0.7

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.
@@ -0,0 +1,920 @@
1
+ /**
2
+ * Convert the parsed `ToolCallResult[]` emitted by the native chat
3
+ * pipeline into the `ToolCall[]` shape expected by
4
+ * `ChatMessage.toolCalls` (and, by extension, the jinja chat
5
+ * templates on cold replay).
6
+ *
7
+ * Two shape differences to bridge:
8
+ *
9
+ * 1. `ToolCallResult.arguments` is `Record<string, unknown> | string`
10
+ * (already parsed by the native parser when status is "ok",
11
+ * preserved as the original string on parse failure). The
12
+ * `ChatMessage.toolCalls` contract is `arguments: string`, and
13
+ * the native tokenizer's `render_chat_template` pre-parses that
14
+ * string back into a `serde_json::Value` before handing it to
15
+ * jinja. We therefore `JSON.stringify` any non-string argument
16
+ * so the round-trip is lossless. Strings are passed through
17
+ * verbatim so a failed-to-parse payload retains its original
18
+ * bytes (the template then sees it as a quoted string, which is
19
+ * the safest available fallback).
20
+ * 2. Only `status === "ok"` calls carry a well-formed
21
+ * `(name, arguments)` pair — the other statuses (`invalid_json`,
22
+ * `missing_name`, `parse_error`) are informational diagnostics
23
+ * that the native parser emits for observability and that the
24
+ * downstream chat template has no way to render. Preserving them
25
+ * on the replay path would inject garbage tool-call tags into
26
+ * the jinja output. We filter to `ok` entries only — matching the
27
+ * filter every other consumer (server response mapper, tool-use
28
+ * examples, README guidance) already applies.
29
+ *
30
+ * Returns `undefined` when the input is absent or yields no `ok`
31
+ * entries so the assistant `ChatMessage` stays minimal (no empty
32
+ * `toolCalls: []` field polluting the history).
33
+ */
34
+ function toAssistantToolCalls(toolCalls) {
35
+ if (!toolCalls || toolCalls.length === 0)
36
+ return undefined;
37
+ const out = [];
38
+ for (const tc of toolCalls) {
39
+ if (tc.status !== 'ok')
40
+ continue;
41
+ const argsStr = typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments);
42
+ out.push({ id: tc.id, name: tc.name, arguments: argsStr });
43
+ }
44
+ return out.length > 0 ? out : undefined;
45
+ }
46
+ /**
47
+ * Build an assistant `ChatMessage` from a just-completed turn's
48
+ * decoded text + tool-call list. The assistant entry is appended to
49
+ * `this.history` after every successful turn and is later read back
50
+ * by the native `chatSessionStart` cold-replay path (image-change
51
+ * mid-session restart, `startFromHistory*`, server-side
52
+ * `SessionRegistry` cache-miss rebuild). Dropping the `toolCalls`
53
+ * field here would orphan any subsequent `{role: 'tool', ...}`
54
+ * entries on replay — the jinja template would render a
55
+ * `<tool_response>` for a call that was never declared on the
56
+ * preceding assistant turn, corrupting the conversation structure
57
+ * and changing model behavior after a restart.
58
+ */
59
+ function buildAssistantMessage(text, toolCalls) {
60
+ const calls = toAssistantToolCalls(toolCalls);
61
+ if (calls) {
62
+ return { role: 'assistant', content: text, toolCalls: calls };
63
+ }
64
+ return { role: 'assistant', content: text };
65
+ }
66
+ /**
67
+ * Count the `ok`-status tool calls in a `ChatResult.toolCalls` /
68
+ * terminal stream chunk. Used to detect the unsupported multi-call
69
+ * fan-out pattern — the chat-session API only serves one tool call
70
+ * per assistant turn because each `sendToolResult` dispatch
71
+ * immediately re-opens the assistant turn, so a second result would
72
+ * land after a new assistant reply and corrupt the conversation
73
+ * structure. Non-`ok` entries (`parse_error`, `invalid_json`, etc.)
74
+ * are ignored because the caller cannot respond to them anyway.
75
+ */
76
+ function countOkToolCalls(toolCalls) {
77
+ if (!toolCalls || toolCalls.length === 0)
78
+ return 0;
79
+ let n = 0;
80
+ for (const c of toolCalls) {
81
+ if (c.status === 'ok')
82
+ n++;
83
+ }
84
+ return n;
85
+ }
86
+ /**
87
+ * Compute a stable hex-encoded identity key for a list of image
88
+ * byte buffers.
89
+ *
90
+ * Returns `null` when no images are provided so `send()` can
91
+ * distinguish "no-images" from "image set changed". The key is
92
+ * order-sensitive: `[A, B]` and `[B, A]` produce different keys,
93
+ * matching the positional semantics of the underlying VLM chat
94
+ * template.
95
+ *
96
+ * This is a byte-identity check — callers use the key solely to
97
+ * decide whether to restart the server-side session, so a
98
+ * non-cryptographic hash is sufficient. We use FNV-1a 64-bit with a
99
+ * length-prefixed framing so different image counts and different
100
+ * byte lengths cannot collide by accident.
101
+ *
102
+ * Implementation note: kept fully sync + self-contained so
103
+ * `send()` can stay synchronous in its routing decision and so the
104
+ * module has no external runtime dependencies beyond `@mlx-node/core`
105
+ * and the existing stream bridge.
106
+ */
107
+ function computeImagesKey(images) {
108
+ if (!images || images.length === 0)
109
+ return null;
110
+ // FNV-1a 64-bit. Split into two 32-bit halves because JavaScript
111
+ // doesn't have a native 64-bit integer type and BigInt ops are
112
+ // slow on large byte streams. This emulates 64-bit FNV-1a using
113
+ // paired 32-bit lo/hi halves — the standard JS idiom.
114
+ const FNV_OFFSET_LO = 0x84222325 >>> 0;
115
+ const FNV_OFFSET_HI = 0xcbf29ce4 >>> 0;
116
+ const FNV_PRIME_LO = 0x000001b3 >>> 0;
117
+ const FNV_PRIME_HI = 0x00000100 >>> 0;
118
+ let lo = FNV_OFFSET_LO;
119
+ let hi = FNV_OFFSET_HI;
120
+ function mix(byte) {
121
+ lo = (lo ^ byte) >>> 0;
122
+ // Multiply (hi:lo) by (FNV_PRIME_HI:FNV_PRIME_LO) mod 2^64.
123
+ // Break 32-bit halves into 16-bit quarters to keep intermediate
124
+ // products inside the safe-integer range.
125
+ const loLo = lo & 0xffff;
126
+ const loHi = lo >>> 16;
127
+ const hiLo = hi & 0xffff;
128
+ const hiHi = hi >>> 16;
129
+ const pLo = FNV_PRIME_LO & 0xffff;
130
+ const pLoH = FNV_PRIME_LO >>> 16;
131
+ const pHi = FNV_PRIME_HI & 0xffff;
132
+ const pHiH = FNV_PRIME_HI >>> 16;
133
+ const r0 = loLo * pLo;
134
+ const r1 = loLo * pLoH + loHi * pLo;
135
+ const r2 = loLo * pHi + loHi * pLoH + hiLo * pLo;
136
+ const r3 = loLo * pHiH + loHi * pHi + hiLo * pLoH + hiHi * pLo;
137
+ const newLo0 = r0 & 0xffff;
138
+ const carry1 = r0 >>> 16;
139
+ const sum1 = r1 + carry1;
140
+ const newLo1 = sum1 & 0xffff;
141
+ const carry2 = Math.floor(sum1 / 0x10000);
142
+ const sum2 = r2 + carry2;
143
+ const newHi0 = sum2 & 0xffff;
144
+ const carry3 = Math.floor(sum2 / 0x10000);
145
+ const sum3 = r3 + carry3;
146
+ const newHi1 = sum3 & 0xffff;
147
+ lo = ((newLo1 << 16) | newLo0) >>> 0;
148
+ hi = ((newHi1 << 16) | newHi0) >>> 0;
149
+ }
150
+ // Frame each image with a 4-byte little-endian length prefix so
151
+ // `[ab, c]` and `[a, bc]` hash to distinct values.
152
+ mix(images.length & 0xff);
153
+ mix((images.length >>> 8) & 0xff);
154
+ mix((images.length >>> 16) & 0xff);
155
+ mix((images.length >>> 24) & 0xff);
156
+ for (const img of images) {
157
+ mix(img.byteLength & 0xff);
158
+ mix((img.byteLength >>> 8) & 0xff);
159
+ mix((img.byteLength >>> 16) & 0xff);
160
+ mix((img.byteLength >>> 24) & 0xff);
161
+ for (let i = 0; i < img.byteLength; i++) {
162
+ mix(img[i]);
163
+ }
164
+ }
165
+ return hi.toString(16).padStart(8, '0') + lo.toString(16).padStart(8, '0');
166
+ }
167
+ /**
168
+ * Cross-model chat session. See module docstring for design notes.
169
+ *
170
+ * The generic parameter `M` statically captures the concrete model
171
+ * type so the structural interface stays as expressive as the
172
+ * concrete one. Internally the class only uses the
173
+ * `SessionCapableModel` surface.
174
+ */
175
+ export class ChatSession {
176
+ model;
177
+ system;
178
+ defaultConfig;
179
+ /**
180
+ * Full conversation history tracked on the TS side. Appended to on
181
+ * every successful turn. Only read back when the image-change path
182
+ * triggers a restart — normal text continues use the server-side
183
+ * cache, not this array.
184
+ */
185
+ history = [];
186
+ /**
187
+ * Hex-encoded byte-identity key of the image set currently bound
188
+ * to the server's KV cache (FNV-1a 64-bit; see `computeImagesKey`).
189
+ * `null` when no images are cached. A `send()` whose new key
190
+ * differs triggers a full `chatSessionStart` restart.
191
+ */
192
+ lastImagesKey = null;
193
+ turnCount = 0;
194
+ inFlight = false;
195
+ /**
196
+ * Count of `ok` tool calls emitted by the prior assistant turn, or
197
+ * `null` when the prior turn produced none. Gates every continuation
198
+ * entry point on the tool-call resolution invariant because each
199
+ * native `chat_session_continue*` dispatch re-opens the assistant
200
+ * turn:
201
+ *
202
+ * - A plain text `send` / `sendStream` after ANY outstanding tool
203
+ * call would orphan the call(s) by weaving a fresh user turn
204
+ * between the assistant's `tool_call` and any response.
205
+ * - A `sendToolResult` / `sendToolResultStream` is only servable
206
+ * when exactly one tool call is outstanding. A multi-call
207
+ * fan-out (`> 1`) cannot be resolved one result at a time — the
208
+ * siblings would be separated by fresh assistant replies — so
209
+ * those entry points also reject.
210
+ *
211
+ * Cleared on every successful commit whose new turn emits zero `ok`
212
+ * tool calls, and on `reset()`. See `assertCanSendPlain` /
213
+ * `assertCanSendToolResult` for the per-entry-point gate logic.
214
+ */
215
+ unresolvedOkToolCallCount = null;
216
+ constructor(model, options = {}) {
217
+ this.model = model;
218
+ this.system = options.system;
219
+ this.defaultConfig = options.defaultConfig ?? {};
220
+ }
221
+ /**
222
+ * Number of completed turns. Increments only after a successful
223
+ * round-trip — in-flight or failed calls leave this untouched.
224
+ */
225
+ get turns() {
226
+ return this.turnCount;
227
+ }
228
+ /** Whether the session currently has images bound to its cache. */
229
+ get hasImages() {
230
+ return this.lastImagesKey !== null;
231
+ }
232
+ /**
233
+ * Count of `ok` tool calls from the most recent assistant turn, or
234
+ * `null` when the trailing turn produced none. Non-null means the
235
+ * session is parked on an unresolved tool-call turn and the only
236
+ * forward-progress move is `sendToolResult*()` against one of the
237
+ * outstanding ids — and only when the count is exactly 1. A
238
+ * multi-call fan-out (`> 1`) cannot be served by the chat-session
239
+ * API at all; server endpoints should pre-check this getter and
240
+ * route around a fan-out via `reset()` + `primeHistory()` +
241
+ * `startFromHistory()` cold replay that resolves every sibling in
242
+ * one atomic jinja render.
243
+ *
244
+ * The flag updates after every successful `send` / `sendStream` /
245
+ * `sendToolResult` / `sendToolResultStream` / `startFromHistory*`
246
+ * commit, and after `primeHistory()` (from the trailing assistant
247
+ * message's `toolCalls.length`). `reset()` clears it.
248
+ */
249
+ get pendingUnresolvedToolCallCount() {
250
+ return this.unresolvedOkToolCallCount;
251
+ }
252
+ /**
253
+ * Send a user message and resolve with the assistant reply.
254
+ *
255
+ * Turn 0 and any turn whose image set has changed dispatch through
256
+ * `chatSessionStart` with the full history. All other turns use
257
+ * the cheap `chatSessionContinue` delta path.
258
+ */
259
+ async send(userMessage, opts = {}) {
260
+ if (this.inFlight) {
261
+ throw new Error('ChatSession: concurrent send() not allowed; await the previous call first');
262
+ }
263
+ this.assertCanSendPlain('send');
264
+ this.inFlight = true;
265
+ try {
266
+ const mergedConfig = this.mergeConfig(opts.config);
267
+ const newImagesKey = computeImagesKey(opts.images);
268
+ // Only an explicit NEW image set can trigger a restart. Omitting
269
+ // `images` (newImagesKey === null) is interpreted as "keep the
270
+ // current image cache state" — the server-side cache already
271
+ // holds any prior image context, so a text-only follow-up like
272
+ // "what about the top-right?" can stay on the cheap delta path
273
+ // even after an image turn.
274
+ const imageChanged = newImagesKey !== null && newImagesKey !== this.lastImagesKey;
275
+ const isFirstTurn = this.turnCount === 0;
276
+ if (isFirstTurn || imageChanged) {
277
+ return await this.runStartPath(userMessage, opts.images, newImagesKey, imageChanged, isFirstTurn, mergedConfig);
278
+ }
279
+ // Delta continue: text-only, images always null. The server
280
+ // cache already holds all prior turns (including any images
281
+ // from an earlier restart), so we only need to ship the new
282
+ // user string.
283
+ const result = await this.model.chatSessionContinue(userMessage, null, mergedConfig);
284
+ this.history.push({ role: 'user', content: userMessage });
285
+ this.history.push(buildAssistantMessage(result.text, result.toolCalls));
286
+ this.turnCount++;
287
+ this.recordToolCallFanout(result.toolCalls);
288
+ return result;
289
+ }
290
+ finally {
291
+ this.inFlight = false;
292
+ }
293
+ }
294
+ /**
295
+ * Streaming variant of {@link ChatSession#send}.
296
+ *
297
+ * Routing matches `send()`. The assistant reply is accumulated
298
+ * from stream deltas and pushed to `history` only after a
299
+ * successful terminal chunk (`done: true` with non-error
300
+ * `finishReason`). Caller break, mid-stream exceptions, and error
301
+ * finishes all leave `turnCount` untouched and the history
302
+ * un-appended for the turn so the next call re-routes through the
303
+ * start path.
304
+ */
305
+ async *sendStream(userMessage, opts = {}) {
306
+ if (this.inFlight) {
307
+ throw new Error('ChatSession: concurrent send() not allowed; await the previous call first');
308
+ }
309
+ this.assertCanSendPlain('sendStream');
310
+ this.inFlight = true;
311
+ try {
312
+ const mergedConfig = this.mergeConfig(opts.config);
313
+ const newImagesKey = computeImagesKey(opts.images);
314
+ // Only an explicit NEW image set can trigger a restart. Omitting
315
+ // `images` (newImagesKey === null) is interpreted as "keep the
316
+ // current image cache state" — the server-side cache already
317
+ // holds any prior image context, so a text-only follow-up like
318
+ // "what about the top-right?" can stay on the cheap delta path
319
+ // even after an image turn.
320
+ const imageChanged = newImagesKey !== null && newImagesKey !== this.lastImagesKey;
321
+ const isFirstTurn = this.turnCount === 0;
322
+ if (isFirstTurn || imageChanged) {
323
+ yield* this.runStartStreamPath(userMessage, opts.images, newImagesKey, imageChanged, isFirstTurn, mergedConfig, opts.signal);
324
+ return;
325
+ }
326
+ // Delta continue stream: text-only.
327
+ let sawFinal = false;
328
+ let accumulated = '';
329
+ let finalRaw = null;
330
+ let finalToolCalls;
331
+ try {
332
+ for await (const event of this.model.chatStreamSessionContinue(userMessage, null, mergedConfig, opts.signal)) {
333
+ if (event.done) {
334
+ if (event.finishReason !== 'error') {
335
+ sawFinal = true;
336
+ finalRaw = event.text;
337
+ finalToolCalls = event.toolCalls;
338
+ }
339
+ }
340
+ else {
341
+ accumulated += event.text;
342
+ }
343
+ yield event;
344
+ }
345
+ }
346
+ finally {
347
+ // finally runs for normal completion, mid-stream throw,
348
+ // caller `break` (which calls `iterator.return()` and
349
+ // short-circuits the suspended yield), and error-finish
350
+ // chunks alike. The delta path doesn't push to history until
351
+ // commit, so the rollback branch is a no-op: nothing to
352
+ // undo, and the native cache state is managed by the Rust
353
+ // save_cache_state path on its own.
354
+ if (sawFinal) {
355
+ this.history.push({ role: 'user', content: userMessage });
356
+ this.history.push(buildAssistantMessage(finalRaw ?? accumulated, finalToolCalls));
357
+ this.turnCount++;
358
+ this.recordToolCallFanout(finalToolCalls);
359
+ }
360
+ }
361
+ }
362
+ finally {
363
+ this.inFlight = false;
364
+ }
365
+ }
366
+ /**
367
+ * Send a tool-result turn. Always dispatches
368
+ * `chatSessionContinueTool` — tool turns never change image state,
369
+ * so there is no restart path here.
370
+ *
371
+ * Rejects if the prior assistant turn emitted more than one `ok`
372
+ * tool call: the chat-session API only supports exactly one tool
373
+ * call per assistant turn because each `sendToolResult` dispatch
374
+ * immediately re-opens the assistant turn, so responding to the
375
+ * remaining calls would interleave new assistant replies between
376
+ * the results and corrupt the conversation structure. Callers that
377
+ * hit this must tighten the prompt / tool spec or reset the
378
+ * session.
379
+ *
380
+ * Appends a `{ role: 'tool', ... }` message to history on success.
381
+ */
382
+ async sendToolResult(toolCallId, content, opts = {}) {
383
+ if (this.inFlight) {
384
+ throw new Error('ChatSession: concurrent send() not allowed; await the previous call first');
385
+ }
386
+ this.assertCanSendToolResult('sendToolResult');
387
+ this.inFlight = true;
388
+ try {
389
+ const mergedConfig = this.mergeConfig(opts.config);
390
+ const result = await this.model.chatSessionContinueTool(toolCallId, content, mergedConfig);
391
+ this.history.push({ role: 'tool', content, toolCallId });
392
+ this.history.push(buildAssistantMessage(result.text, result.toolCalls));
393
+ this.turnCount++;
394
+ this.recordToolCallFanout(result.toolCalls);
395
+ return result;
396
+ }
397
+ finally {
398
+ this.inFlight = false;
399
+ }
400
+ }
401
+ /** Streaming variant of {@link ChatSession#sendToolResult}. */
402
+ async *sendToolResultStream(toolCallId, content, opts = {}) {
403
+ if (this.inFlight) {
404
+ throw new Error('ChatSession: concurrent send() not allowed; await the previous call first');
405
+ }
406
+ this.assertCanSendToolResult('sendToolResultStream');
407
+ this.inFlight = true;
408
+ try {
409
+ const mergedConfig = this.mergeConfig(opts.config);
410
+ let sawFinal = false;
411
+ let accumulated = '';
412
+ let finalRaw = null;
413
+ let finalToolCalls;
414
+ try {
415
+ for await (const event of this.model.chatStreamSessionContinueTool(toolCallId, content, mergedConfig, opts.signal)) {
416
+ if (event.done) {
417
+ if (event.finishReason !== 'error') {
418
+ sawFinal = true;
419
+ finalRaw = event.text;
420
+ finalToolCalls = event.toolCalls;
421
+ }
422
+ }
423
+ else {
424
+ accumulated += event.text;
425
+ }
426
+ yield event;
427
+ }
428
+ }
429
+ finally {
430
+ // finally runs for normal completion, mid-stream throw,
431
+ // caller `break` (iterator.return() short-circuits the yield),
432
+ // and error-finish chunks alike. Tool turns never touch
433
+ // history until commit, so the rollback branch is a no-op.
434
+ if (sawFinal) {
435
+ this.history.push({ role: 'tool', content, toolCallId });
436
+ this.history.push(buildAssistantMessage(finalRaw ?? accumulated, finalToolCalls));
437
+ this.turnCount++;
438
+ this.recordToolCallFanout(finalToolCalls);
439
+ }
440
+ }
441
+ }
442
+ finally {
443
+ this.inFlight = false;
444
+ }
445
+ }
446
+ /**
447
+ * Reset the session state.
448
+ *
449
+ * Clears the underlying model's KV caches and wipes local history,
450
+ * image key, and turn counter so the next `send()` goes through
451
+ * `chatSessionStart` again.
452
+ *
453
+ * Returns `Promise<void>` for an async-friendly signature even
454
+ * though `resetCaches()` is currently synchronous.
455
+ */
456
+ async reset() {
457
+ if (this.inFlight) {
458
+ throw new Error('ChatSession: cannot reset() while a send() is in flight; await the previous call first');
459
+ }
460
+ this.model.resetCaches();
461
+ this.history = [];
462
+ this.lastImagesKey = null;
463
+ this.turnCount = 0;
464
+ this.unresolvedOkToolCallCount = null;
465
+ }
466
+ /**
467
+ * Prime the session history without running inference.
468
+ *
469
+ * Used by the server-side `SessionRegistry` cold-start fallback: when
470
+ * a request arrives with a `previous_response_id` that the cache has
471
+ * missed, the endpoint reconstructs the full conversation from the
472
+ * `ResponseStore` and primes a fresh session with it, then calls
473
+ * `startFromHistory()` to replay it through the native KV cache.
474
+ *
475
+ * Rejects if the session is in flight or has already taken a turn.
476
+ * Replaces the internal history with a shallow copy of `messages`.
477
+ */
478
+ primeHistory(messages) {
479
+ if (this.inFlight) {
480
+ throw new Error('ChatSession: cannot primeHistory() while a send() is in flight');
481
+ }
482
+ if (this.turnCount > 0) {
483
+ throw new Error('ChatSession: primeHistory() can only be called on a fresh session (turn 0)');
484
+ }
485
+ this.history = messages.slice();
486
+ // Derive the unresolved-tool-call guard from the trailing assistant
487
+ // turn in the primed history so an immediately-post-prime session
488
+ // exposes the same `pendingUnresolvedToolCallCount` state a live
489
+ // session would have been in at that point of the conversation.
490
+ // This lets the server endpoint layer (and any other caller)
491
+ // pre-check the guard before starting cold replay and route around
492
+ // unresolved turns instead of letting `startFromHistory*()` blindly
493
+ // advance past them. The flag is reset on commit in both sync and
494
+ // streaming start-from-history paths based on the new assistant
495
+ // reply, which is the correct semantics for the post-replay current
496
+ // position.
497
+ this.unresolvedOkToolCallCount = this.computeTrailingAssistantUnresolvedToolCallCount();
498
+ // lastImagesKey stays null until startFromHistory() / send() runs —
499
+ // the trailing-images hydration happens at commit time.
500
+ }
501
+ /**
502
+ * Run a cold-start `chatSessionStart` using the currently primed
503
+ * history.
504
+ *
505
+ * Intended pairing with {@link primeHistory}: call
506
+ * `primeHistory(fullHistory)` first, then `startFromHistory()` to
507
+ * replay the conversation through the native chat-session API. The
508
+ * final history entry must be a user or tool turn — this is what the
509
+ * native side treats as the "current input" to generate against.
510
+ *
511
+ * Pushes the assistant reply onto the history, advances `turnCount`
512
+ * to 1, and computes `lastImagesKey` from the most recent user
513
+ * message that carries images (so subsequent text-only continues
514
+ * stay on the delta path, and subsequent image turns correctly
515
+ * trigger restart).
516
+ */
517
+ async startFromHistory(config) {
518
+ if (this.inFlight) {
519
+ throw new Error('ChatSession: cannot startFromHistory() while a send() is in flight');
520
+ }
521
+ if (this.turnCount > 0) {
522
+ throw new Error('ChatSession: startFromHistory() can only be called on a fresh session');
523
+ }
524
+ if (this.history.length === 0) {
525
+ throw new Error('ChatSession: startFromHistory() requires a primed history');
526
+ }
527
+ this.inFlight = true;
528
+ try {
529
+ const mergedConfig = this.mergeConfig(config);
530
+ const result = await this.model.chatSessionStart(this.history.slice(), mergedConfig);
531
+ this.history.push(buildAssistantMessage(result.text, result.toolCalls));
532
+ this.turnCount++;
533
+ this.lastImagesKey = this.computeTrailingImagesKey();
534
+ this.recordToolCallFanout(result.toolCalls);
535
+ return result;
536
+ }
537
+ finally {
538
+ this.inFlight = false;
539
+ }
540
+ }
541
+ /**
542
+ * Streaming counterpart to {@link startFromHistory}.
543
+ *
544
+ * Iterates `model.chatStreamSessionStart(history.slice(), config)`,
545
+ * accumulates text, and only commits history + `turnCount` +
546
+ * `lastImagesKey` in the `finally` block when a successful terminal
547
+ * chunk was observed (`done: true` with non-error finishReason).
548
+ * Because history is primed (not appended to), rollback on failure
549
+ * is a no-op: the primed state stays intact so the caller can retry.
550
+ */
551
+ async *startFromHistoryStream(config, signal) {
552
+ if (this.inFlight) {
553
+ throw new Error('ChatSession: cannot startFromHistoryStream() while a send() is in flight');
554
+ }
555
+ if (this.turnCount > 0) {
556
+ throw new Error('ChatSession: startFromHistoryStream() can only be called on a fresh session');
557
+ }
558
+ if (this.history.length === 0) {
559
+ throw new Error('ChatSession: startFromHistoryStream() requires a primed history');
560
+ }
561
+ this.inFlight = true;
562
+ try {
563
+ const mergedConfig = this.mergeConfig(config);
564
+ const historySnapshot = this.history.slice();
565
+ let sawFinal = false;
566
+ let accumulated = '';
567
+ let finalRaw = null;
568
+ let finalToolCalls;
569
+ try {
570
+ for await (const event of this.model.chatStreamSessionStart(historySnapshot, mergedConfig, signal)) {
571
+ if (event.done) {
572
+ if (event.finishReason !== 'error') {
573
+ sawFinal = true;
574
+ finalRaw = event.text;
575
+ finalToolCalls = event.toolCalls;
576
+ }
577
+ }
578
+ else {
579
+ accumulated += event.text;
580
+ }
581
+ yield event;
582
+ }
583
+ }
584
+ finally {
585
+ // finally runs on normal completion, mid-stream throw, caller
586
+ // `break` (iterator.return() short-circuits the yield), and
587
+ // error-finish chunks alike. The primed history is only
588
+ // mutated on a successful commit — on any non-success exit,
589
+ // the primed state is left intact so the caller can retry.
590
+ if (sawFinal) {
591
+ this.history.push(buildAssistantMessage(finalRaw ?? accumulated, finalToolCalls));
592
+ this.turnCount++;
593
+ this.lastImagesKey = this.computeTrailingImagesKey();
594
+ this.recordToolCallFanout(finalToolCalls);
595
+ }
596
+ }
597
+ }
598
+ finally {
599
+ this.inFlight = false;
600
+ }
601
+ }
602
+ // -------------------------------------------------------------------
603
+ // Internal helpers
604
+ // -------------------------------------------------------------------
605
+ /**
606
+ * Gate plain-text continuation entry points (`send`, `sendStream`)
607
+ * on the tool-call resolution invariant. Any outstanding `ok` tool
608
+ * call from the prior assistant turn — single or multi — makes a
609
+ * plain text continuation unsafe: the native chat-session API
610
+ * re-opens the assistant turn on each continue, so a new user delta
611
+ * would weave a fresh user message between the assistant's
612
+ * `tool_call` and any response, orphaning the call. Callers must
613
+ * resolve outstanding calls via `sendToolResult*()` (single-call
614
+ * case) or re-enter via `reset()` + `primeHistory()` +
615
+ * `startFromHistory()` with a resolved conversation (multi-call
616
+ * fan-out). `reset()` clears the flag and `startFromHistory*`
617
+ * overwrites it via `recordToolCallFanout` on the new response, so
618
+ * legitimate recovery paths are unaffected.
619
+ */
620
+ assertCanSendPlain(entryPoint) {
621
+ const n = this.unresolvedOkToolCallCount;
622
+ if (n !== null) {
623
+ const plural = n === 1 ? '' : 's';
624
+ const followUp = n > 1
625
+ ? `multi-call fan-outs cannot be served one result at a time — re-enter through reset() + primeHistory() + startFromHistory() with a conversation that resolves every sibling in one atomic replay`
626
+ : `resolve the outstanding call via sendToolResult()`;
627
+ throw new Error(`ChatSession.${entryPoint}: previous assistant turn has ${n} unresolved ok tool call${plural}; ` +
628
+ `a plain text continuation would orphan the call${plural} by weaving a new user turn between the ` +
629
+ `assistant's tool_call and any response. ${followUp}, reset() the session, or re-enter through ` +
630
+ `primeHistory() + startFromHistory() with a resolved conversation.`);
631
+ }
632
+ }
633
+ /**
634
+ * Gate tool-result entry points (`sendToolResult`,
635
+ * `sendToolResultStream`) on the single-tool-call-per-turn
636
+ * invariant. Exactly one outstanding tool call is servable — that
637
+ * is the case these methods exist for.
638
+ *
639
+ * Zero outstanding calls (`null`) is also unservable: without a
640
+ * preceding assistant turn that emitted a tool call, a tool-result
641
+ * dispatch would synthesize a `<tool_response>` delta for a call
642
+ * that never existed, corrupting the conversation structure. The
643
+ * native backends do not authenticate `tool_call_id` against prior
644
+ * state — several simply append the tool-response delta verbatim —
645
+ * so rejecting here is the only gate that prevents forged tool
646
+ * state from reaching the model. Callers that want to start a
647
+ * conversation on a resolved tool turn must prime an unresolved
648
+ * single-call assistant turn via `primeHistory()` +
649
+ * `startFromHistory()` first.
650
+ *
651
+ * A multi-call fan-out (`> 1`) cannot be resolved one result at a
652
+ * time because each `sendToolResult` dispatch immediately re-opens
653
+ * the assistant turn, so responding to the siblings would
654
+ * interleave new assistant replies between the results.
655
+ */
656
+ assertCanSendToolResult(entryPoint) {
657
+ const n = this.unresolvedOkToolCallCount;
658
+ if (n === null) {
659
+ throw new Error(`ChatSession.${entryPoint}: no outstanding ok tool call on the previous assistant turn. ` +
660
+ `Tool-result entry points can only be called when the model has just emitted exactly one ` +
661
+ `ok tool call that has not yet been resolved — dispatching a tool result against an empty ` +
662
+ `or already-resolved turn would synthesize a <tool_response> delta for a call that never ` +
663
+ `existed and corrupt the conversation structure. Call send() / sendStream() for plain user ` +
664
+ `turns, or re-enter through primeHistory() + startFromHistory() with a conversation that ` +
665
+ `ends on an unresolved single-call assistant turn.`);
666
+ }
667
+ if (n > 1) {
668
+ throw new Error(`ChatSession.${entryPoint}: previous assistant turn emitted ${n} ok tool calls; ` +
669
+ `the chat-session API only supports exactly one tool call per assistant turn because each tool-result ` +
670
+ `call immediately re-opens the assistant turn — responding to the siblings would interleave new assistant ` +
671
+ `replies between the results. Tighten the prompt / tool spec so the model produces at most one call per ` +
672
+ `turn, reset() the session, or re-enter through primeHistory() + startFromHistory() with a resolved ` +
673
+ `conversation.`);
674
+ }
675
+ }
676
+ /**
677
+ * Inspect a just-committed turn's tool calls and store the count of
678
+ * `ok` entries in `unresolvedOkToolCallCount`. Any non-zero count
679
+ * parks the session on an unresolved tool-call turn, which gates
680
+ * the next entry point:
681
+ *
682
+ * - count === 0 → flag is `null`: `send`/`sendStream` ok,
683
+ * `sendToolResult*` throws (no outstanding call to resolve)
684
+ * - count === 1 → `send`/`sendStream` throw; `sendToolResult*` ok
685
+ * - count > 1 → every entry point throws (fan-out unservable)
686
+ *
687
+ * See `assertCanSendPlain` / `assertCanSendToolResult` for the full
688
+ * rationale.
689
+ */
690
+ recordToolCallFanout(toolCalls) {
691
+ const n = countOkToolCalls(toolCalls);
692
+ this.unresolvedOkToolCallCount = n > 0 ? n : null;
693
+ }
694
+ /**
695
+ * Merge default + per-call config and force `reuseCache: true`.
696
+ * The session path is a session-reuse operation by construction —
697
+ * `reuseCache: false` on the continue path would wipe the very
698
+ * cache the delta depends on.
699
+ */
700
+ mergeConfig(overlay) {
701
+ return {
702
+ ...this.defaultConfig,
703
+ ...overlay,
704
+ reuseCache: true,
705
+ };
706
+ }
707
+ /**
708
+ * Shared start-path logic for `send()`. Handles both the turn-0
709
+ * first-ever-send case and the image-change mid-session restart
710
+ * case. The image-change restart preserves prior history so the
711
+ * native side gets the full conversation re-rendered with the new
712
+ * image set.
713
+ */
714
+ async runStartPath(userMessage, images, newImagesKey, imageChanged, isFirstTurn, config) {
715
+ // Capture pre-state so the restart can be rolled back if the
716
+ // native call fails. The image-change branch resets caches BEFORE
717
+ // we know whether the new prefill will succeed, so on failure we
718
+ // also have to drop turnCount + lastImagesKey to force the next
719
+ // call to re-route through the start path (rather than a delta
720
+ // continue against wiped caches).
721
+ const wasImageChangeRestart = imageChanged && !isFirstTurn;
722
+ const historyLenBefore = this.history.length;
723
+ this.prepareStartPath(imageChanged, isFirstTurn);
724
+ const userMsg = this.buildUserMessage(userMessage, images);
725
+ this.history.push(userMsg);
726
+ try {
727
+ // Pass a shallow snapshot so later pushes to `this.history`
728
+ // (e.g. the assistant reply below) don't retroactively mutate
729
+ // what the native side / any mock observed as its `messages`
730
+ // argument.
731
+ const result = await this.model.chatSessionStart(this.history.slice(), config);
732
+ this.history.push(buildAssistantMessage(result.text, result.toolCalls));
733
+ this.turnCount++;
734
+ this.lastImagesKey = newImagesKey;
735
+ this.recordToolCallFanout(result.toolCalls);
736
+ return result;
737
+ }
738
+ catch (err) {
739
+ // Roll back: drop the tentative user push so history stays
740
+ // consistent with turnCount.
741
+ this.history.length = historyLenBefore;
742
+ if (wasImageChangeRestart) {
743
+ // Caches were wiped by prepareStartPath() but the new prefill
744
+ // failed. Force the next call to re-route through the start
745
+ // path with the (preserved) prior history.
746
+ this.turnCount = 0;
747
+ this.lastImagesKey = null;
748
+ }
749
+ throw err;
750
+ }
751
+ }
752
+ /** Streaming counterpart to {@link runStartPath}. */
753
+ async *runStartStreamPath(userMessage, images, newImagesKey, imageChanged, isFirstTurn, config, signal) {
754
+ // Capture pre-state so any non-successful exit can roll back.
755
+ // See `runStartPath` for the full rationale.
756
+ const wasImageChangeRestart = imageChanged && !isFirstTurn;
757
+ const historyLenBefore = this.history.length;
758
+ this.prepareStartPath(imageChanged, isFirstTurn);
759
+ const userMsg = this.buildUserMessage(userMessage, images);
760
+ // Stage the user message on the pending history BEFORE the
761
+ // stream starts — the native call reads it synchronously via
762
+ // `model.chatStreamSessionStart(history, config)`.
763
+ this.history.push(userMsg);
764
+ let sawFinal = false;
765
+ let accumulated = '';
766
+ let finalRaw = null;
767
+ let finalToolCalls;
768
+ // Snapshot the history before dispatch — see `runStartPath` for
769
+ // the rationale.
770
+ const historySnapshot = this.history.slice();
771
+ try {
772
+ for await (const event of this.model.chatStreamSessionStart(historySnapshot, config, signal)) {
773
+ if (event.done) {
774
+ if (event.finishReason !== 'error') {
775
+ sawFinal = true;
776
+ finalRaw = event.text;
777
+ finalToolCalls = event.toolCalls;
778
+ }
779
+ }
780
+ else {
781
+ accumulated += event.text;
782
+ }
783
+ yield event;
784
+ }
785
+ }
786
+ finally {
787
+ // finally runs in ALL termination paths: normal completion,
788
+ // mid-stream throw, caller `break` (which calls
789
+ // `iterator.return()` on the generator and short-circuits the
790
+ // suspended `yield`, skipping any post-loop code), and
791
+ // error-finish chunks. The unified commit-or-rollback below
792
+ // makes restart fully transactional regardless of how the
793
+ // generator was wound down. Mid-stream throws still propagate
794
+ // naturally — finally runs first, then the error continues up.
795
+ if (sawFinal) {
796
+ this.history.push(buildAssistantMessage(finalRaw ?? accumulated, finalToolCalls));
797
+ this.turnCount++;
798
+ this.lastImagesKey = newImagesKey;
799
+ this.recordToolCallFanout(finalToolCalls);
800
+ }
801
+ else {
802
+ // Roll back: drop the tentative user push so history stays
803
+ // consistent with turnCount.
804
+ this.history.length = historyLenBefore;
805
+ if (wasImageChangeRestart) {
806
+ // Caches were wiped by prepareStartPath() but the new
807
+ // prefill never reached a successful done:true. Force the
808
+ // next call to re-route through the start path with the
809
+ // preserved prior history.
810
+ this.turnCount = 0;
811
+ this.lastImagesKey = null;
812
+ }
813
+ }
814
+ }
815
+ }
816
+ /**
817
+ * Shared pre-start bookkeeping for both `send()` and `sendStream()`:
818
+ *
819
+ * - On an image-change restart (turn >= 1), reset the native KV
820
+ * caches so the new image set gets a fresh prefill. History is
821
+ * intentionally preserved — `chatSessionStart` receives the full
822
+ * accumulated conversation plus the new user turn so the jinja
823
+ * render walks every prior turn and every prior image again
824
+ * (see plan's Turn 3 example: "full jinja on 3-turn history +
825
+ * image B"). `lastImagesKey` will be overwritten by the
826
+ * successful start path right after, and `turnCount` is
827
+ * incremented by the start path the same way as for any other
828
+ * turn.
829
+ * - On a fresh / reset history, re-inject the system prompt.
830
+ */
831
+ prepareStartPath(imageChanged, isFirstTurn) {
832
+ if (imageChanged && !isFirstTurn) {
833
+ this.model.resetCaches();
834
+ }
835
+ if (this.history.length === 0 && this.system != null) {
836
+ this.history.push({ role: 'system', content: this.system });
837
+ }
838
+ }
839
+ /** Build a user `ChatMessage` with or without attached images. */
840
+ buildUserMessage(userMessage, images) {
841
+ if (images && images.length > 0) {
842
+ return { role: 'user', content: userMessage, images };
843
+ }
844
+ return { role: 'user', content: userMessage };
845
+ }
846
+ /**
847
+ * Walk the history backward to find the most recent user message
848
+ * with images and return its FNV-1a key. Used by
849
+ * {@link startFromHistory} and {@link startFromHistoryStream} to
850
+ * hydrate `lastImagesKey` after a cold replay, so subsequent delta
851
+ * continues correctly detect image changes.
852
+ */
853
+ computeTrailingImagesKey() {
854
+ for (let i = this.history.length - 1; i >= 0; i--) {
855
+ const msg = this.history[i];
856
+ if (msg?.role === 'user' && msg.images && msg.images.length > 0) {
857
+ return computeImagesKey(msg.images);
858
+ }
859
+ }
860
+ return null;
861
+ }
862
+ /**
863
+ * Derive the post-prime value of `unresolvedOkToolCallCount` from
864
+ * the primed history. Walks backward to the most recent assistant
865
+ * turn, then walks forward from that assistant to the end of history
866
+ * subtracting any `tool:` message that references one of the turn's
867
+ * `call_id`s. A fully-resolved history (every outstanding id matched
868
+ * by a sibling `tool:` message) returns `null`; any leftover count is
869
+ * the number of still-unresolved tool calls.
870
+ *
871
+ * Matches the runtime `recordToolCallFanout` semantics on the hot
872
+ * path: zero unresolved → `null` (no pending obligation); one →
873
+ * `1` (servable via `sendToolResult*()` only); two or more → the
874
+ * count itself (unservable fan-out — must be resolved via cold
875
+ * replay). The distinction between "ok" vs. other statuses only
876
+ * exists in the live `ToolCallResult[]` emitted by the native side —
877
+ * the persisted `ChatMessage.toolCalls` on an assistant message only
878
+ * carries successfully parsed calls (i.e. what would have been "ok"
879
+ * in the original live turn), so counting the array length is
880
+ * equivalent. Tool calls whose `id` is missing or empty can't be
881
+ * matched against subsequent `tool_call_id`s, so in that case we
882
+ * fall back to returning the raw `calls.length` (err safe).
883
+ */
884
+ computeTrailingAssistantUnresolvedToolCallCount() {
885
+ let assistantIdx = -1;
886
+ for (let i = this.history.length - 1; i >= 0; i--) {
887
+ if (this.history[i]?.role === 'assistant') {
888
+ assistantIdx = i;
889
+ break;
890
+ }
891
+ }
892
+ if (assistantIdx === -1)
893
+ return null;
894
+ const assistant = this.history[assistantIdx];
895
+ const calls = assistant.toolCalls ?? [];
896
+ if (calls.length === 0)
897
+ return null;
898
+ const outstanding = new Set();
899
+ let missingIdCount = 0;
900
+ for (const tc of calls) {
901
+ if (typeof tc.id === 'string' && tc.id.length > 0) {
902
+ outstanding.add(tc.id);
903
+ }
904
+ else {
905
+ missingIdCount++;
906
+ }
907
+ }
908
+ // Untracked calls (no id) can't be matched against resolutions —
909
+ // err safe by reporting the raw count.
910
+ if (missingIdCount > 0)
911
+ return calls.length;
912
+ for (let j = assistantIdx + 1; j < this.history.length; j++) {
913
+ const msg = this.history[j];
914
+ if (msg?.role === 'tool' && typeof msg.toolCallId === 'string' && msg.toolCallId.length > 0) {
915
+ outstanding.delete(msg.toolCallId);
916
+ }
917
+ }
918
+ return outstanding.size > 0 ? outstanding.size : null;
919
+ }
920
+ }