@mlx-node/lm 0.0.6 → 0.0.8

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,1467 @@
1
+ /**
2
+ * Generic server-side chat session wrapper.
3
+ *
4
+ * `ChatSession<M>` is the cross-model chat-session wrapper. It works
5
+ * against any model that exposes the uniform chat-session NAPI
6
+ * surface — `chatSessionStart`,
7
+ * `chatSessionContinue`, `chatSessionContinueTool`, and their
8
+ * streaming variants plus `resetCaches`. See `SessionCapableModel`
9
+ * below.
10
+ *
11
+ * Design notes:
12
+ *
13
+ * - The session tracks its own `ChatMessage[]` history on the
14
+ * TypeScript side. In the common text-continue case the history
15
+ * is only appended to and never read back — each `send()` on
16
+ * turn >= 1 issues a cheap `chatSessionContinue` delta against
17
+ * the live KV cache. The history is kept purely so the
18
+ * image-change mid-session path can call `chatSessionStart` with
19
+ * the full rebuilt history for a clean re-prefill.
20
+ *
21
+ * - An image hash (`lastImagesKey`) tracks the images bound to the
22
+ * current cache. A `send()` call whose image set has changed
23
+ * (different bytes or different ordering) triggers a full
24
+ * restart: `resetCaches()` → push the new user message (with
25
+ * images) to history → `chatSessionStart(history)`.
26
+ *
27
+ * - Text-only `send()` on turn >= 1 takes the cheap delta path.
28
+ *
29
+ * - `sendToolResult` always dispatches `chatSessionContinueTool`,
30
+ * since tool turns never change image state. The session enforces
31
+ * a strict unresolved-ok-tool-call contract at runtime, driven by
32
+ * `unresolvedOkToolCallCount` (derived from `ChatResult.toolCalls`
33
+ * after each turn via `countOkToolCalls` /
34
+ * `computeTrailingAssistantUnresolvedToolCallCount`):
35
+ *
36
+ * * `null` — the trailing assistant turn has no outstanding ok
37
+ * tool call. Plain `send()` / `sendStream()` are the only
38
+ * valid entry points; `sendToolResult*()` throws because
39
+ * there is nothing for the result to resolve.
40
+ * * `1` — exactly one outstanding ok tool call. Plain `send()` /
41
+ * `sendStream()` throw (they would orphan the call);
42
+ * `sendToolResult*()` is the sole valid forward step and
43
+ * dispatches the tool result through the native session.
44
+ * * `>1` — a multi-tool-call fan-out that the chat-session API
45
+ * cannot progress incrementally (each `sendToolResult*` would
46
+ * re-open the assistant turn and weave new replies between
47
+ * the sibling results). Both `send()` / `sendStream()` and
48
+ * `sendToolResult*()` throw. The only valid recovery is
49
+ * `reset()` or `primeHistory()` + `startFromHistory*()` with
50
+ * a fully-resolved conversation — there is no "advance past
51
+ * the broken turn" path. This mirrors the native ChatML delta
52
+ * format which would otherwise silently corrupt multi-call
53
+ * conversations.
54
+ *
55
+ * - `sawFinal` gates `turnCount` advance on the streaming path, so
56
+ * the session refuses to advance when the stream throws
57
+ * mid-decode or yields a final chunk with
58
+ * `finishReason: 'error'`.
59
+ *
60
+ * - The `inFlight` guard rejects concurrent `send()` /
61
+ * `sendStream()` calls at the class level. The native side
62
+ * serializes cache mutation on a single worker thread, so a
63
+ * second in-flight call would race the first's cache-save step.
64
+ *
65
+ * - **Cold-restart primitives.** `primeHistory()` plus
66
+ * `startFromHistory()` / `startFromHistoryStream()` let a caller
67
+ * seed a fresh session with an externally-reconstructed history
68
+ * (e.g. a server `ResponseStore` chain) and replay it through the
69
+ * native `chatSessionStart` path without going through `send()`.
70
+ * These are intended for server-side `SessionRegistry` cache-miss
71
+ * cold-start; normal usage stays on `send` / `sendStream` /
72
+ * `sendToolResult` / `reset`.
73
+ *
74
+ * ## Typical usage
75
+ *
76
+ * ```typescript
77
+ * import { Qwen35Model, ChatSession } from '@mlx-node/lm';
78
+ *
79
+ * const model = await Qwen35Model.load('./models/qwen3.5-0.8b');
80
+ * const session = new ChatSession(model, { system: 'Be concise.' });
81
+ * const r1 = await session.send('Say hi in one word.');
82
+ * const r2 = await session.send('Another word?');
83
+ * await session.reset();
84
+ * ```
85
+ */
86
+ import { createHash } from 'node:crypto';
87
+ /**
88
+ * Typed prefix the native delta path uses to reject a text-only
89
+ * continuation while the session still holds image/audio KV state
90
+ * (gemma4 raises this after a media turn). The native session refuses
91
+ * to advance the cheap delta on top of media KV, so the session layer
92
+ * recognizes this exact prefix and transparently replays the whole
93
+ * conversation through the cold start path instead of surfacing the
94
+ * raw error to the caller.
95
+ *
96
+ * MUST stay byte-for-byte identical to the Rust constant
97
+ * `IMAGE_CHANGE_RESTART_PREFIX` in
98
+ * `crates/mlx-core/src/engine/cache.rs` — it is not exported across the
99
+ * NAPI boundary, so the two literals are kept in sync by hand. The
100
+ * native message starts with this prefix and is delivered as the
101
+ * `Error.message`: on the sync delta path as a rejected promise, and on
102
+ * the streaming delta path as a thrown error on the generator's first
103
+ * iteration (the native worker-thread sink error is re-thrown by the
104
+ * `packages/lm/src/stream.ts` bridge before any chunk is yielded).
105
+ */
106
+ const IMAGE_CHANGE_RESTART_PREFIX = 'IMAGE_CHANGE_REQUIRES_SESSION_RESTART:';
107
+ /**
108
+ * Default resolved by the native shared chat engine when `maxNewTokens` is
109
+ * absent. Keep this in sync with `extract_chat_params()` in
110
+ * `crates/mlx-core/src/engine/params.rs`.
111
+ */
112
+ const NATIVE_DEFAULT_MAX_NEW_TOKENS = 2048;
113
+ /**
114
+ * Stable, provider-neutral error raised before native inference when a
115
+ * rendered prompt cannot fit in the model's physically available hot KV
116
+ * window. The marker is intentionally the canonical string recognized by
117
+ * pi's overflow recovery, so managed agent sessions compact and retry while
118
+ * stateless HTTP callers receive a clean request error instead of a native
119
+ * `BlockAllocator exhausted` failure.
120
+ */
121
+ export class ContextCapacityError extends Error {
122
+ promptTokens;
123
+ effectiveWindowTokens;
124
+ code = 'context_length_exceeded';
125
+ constructor(promptTokens, effectiveWindowTokens) {
126
+ super(`context_length_exceeded: rendered prompt uses ${promptTokens} tokens, ` +
127
+ `but this model currently has capacity for ${effectiveWindowTokens} tokens`);
128
+ this.promptTokens = promptTokens;
129
+ this.effectiveWindowTokens = effectiveWindowTokens;
130
+ this.name = 'ContextCapacityError';
131
+ }
132
+ }
133
+ /** Recognize both the typed JS preflight and the native hard backstop. */
134
+ export function isContextCapacityError(error) {
135
+ return (error instanceof ContextCapacityError ||
136
+ (error instanceof Error && error.message.startsWith('context_length_exceeded:')));
137
+ }
138
+ /**
139
+ * Whether `err` is the native media-held delta rejection (see
140
+ * {@link IMAGE_CHANGE_RESTART_PREFIX}). The native message begins with
141
+ * the literal prefix and reaches both the sync and streaming bridges
142
+ * unwrapped (NAPI surfaces `Error.from_reason` as `Error.message`
143
+ * verbatim), so a `startsWith` match is exact.
144
+ */
145
+ function isMediaHeldRestartError(err) {
146
+ return err instanceof Error && err.message.startsWith(IMAGE_CHANGE_RESTART_PREFIX);
147
+ }
148
+ /**
149
+ * Convert the parsed `ToolCallResult[]` emitted by the native chat
150
+ * pipeline into the `ToolCall[]` shape expected by
151
+ * `ChatMessage.toolCalls` (and, by extension, the jinja chat
152
+ * templates on cold replay).
153
+ *
154
+ * Two shape differences to bridge:
155
+ *
156
+ * 1. `ToolCallResult.arguments` is `Record<string, unknown> | string`
157
+ * (already parsed by the native parser when status is "ok",
158
+ * preserved as the original string on parse failure). The
159
+ * `ChatMessage.toolCalls` contract is `arguments: string`, and
160
+ * the native tokenizer's `render_chat_template` pre-parses that
161
+ * string back into a `serde_json::Value` before handing it to
162
+ * jinja. We therefore `JSON.stringify` any non-string argument
163
+ * so the round-trip is lossless. Strings are passed through
164
+ * verbatim so a failed-to-parse payload retains its original
165
+ * bytes (the template then sees it as a quoted string, which is
166
+ * the safest available fallback).
167
+ * 2. Only `status === "ok"` calls carry a well-formed
168
+ * `(name, arguments)` pair — the other statuses (`invalid_json`,
169
+ * `missing_name`, `parse_error`) are informational diagnostics
170
+ * that the native parser emits for observability and that the
171
+ * downstream chat template has no way to render. Preserving them
172
+ * on the replay path would inject garbage tool-call tags into
173
+ * the jinja output. We filter to `ok` entries only — matching the
174
+ * filter every other consumer (server response mapper, tool-use
175
+ * examples, README guidance) already applies.
176
+ *
177
+ * Returns `undefined` when the input is absent or yields no `ok`
178
+ * entries so the assistant `ChatMessage` stays minimal (no empty
179
+ * `toolCalls: []` field polluting the history).
180
+ */
181
+ function toAssistantToolCalls(toolCalls) {
182
+ if (!toolCalls || toolCalls.length === 0)
183
+ return undefined;
184
+ const out = [];
185
+ for (const tc of toolCalls) {
186
+ if (tc.status !== 'ok')
187
+ continue;
188
+ const argsStr = typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments);
189
+ out.push({ id: tc.id, name: tc.name, arguments: argsStr });
190
+ }
191
+ return out.length > 0 ? out : undefined;
192
+ }
193
+ /**
194
+ * Build an assistant `ChatMessage` from a just-completed turn's
195
+ * decoded text + tool-call list. The assistant entry is appended to
196
+ * `this.history` after every successful turn and is later read back
197
+ * by the native `chatSessionStart` cold-replay path (image-change
198
+ * mid-session restart, `startFromHistory*`, server-side
199
+ * `SessionRegistry` cache-miss rebuild). Dropping the `toolCalls`
200
+ * field here would orphan any subsequent `{role: 'tool', ...}`
201
+ * entries on replay — the jinja template would render a
202
+ * `<tool_response>` for a call that was never declared on the
203
+ * preceding assistant turn, corrupting the conversation structure
204
+ * and changing model behavior after a restart.
205
+ */
206
+ function buildAssistantMessage(text, toolCalls) {
207
+ const calls = toAssistantToolCalls(toolCalls);
208
+ if (calls) {
209
+ return { role: 'assistant', content: text, toolCalls: calls };
210
+ }
211
+ return { role: 'assistant', content: text };
212
+ }
213
+ /**
214
+ * Count the `ok`-status tool calls in a `ChatResult.toolCalls` /
215
+ * terminal stream chunk. Used to detect the unsupported multi-call
216
+ * fan-out pattern — the chat-session API only serves one tool call
217
+ * per assistant turn because each `sendToolResult` dispatch
218
+ * immediately re-opens the assistant turn, so a second result would
219
+ * land after a new assistant reply and corrupt the conversation
220
+ * structure. Non-`ok` entries (`parse_error`, `invalid_json`, etc.)
221
+ * are ignored because the caller cannot respond to them anyway.
222
+ */
223
+ function countOkToolCalls(toolCalls) {
224
+ if (!toolCalls || toolCalls.length === 0)
225
+ return 0;
226
+ let n = 0;
227
+ for (const c of toolCalls) {
228
+ if (c.status === 'ok')
229
+ n++;
230
+ }
231
+ return n;
232
+ }
233
+ /**
234
+ * Compute a stable hex-encoded identity key for a list of image
235
+ * byte buffers.
236
+ *
237
+ * Returns `null` when no images are provided so `send()` can
238
+ * distinguish "no-images" from "image set changed". The key is
239
+ * order-sensitive: `[A, B]` and `[B, A]` produce different keys,
240
+ * matching the positional semantics of the underlying VLM chat
241
+ * template.
242
+ *
243
+ * This is a byte-identity check — callers use the key solely to
244
+ * decide whether to restart the server-side session, so any
245
+ * collision-resistant digest is sufficient. We use SHA-256 (native
246
+ * `node:crypto`) with a length-prefixed framing so different image
247
+ * counts and different byte lengths cannot collide by accident.
248
+ *
249
+ * Implementation note: kept fully sync + self-contained so
250
+ * `send()` can stay synchronous in its routing decision. `node:crypto`
251
+ * is a Node built-in, so this adds no external runtime dependency
252
+ * beyond `@mlx-node/core` and the existing stream bridge.
253
+ */
254
+ function computeImagesKey(images) {
255
+ return computeByteListKey(images);
256
+ }
257
+ /**
258
+ * Audio counterpart of {@link computeImagesKey}: a stable, order-sensitive
259
+ * byte-identity key for a list of encoded audio buffers. Used by `send()` /
260
+ * `sendStream()` to decide whether a new audio set must cold-restart the
261
+ * server-side session. Shares the exact SHA-256 framing as the image key.
262
+ */
263
+ function computeAudioKey(audio) {
264
+ return computeByteListKey(audio);
265
+ }
266
+ /**
267
+ * SHA-256 byte-identity key for a length-framed list of byte buffers.
268
+ * Returns `null` for an empty/absent list so callers can distinguish
269
+ * "no media" from "media changed". Shared by the image and audio keys.
270
+ *
271
+ * Uses `node:crypto`'s native SHA-256 rather than a hand-rolled JS hash
272
+ * loop: hashing large image/audio buffers byte-at-a-time in JS is
273
+ * 25-60x slower than the native digest (measured: 5MB ~105ms JS loop
274
+ * vs ~1.8ms native) and this runs synchronously on the event loop
275
+ * before any `await` in `send()`/`sendStream()`, so the JS loop's cost
276
+ * was a real head-of-line-blocking stall for every other request
277
+ * handled by the same process.
278
+ */
279
+ function computeByteListKey(buffers) {
280
+ if (!buffers || buffers.length === 0)
281
+ return null;
282
+ const hash = createHash('sha256');
283
+ // Frame each buffer with a 4-byte little-endian length prefix (and a
284
+ // leading count prefix) so `[ab, c]` and `[a, bc]` — and different
285
+ // buffer counts — hash to distinct values.
286
+ const prefix = new Uint8Array(4);
287
+ const prefixView = new DataView(prefix.buffer);
288
+ prefixView.setUint32(0, buffers.length, true);
289
+ hash.update(prefix);
290
+ for (const buf of buffers) {
291
+ prefixView.setUint32(0, buf.byteLength, true);
292
+ hash.update(prefix);
293
+ hash.update(buf);
294
+ }
295
+ return hash.digest('hex');
296
+ }
297
+ /**
298
+ * Cross-model chat session. See module docstring for design notes.
299
+ *
300
+ * The generic parameter `M` statically captures the concrete model
301
+ * type so the structural interface stays as expressive as the
302
+ * concrete one. Internally the class only uses the
303
+ * `SessionCapableModel` surface.
304
+ */
305
+ export class ChatSession {
306
+ model;
307
+ system;
308
+ defaultConfig;
309
+ /**
310
+ * Full conversation history tracked on the TS side. Appended to on
311
+ * every successful turn. Only read back when the image-change path
312
+ * triggers a restart — normal text continues use the server-side
313
+ * cache, not this array.
314
+ */
315
+ history = [];
316
+ /**
317
+ * Hex-encoded byte-identity key of the image set currently bound
318
+ * to the server's KV cache (SHA-256; see `computeImagesKey`).
319
+ * `null` when no images are cached. A `send()` whose new key
320
+ * differs triggers a full `chatSessionStart` restart.
321
+ */
322
+ lastImagesKey = null;
323
+ /**
324
+ * Hex-encoded byte-identity key of the audio set currently bound to the
325
+ * server's KV cache (see {@link computeAudioKey}). `null` when no audio is
326
+ * cached. A `send()` whose new key differs triggers a full
327
+ * `chatSessionStart` restart — the audio counterpart of `lastImagesKey`.
328
+ */
329
+ lastAudioKey = null;
330
+ turnCount = 0;
331
+ inFlight = false;
332
+ /** A failed/abandoned native delta must be followed by a full replay. */
333
+ needsFullReplay = false;
334
+ /**
335
+ * Count of `ok` tool calls emitted by the prior assistant turn, or
336
+ * `null` when the prior turn produced none. Gates every continuation
337
+ * entry point on the tool-call resolution invariant because each
338
+ * native `chat_session_continue*` dispatch re-opens the assistant
339
+ * turn:
340
+ *
341
+ * - A plain text `send` / `sendStream` after ANY outstanding tool
342
+ * call would orphan the call(s) by weaving a fresh user turn
343
+ * between the assistant's `tool_call` and any response.
344
+ * - A `sendToolResult` / `sendToolResultStream` is only servable
345
+ * when exactly one tool call is outstanding. A multi-call
346
+ * fan-out (`> 1`) cannot be resolved one result at a time — the
347
+ * siblings would be separated by fresh assistant replies — so
348
+ * those entry points also reject.
349
+ *
350
+ * Cleared on every successful commit whose new turn emits zero `ok`
351
+ * tool calls, and on `reset()`. See `assertCanSendPlain` /
352
+ * `assertCanSendToolResult` for the per-entry-point gate logic.
353
+ */
354
+ unresolvedOkToolCallCount = null;
355
+ constructor(model, options = {}) {
356
+ this.model = model;
357
+ this.system = options.system;
358
+ this.defaultConfig = options.defaultConfig ?? {};
359
+ }
360
+ /**
361
+ * Number of completed turns. Increments only after a successful
362
+ * round-trip — in-flight or failed calls leave this untouched.
363
+ */
364
+ get turns() {
365
+ return this.turnCount;
366
+ }
367
+ /** Whether the session currently has images bound to its cache. */
368
+ get hasImages() {
369
+ return this.lastImagesKey !== null;
370
+ }
371
+ /** Load-time physical context snapshot, when exposed by the model. */
372
+ contextLimits() {
373
+ return this.model.contextLimits?.();
374
+ }
375
+ /** Authoritative image-input capability of the loaded native model. */
376
+ supportsImages() {
377
+ return this.model.supportsImages?.() === true;
378
+ }
379
+ /**
380
+ * Render and validate a complete message list against the model's physical
381
+ * context window without starting inference or mutating session/native cache
382
+ * state.
383
+ *
384
+ * HTTP streaming callers use this before committing SSE headers so an
385
+ * oversized prompt can still receive a protocol-shaped 400 response without
386
+ * delaying those headers until image processing, prefill, or the first
387
+ * generated token. The returned config carries the same output-budget clamp
388
+ * applied by the send entry points; those entry points intentionally repeat
389
+ * the check against their own authoritative history before native dispatch.
390
+ */
391
+ async preflightContextCapacity(messages, config) {
392
+ if (this.inFlight) {
393
+ throw new Error('ChatSession: cannot preflight context capacity while a send() is in flight');
394
+ }
395
+ return await this.constrainToContextCapacity(messages.slice(), this.mergeConfig(config));
396
+ }
397
+ /**
398
+ * Capacity-preflight one pending user/tool message against this session's
399
+ * preserved history without starting inference or mutating cache state.
400
+ *
401
+ * This is the exact counterpart of the delta `send*` paths. It matters for
402
+ * server-side prompt-cache hits where the HTTP request contains only the new
403
+ * message while the leased ChatSession owns the earlier conversation.
404
+ */
405
+ async preflightPendingContextCapacity(pending, config) {
406
+ if (this.inFlight) {
407
+ throw new Error('ChatSession: cannot preflight pending context capacity while a send() is in flight');
408
+ }
409
+ if (pending.role === 'user') {
410
+ this.assertCanSendPlain('sendStream');
411
+ }
412
+ else if (pending.role === 'tool') {
413
+ this.assertCanSendToolResult('sendToolResultStream');
414
+ }
415
+ else {
416
+ throw new Error('ChatSession: pending context capacity preflight requires a user or tool message');
417
+ }
418
+ return await this.constrainToContextCapacity(this.historyWithPending(pending), this.mergeConfig(config));
419
+ }
420
+ /**
421
+ * Count of `ok` tool calls from the most recent assistant turn, or
422
+ * `null` when the trailing turn produced none. Non-null means the
423
+ * session is parked on an unresolved tool-call turn and the only
424
+ * forward-progress move is `sendToolResult*()` against one of the
425
+ * outstanding ids — and only when the count is exactly 1. A
426
+ * multi-call fan-out (`> 1`) cannot be served by the chat-session
427
+ * API at all; server endpoints should pre-check this getter and
428
+ * route around a fan-out via `reset()` + `primeHistory()` +
429
+ * `startFromHistory()` cold replay that resolves every sibling in
430
+ * one atomic jinja render.
431
+ *
432
+ * The flag updates after every successful `send` / `sendStream` /
433
+ * `sendToolResult` / `sendToolResultStream` / `startFromHistory*`
434
+ * commit, and after `primeHistory()` (from the trailing assistant
435
+ * message's `toolCalls.length`). `reset()` clears it.
436
+ */
437
+ get pendingUnresolvedToolCallCount() {
438
+ return this.unresolvedOkToolCallCount;
439
+ }
440
+ /**
441
+ * Send a user message and resolve with the assistant reply.
442
+ *
443
+ * Turn 0 and any turn whose image set has changed dispatch through
444
+ * `chatSessionStart` with the full history. All other turns use
445
+ * the cheap `chatSessionContinue` delta path.
446
+ */
447
+ async send(userMessage, opts = {}) {
448
+ if (this.inFlight) {
449
+ throw new Error('ChatSession: concurrent send() not allowed; await the previous call first');
450
+ }
451
+ this.assertCanSendPlain('send');
452
+ this.inFlight = true;
453
+ try {
454
+ const mergedConfig = this.mergeConfig(opts.config);
455
+ const newImagesKey = computeImagesKey(opts.images);
456
+ const newAudioKey = computeAudioKey(opts.audio);
457
+ // Only an explicit NEW image/audio set can trigger a restart. Omitting
458
+ // `images`/`audio` (key === null) is interpreted as "keep the current
459
+ // media cache state" — the server-side cache already holds any prior
460
+ // media context, so a text-only follow-up like "what about the
461
+ // top-right?" can stay on the cheap delta path even after a media turn.
462
+ const imageChanged = newImagesKey !== null && newImagesKey !== this.lastImagesKey;
463
+ const audioChanged = newAudioKey !== null && newAudioKey !== this.lastAudioKey;
464
+ const isFirstTurn = this.turnCount === 0;
465
+ const replayRequired = this.needsFullReplay;
466
+ if (isFirstTurn || imageChanged || audioChanged || replayRequired) {
467
+ return await this.runStartPath(userMessage, opts.images, opts.audio, imageChanged || audioChanged || replayRequired, isFirstTurn, mergedConfig);
468
+ }
469
+ // Delta continue: text-only, images/audio always null. The server
470
+ // cache already holds all prior turns (including any media from an
471
+ // earlier restart), so we only need to ship the new user string.
472
+ const pendingUser = { role: 'user', content: userMessage };
473
+ const constrainedConfig = await this.constrainToContextCapacity(this.historyWithPending(pendingUser), mergedConfig);
474
+ let result;
475
+ try {
476
+ result = await this.model.chatSessionContinue(userMessage, null, null, constrainedConfig);
477
+ }
478
+ catch (err) {
479
+ if (!isMediaHeldRestartError(err)) {
480
+ this.needsFullReplay = true;
481
+ throw err;
482
+ }
483
+ // The native session holds media KV (gemma4 after an image/audio
484
+ // turn) and refused the text delta. Transparently replay the full
485
+ // conversation through the cold start path. The earlier media turn
486
+ // already lives in `this.history`, so the start path re-renders it;
487
+ // the trailing-media keys keep `lastImagesKey`/`lastAudioKey`
488
+ // consistent across the replay. The delta path has NOT pushed
489
+ // `userMessage` yet, so `runStartPath` pushing it adds no duplicate.
490
+ return await this.runStartPath(userMessage, undefined, undefined, true, false, constrainedConfig);
491
+ }
492
+ this.history.push(pendingUser);
493
+ this.history.push(buildAssistantMessage(result.text, result.toolCalls));
494
+ this.turnCount++;
495
+ this.recordToolCallFanout(result.toolCalls);
496
+ return result;
497
+ }
498
+ finally {
499
+ this.inFlight = false;
500
+ }
501
+ }
502
+ /**
503
+ * Streaming variant of {@link ChatSession#send}.
504
+ *
505
+ * Routing matches `send()`. The assistant reply is accumulated
506
+ * from stream deltas and pushed to `history` only after a
507
+ * successful terminal chunk (`done: true` with non-error
508
+ * `finishReason`). Caller break, mid-stream exceptions, and error
509
+ * finishes all leave `turnCount` untouched and the history
510
+ * un-appended for the turn so the next call re-routes through the
511
+ * start path.
512
+ */
513
+ async *sendStream(userMessage, opts = {}) {
514
+ if (this.inFlight) {
515
+ throw new Error('ChatSession: concurrent send() not allowed; await the previous call first');
516
+ }
517
+ this.assertCanSendPlain('sendStream');
518
+ this.inFlight = true;
519
+ try {
520
+ const mergedConfig = this.mergeConfig(opts.config);
521
+ const newImagesKey = computeImagesKey(opts.images);
522
+ const newAudioKey = computeAudioKey(opts.audio);
523
+ // Only an explicit NEW image/audio set can trigger a restart. Omitting
524
+ // `images`/`audio` (key === null) is interpreted as "keep the current
525
+ // media cache state" — the server-side cache already holds any prior
526
+ // media context, so a text-only follow-up like "what about the
527
+ // top-right?" can stay on the cheap delta path even after a media turn.
528
+ const imageChanged = newImagesKey !== null && newImagesKey !== this.lastImagesKey;
529
+ const audioChanged = newAudioKey !== null && newAudioKey !== this.lastAudioKey;
530
+ const isFirstTurn = this.turnCount === 0;
531
+ const replayRequired = this.needsFullReplay;
532
+ if (isFirstTurn || imageChanged || audioChanged || replayRequired) {
533
+ yield* this.runStartStreamPath(userMessage, opts.images, opts.audio, imageChanged || audioChanged || replayRequired, isFirstTurn, mergedConfig, opts.signal);
534
+ return;
535
+ }
536
+ // Delta continue stream: text-only.
537
+ const pendingUser = { role: 'user', content: userMessage };
538
+ const constrainedConfig = await this.constrainToContextCapacity(this.historyWithPending(pendingUser), mergedConfig);
539
+ let sawFinal = false;
540
+ let accumulated = '';
541
+ let accumulatedVisible = '';
542
+ let finalRaw = null;
543
+ let finalToolCalls;
544
+ // Set when the media-held rejection re-routes this turn through the
545
+ // cold start stream. The replay path owns the history push, turnCount
546
+ // increment, and media-key rehydration, so the commit `finally` below
547
+ // must NOT also fire.
548
+ let delegated = false;
549
+ try {
550
+ try {
551
+ for await (const event of this.model.chatStreamSessionContinue(userMessage, null, null, constrainedConfig, opts.signal)) {
552
+ if (event.done) {
553
+ if (event.finishReason !== 'error') {
554
+ sawFinal = true;
555
+ finalRaw = event.text;
556
+ finalToolCalls = event.toolCalls;
557
+ }
558
+ }
559
+ else {
560
+ accumulated += event.text;
561
+ if (event.isReasoning !== true) {
562
+ accumulatedVisible += event.text;
563
+ }
564
+ }
565
+ yield event;
566
+ }
567
+ }
568
+ catch (err) {
569
+ // The native session holds media KV (gemma4 after an image/audio
570
+ // turn) and refused the text delta. The streaming bridge re-throws
571
+ // that rejection on the first iteration, BEFORE any chunk is
572
+ // emitted — the native guard fires ahead of any prefill, so
573
+ // `!sawFinal && accumulated === ''` is guaranteed here. Replay the
574
+ // full conversation through the cold start stream. Any non-prefix
575
+ // error, or any error after tokens were already emitted, must
576
+ // propagate unchanged.
577
+ if (!isMediaHeldRestartError(err) || sawFinal || accumulated !== '') {
578
+ throw err;
579
+ }
580
+ delegated = true;
581
+ yield* this.runStartStreamPath(userMessage, undefined, undefined, true, false, constrainedConfig, opts.signal);
582
+ return;
583
+ }
584
+ }
585
+ finally {
586
+ // finally runs for normal completion, mid-stream throw,
587
+ // caller `break` (which calls `iterator.return()` and
588
+ // short-circuits the suspended yield), and error-finish
589
+ // chunks alike. The delta path doesn't push to history until
590
+ // commit, so the rollback branch is a no-op: nothing to
591
+ // undo, and the native cache state is managed by the Rust
592
+ // save_cache_state path on its own. When the media-held
593
+ // rejection delegated to the replay stream, that path already
594
+ // committed (or rolled back) — so this commit must stay off.
595
+ if (sawFinal && !delegated) {
596
+ this.history.push(pendingUser);
597
+ this.history.push(buildAssistantMessage(finalRaw || accumulatedVisible, finalToolCalls));
598
+ this.turnCount++;
599
+ this.recordToolCallFanout(finalToolCalls);
600
+ }
601
+ else if (!delegated) {
602
+ // Qwen commits cancelled/failed delta tokens to its native cached
603
+ // history even though this JS turn is intentionally uncommitted.
604
+ // The next plain turn must reset and replay the preserved history.
605
+ this.needsFullReplay = true;
606
+ }
607
+ }
608
+ }
609
+ finally {
610
+ this.inFlight = false;
611
+ }
612
+ }
613
+ /**
614
+ * Send a tool-result turn. Always dispatches
615
+ * `chatSessionContinueTool` — tool turns never change image state,
616
+ * so there is no restart path here.
617
+ *
618
+ * Rejects if the prior assistant turn emitted more than one `ok`
619
+ * tool call: the chat-session API only supports exactly one tool
620
+ * call per assistant turn because each `sendToolResult` dispatch
621
+ * immediately re-opens the assistant turn, so responding to the
622
+ * remaining calls would interleave new assistant replies between
623
+ * the results and corrupt the conversation structure. Callers that
624
+ * hit this must tighten the prompt / tool spec or reset the
625
+ * session.
626
+ *
627
+ * `isError` is the structured tool-error signal. When `true`, the
628
+ * native renderer prepends a short, model-facing error marker to
629
+ * `content` inside the wire-format tool block so the model
630
+ * receives a clear text-level cue that the tool result represents
631
+ * a failure. The structured field is stored verbatim on the
632
+ * appended `{ role: 'tool', ... }` history entry so cold-replay
633
+ * (image-change restart, `startFromHistory*`, server-side
634
+ * `SessionRegistry` cache-miss rebuild) re-renders the marker
635
+ * consistently with the live turn. Defaults to `undefined` (no
636
+ * marker). Pass through verbatim — we do NOT infer error from
637
+ * `content`.
638
+ *
639
+ * Appends a `{ role: 'tool', ... }` message to history on success.
640
+ */
641
+ async sendToolResult(toolCallId, content, opts = {}) {
642
+ if (this.inFlight) {
643
+ throw new Error('ChatSession: concurrent send() not allowed; await the previous call first');
644
+ }
645
+ this.assertCanSendToolResult('sendToolResult');
646
+ this.inFlight = true;
647
+ try {
648
+ const { isError, config } = opts;
649
+ const mergedConfig = this.mergeConfig(config);
650
+ const toolMsg = { role: 'tool', content, toolCallId, isError };
651
+ const constrainedConfig = await this.constrainToContextCapacity(this.historyWithPending(toolMsg), mergedConfig);
652
+ // A cold native session (turnCount===0) has no live KV to delta
653
+ // against — the typical cause is an interrupted media-held replay
654
+ // whose rollback wiped the cache and reset the counter while
655
+ // leaving the unresolved tool-call flag set. Mirror `send()`'s
656
+ // turn-0 routing: replay the preserved history through the cold
657
+ // start path instead of dispatching a delta that the native side
658
+ // would reject with an un-prefixed "requires an initialized
659
+ // session" error. A normal tool result always follows a prior
660
+ // tool-call turn (turnCount>=1), so this never fires on the happy
661
+ // path.
662
+ if (this.turnCount === 0 || this.needsFullReplay) {
663
+ return await this.replayToolResultThroughStartPath(toolMsg, constrainedConfig);
664
+ }
665
+ try {
666
+ const result = await this.model.chatSessionContinueTool(toolCallId, content, constrainedConfig, isError ?? null);
667
+ this.history.push({ role: 'tool', content, toolCallId, isError });
668
+ this.history.push(buildAssistantMessage(result.text, result.toolCalls));
669
+ this.turnCount++;
670
+ this.recordToolCallFanout(result.toolCalls);
671
+ return result;
672
+ }
673
+ catch (err) {
674
+ if (!isMediaHeldRestartError(err)) {
675
+ this.needsFullReplay = true;
676
+ throw err;
677
+ }
678
+ // The native session holds media KV (gemma4 after an image/audio
679
+ // turn) and refused the tool-result delta. Transparently replay
680
+ // the full conversation through the cold start path. The prior
681
+ // media turn already lives in `this.history`, so the start path
682
+ // re-renders it; the trailing-media keys keep
683
+ // `lastImagesKey`/`lastAudioKey` consistent across the replay.
684
+ // The delta path threw before pushing the tool message, so the
685
+ // restart core pushes it — `isError` rides on that message so the
686
+ // wire-format error marker is re-rendered, and a tool result
687
+ // always follows >=1 prior turn so `isFirstTurn` is false.
688
+ return await this.replayToolResultThroughStartPath(toolMsg, constrainedConfig);
689
+ }
690
+ }
691
+ finally {
692
+ this.inFlight = false;
693
+ }
694
+ }
695
+ /**
696
+ * Cold-replay a tool result through the start path: re-render the
697
+ * full preserved history (including the prior media turn and the
698
+ * unresolved tool-call assistant turn) plus this tool message. Used
699
+ * when the native session is cold (turnCount===0 — e.g. after an
700
+ * interrupted media-held replay rolled the cache back) and by the
701
+ * media-held rejection catch. `mediaChanged=true` forces a
702
+ * resetCaches so the prefill always starts from a guaranteed-clean
703
+ * cache; `isFirstTurn=false` because a tool result always follows a
704
+ * prior tool-call turn.
705
+ */
706
+ async replayToolResultThroughStartPath(toolMsg, config) {
707
+ return await this.runStartPathWithMessage(toolMsg, true, false, config);
708
+ }
709
+ /**
710
+ * Streaming variant of {@link ChatSession#sendToolResult}.
711
+ *
712
+ * `isError` mirrors the non-streaming entry point — when `true`,
713
+ * the native renderer prepends a short, model-facing error marker
714
+ * to `content` inside the wire-format tool block. The structured
715
+ * field is stored verbatim on the appended `{ role: 'tool', ... }`
716
+ * history entry so cold-replay re-renders the marker consistently
717
+ * with the live streaming turn.
718
+ */
719
+ async *sendToolResultStream(toolCallId, content, opts = {}) {
720
+ if (this.inFlight) {
721
+ throw new Error('ChatSession: concurrent send() not allowed; await the previous call first');
722
+ }
723
+ this.assertCanSendToolResult('sendToolResultStream');
724
+ this.inFlight = true;
725
+ try {
726
+ const { isError, config, signal } = opts;
727
+ const mergedConfig = this.mergeConfig(config);
728
+ const toolMsg = { role: 'tool', content, toolCallId, isError };
729
+ const constrainedConfig = await this.constrainToContextCapacity(this.historyWithPending(toolMsg), mergedConfig);
730
+ // A cold native session (turnCount===0) has no live KV to delta
731
+ // against — typically the residue of an interrupted media-held
732
+ // replay whose rollback wiped the cache and reset the counter
733
+ // while leaving the unresolved tool-call flag set. Mirror
734
+ // `sendStream()`'s turn-0 routing: replay the preserved history
735
+ // through the cold start stream and return before the
736
+ // delta/commit machinery so the start path owns the history push,
737
+ // turnCount increment, and media-key rehydration. A normal tool
738
+ // result always follows a prior tool-call turn (turnCount>=1), so
739
+ // this never fires on the happy path.
740
+ if (this.turnCount === 0 || this.needsFullReplay) {
741
+ yield* this.replayToolResultThroughStartStreamPath(toolMsg, constrainedConfig, signal);
742
+ return;
743
+ }
744
+ let sawFinal = false;
745
+ let accumulated = '';
746
+ let accumulatedVisible = '';
747
+ let finalRaw = null;
748
+ let finalToolCalls;
749
+ // Set when the media-held rejection re-routes this tool turn
750
+ // through the cold start stream. The replay path owns the history
751
+ // push, turnCount increment, and media-key rehydration, so the
752
+ // commit `finally` below must NOT also fire.
753
+ let delegated = false;
754
+ try {
755
+ try {
756
+ for await (const event of this.model.chatStreamSessionContinueTool(toolCallId, content, constrainedConfig, signal, isError ?? null)) {
757
+ if (event.done) {
758
+ if (event.finishReason !== 'error') {
759
+ sawFinal = true;
760
+ finalRaw = event.text;
761
+ finalToolCalls = event.toolCalls;
762
+ }
763
+ }
764
+ else {
765
+ accumulated += event.text;
766
+ if (event.isReasoning !== true) {
767
+ accumulatedVisible += event.text;
768
+ }
769
+ }
770
+ yield event;
771
+ }
772
+ }
773
+ catch (err) {
774
+ // The native session holds media KV (gemma4 after an image/audio
775
+ // turn) and refused the tool-result delta. The streaming bridge
776
+ // re-throws that rejection on the first iteration, BEFORE any
777
+ // chunk is emitted — the native guard fires ahead of any
778
+ // prefill, so `!sawFinal && accumulated === ''` is guaranteed
779
+ // here. Replay the full conversation through the cold start
780
+ // stream with the pending tool message; `isError` rides on it so
781
+ // the wire-format error marker is re-rendered. Any non-prefix
782
+ // error, or any error after tokens were already emitted, must
783
+ // propagate unchanged.
784
+ if (!isMediaHeldRestartError(err) || sawFinal || accumulated !== '') {
785
+ throw err;
786
+ }
787
+ delegated = true;
788
+ yield* this.replayToolResultThroughStartStreamPath(toolMsg, constrainedConfig, signal);
789
+ return;
790
+ }
791
+ }
792
+ finally {
793
+ // finally runs for normal completion, mid-stream throw,
794
+ // caller `break` (iterator.return() short-circuits the yield),
795
+ // and error-finish chunks alike. Tool turns never touch
796
+ // history until commit, so the rollback branch is a no-op. When
797
+ // the media-held rejection delegated to the replay stream, that
798
+ // path already committed (or rolled back), so this commit stays
799
+ // off.
800
+ if (sawFinal && !delegated) {
801
+ this.history.push({ role: 'tool', content, toolCallId, isError });
802
+ this.history.push(buildAssistantMessage(finalRaw || accumulatedVisible, finalToolCalls));
803
+ this.turnCount++;
804
+ this.recordToolCallFanout(finalToolCalls);
805
+ }
806
+ else if (!delegated) {
807
+ this.needsFullReplay = true;
808
+ }
809
+ }
810
+ }
811
+ finally {
812
+ this.inFlight = false;
813
+ }
814
+ }
815
+ /**
816
+ * Streaming counterpart of {@link replayToolResultThroughStartPath}:
817
+ * cold-replay a tool result through the start stream. Used by the
818
+ * turn-0 precheck and the media-held rejection catch in
819
+ * {@link sendToolResultStream}. The start stream owns the history
820
+ * push, turnCount increment, and media-key rehydration; callers keep
821
+ * `delegated`/early-return semantics so the commit `finally` stays
822
+ * off.
823
+ */
824
+ async *replayToolResultThroughStartStreamPath(toolMsg, config, signal) {
825
+ yield* this.runStartStreamPathWithMessage(toolMsg, true, false, config, signal);
826
+ }
827
+ /**
828
+ * Reset the session state.
829
+ *
830
+ * Clears the underlying model's KV caches and wipes local history,
831
+ * image key, and turn counter so the next `send()` goes through
832
+ * `chatSessionStart` again.
833
+ *
834
+ * This is a full wipe — safe default for public callers. It always
835
+ * calls `model.resetCaches()`, which is the ONLY behavior exposed
836
+ * on the public API because the underlying `SessionCapableModel`
837
+ * is shared across every `ChatSession` lifetime via the native
838
+ * `ModelRegistry`: a partial wipe that leaves the shared native
839
+ * KV cache intact would leak a previous (unrelated) request's
840
+ * cached prefix into the next `chat_session_start_sync` call. The
841
+ * server-side warm-lease replay path (where preserving the native
842
+ * cache is correct) uses its own server-private helper gated by
843
+ * the `SessionRegistry` HIT signal — the only authoritative proof
844
+ * that the native cache genuinely belongs to this chain. That
845
+ * helper lives inside `@mlx-node/server`, never touches the
846
+ * `@mlx-node/lm` export map, and is not reachable from downstream
847
+ * consumers. Public consumers of `@mlx-node/lm` have no such HIT
848
+ * signal, so the public API intentionally offers only the full-wipe
849
+ * option.
850
+ *
851
+ * Returns `Promise<void>` for an async-friendly signature even
852
+ * though `resetCaches()` is currently synchronous.
853
+ */
854
+ async reset() {
855
+ if (this.inFlight) {
856
+ throw new Error('ChatSession: cannot reset() while a send() is in flight; await the previous call first');
857
+ }
858
+ this.model.resetCaches();
859
+ this.history = [];
860
+ this.lastImagesKey = null;
861
+ this.lastAudioKey = null;
862
+ this.turnCount = 0;
863
+ this.unresolvedOkToolCallCount = null;
864
+ this.needsFullReplay = false;
865
+ }
866
+ /**
867
+ * Prime the session history without running inference.
868
+ *
869
+ * Used by the server-side `SessionRegistry` cold-start fallback: when
870
+ * a request arrives with a `previous_response_id` that the cache has
871
+ * missed, the endpoint reconstructs the full conversation from the
872
+ * `ResponseStore` and primes a fresh session with it, then calls
873
+ * `startFromHistory()` to replay it through the native KV cache.
874
+ *
875
+ * Rejects if the session is in flight or has already taken a turn.
876
+ * Replaces the internal history with a shallow copy of `messages`.
877
+ */
878
+ primeHistory(messages) {
879
+ if (this.inFlight) {
880
+ throw new Error('ChatSession: cannot primeHistory() while a send() is in flight');
881
+ }
882
+ if (this.turnCount > 0) {
883
+ throw new Error('ChatSession: primeHistory() can only be called on a fresh session (turn 0)');
884
+ }
885
+ this.history = messages.slice();
886
+ // Derive the unresolved-tool-call guard from the trailing assistant
887
+ // turn in the primed history so an immediately-post-prime session
888
+ // exposes the same `pendingUnresolvedToolCallCount` state a live
889
+ // session would have been in at that point of the conversation.
890
+ // This lets the server endpoint layer (and any other caller)
891
+ // pre-check the guard before starting cold replay and route around
892
+ // unresolved turns instead of letting `startFromHistory*()` blindly
893
+ // advance past them. The flag is reset on commit in both sync and
894
+ // streaming start-from-history paths based on the new assistant
895
+ // reply, which is the correct semantics for the post-replay current
896
+ // position.
897
+ this.unresolvedOkToolCallCount = this.computeTrailingAssistantUnresolvedToolCallCount();
898
+ // lastImagesKey stays null until startFromHistory() / send() runs —
899
+ // the trailing-images hydration happens at commit time.
900
+ }
901
+ /**
902
+ * Run a cold-start `chatSessionStart` using the currently primed
903
+ * history.
904
+ *
905
+ * Intended pairing with {@link primeHistory}: call
906
+ * `primeHistory(fullHistory)` first, then `startFromHistory()` to
907
+ * replay the conversation through the native chat-session API. The
908
+ * final history entry must be a user or tool turn — this is what the
909
+ * native side treats as the "current input" to generate against.
910
+ *
911
+ * Pushes the assistant reply onto the history, advances `turnCount`
912
+ * to 1, and computes `lastImagesKey` from the most recent user
913
+ * message that carries images (so subsequent text-only continues
914
+ * stay on the delta path, and subsequent image turns correctly
915
+ * trigger restart).
916
+ */
917
+ async startFromHistory(config) {
918
+ if (this.inFlight) {
919
+ throw new Error('ChatSession: cannot startFromHistory() while a send() is in flight');
920
+ }
921
+ if (this.turnCount > 0) {
922
+ throw new Error('ChatSession: startFromHistory() can only be called on a fresh session');
923
+ }
924
+ if (this.history.length === 0) {
925
+ throw new Error('ChatSession: startFromHistory() requires a primed history');
926
+ }
927
+ this.inFlight = true;
928
+ try {
929
+ const mergedConfig = this.mergeConfig(config);
930
+ const historySnapshot = this.history.slice();
931
+ const constrainedConfig = await this.constrainToContextCapacity(historySnapshot, mergedConfig);
932
+ const result = await this.model.chatSessionStart(historySnapshot, constrainedConfig);
933
+ this.history.push(buildAssistantMessage(result.text, result.toolCalls));
934
+ this.turnCount++;
935
+ this.needsFullReplay = false;
936
+ this.lastImagesKey = this.computeTrailingImagesKey();
937
+ this.lastAudioKey = this.computeTrailingAudioKey();
938
+ this.recordToolCallFanout(result.toolCalls);
939
+ return result;
940
+ }
941
+ finally {
942
+ this.inFlight = false;
943
+ }
944
+ }
945
+ /**
946
+ * Streaming counterpart to {@link startFromHistory}.
947
+ *
948
+ * Iterates `model.chatStreamSessionStart(history.slice(), config)`,
949
+ * accumulates text, and only commits history + `turnCount` +
950
+ * `lastImagesKey` in the `finally` block when a successful terminal
951
+ * chunk was observed (`done: true` with non-error finishReason).
952
+ * Because history is primed (not appended to), rollback on failure
953
+ * is a no-op: the primed state stays intact so the caller can retry.
954
+ */
955
+ async *startFromHistoryStream(config, signal) {
956
+ if (this.inFlight) {
957
+ throw new Error('ChatSession: cannot startFromHistoryStream() while a send() is in flight');
958
+ }
959
+ if (this.turnCount > 0) {
960
+ throw new Error('ChatSession: startFromHistoryStream() can only be called on a fresh session');
961
+ }
962
+ if (this.history.length === 0) {
963
+ throw new Error('ChatSession: startFromHistoryStream() requires a primed history');
964
+ }
965
+ this.inFlight = true;
966
+ try {
967
+ const mergedConfig = this.mergeConfig(config);
968
+ const historySnapshot = this.history.slice();
969
+ const constrainedConfig = await this.constrainToContextCapacity(historySnapshot, mergedConfig);
970
+ let sawFinal = false;
971
+ let accumulated = '';
972
+ let accumulatedVisible = '';
973
+ let finalRaw = null;
974
+ let finalToolCalls;
975
+ try {
976
+ for await (const event of this.model.chatStreamSessionStart(historySnapshot, constrainedConfig, signal)) {
977
+ if (event.done) {
978
+ if (event.finishReason !== 'error') {
979
+ sawFinal = true;
980
+ finalRaw = event.text;
981
+ finalToolCalls = event.toolCalls;
982
+ }
983
+ }
984
+ else {
985
+ accumulated += event.text;
986
+ if (event.isReasoning !== true) {
987
+ accumulatedVisible += event.text;
988
+ }
989
+ }
990
+ yield event;
991
+ }
992
+ }
993
+ finally {
994
+ // finally runs on normal completion, mid-stream throw, caller
995
+ // `break` (iterator.return() short-circuits the yield), and
996
+ // error-finish chunks alike. The primed history is only
997
+ // mutated on a successful commit — on any non-success exit,
998
+ // the primed state is left intact so the caller can retry.
999
+ if (sawFinal) {
1000
+ this.history.push(buildAssistantMessage(finalRaw || accumulatedVisible, finalToolCalls));
1001
+ this.turnCount++;
1002
+ this.needsFullReplay = false;
1003
+ this.lastImagesKey = this.computeTrailingImagesKey();
1004
+ this.lastAudioKey = this.computeTrailingAudioKey();
1005
+ this.recordToolCallFanout(finalToolCalls);
1006
+ }
1007
+ }
1008
+ }
1009
+ finally {
1010
+ this.inFlight = false;
1011
+ }
1012
+ }
1013
+ // -------------------------------------------------------------------
1014
+ // Internal helpers
1015
+ // -------------------------------------------------------------------
1016
+ /**
1017
+ * Gate plain-text continuation entry points (`send`, `sendStream`)
1018
+ * on the tool-call resolution invariant. Any outstanding `ok` tool
1019
+ * call from the prior assistant turn — single or multi — makes a
1020
+ * plain text continuation unsafe: the native chat-session API
1021
+ * re-opens the assistant turn on each continue, so a new user delta
1022
+ * would weave a fresh user message between the assistant's
1023
+ * `tool_call` and any response, orphaning the call. Callers must
1024
+ * resolve outstanding calls via `sendToolResult*()` (single-call
1025
+ * case) or re-enter via `reset()` + `primeHistory()` +
1026
+ * `startFromHistory()` with a resolved conversation (multi-call
1027
+ * fan-out). `reset()` clears the flag and `startFromHistory*`
1028
+ * overwrites it via `recordToolCallFanout` on the new response, so
1029
+ * legitimate recovery paths are unaffected.
1030
+ */
1031
+ assertCanSendPlain(entryPoint) {
1032
+ const n = this.unresolvedOkToolCallCount;
1033
+ if (n !== null) {
1034
+ const plural = n === 1 ? '' : 's';
1035
+ const followUp = n > 1
1036
+ ? `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`
1037
+ : `resolve the outstanding call via sendToolResult()`;
1038
+ throw new Error(`ChatSession.${entryPoint}: previous assistant turn has ${n} unresolved ok tool call${plural}; ` +
1039
+ `a plain text continuation would orphan the call${plural} by weaving a new user turn between the ` +
1040
+ `assistant's tool_call and any response. ${followUp}, reset() the session, or re-enter through ` +
1041
+ `primeHistory() + startFromHistory() with a resolved conversation.`);
1042
+ }
1043
+ }
1044
+ /**
1045
+ * Gate tool-result entry points (`sendToolResult`,
1046
+ * `sendToolResultStream`) on the single-tool-call-per-turn
1047
+ * invariant. Exactly one outstanding tool call is servable — that
1048
+ * is the case these methods exist for.
1049
+ *
1050
+ * Zero outstanding calls (`null`) is also unservable: without a
1051
+ * preceding assistant turn that emitted a tool call, a tool-result
1052
+ * dispatch would synthesize a `<tool_response>` delta for a call
1053
+ * that never existed, corrupting the conversation structure. The
1054
+ * native backends do not authenticate `tool_call_id` against prior
1055
+ * state — several simply append the tool-response delta verbatim —
1056
+ * so rejecting here is the only gate that prevents forged tool
1057
+ * state from reaching the model. Callers that want to start a
1058
+ * conversation on a resolved tool turn must prime an unresolved
1059
+ * single-call assistant turn via `primeHistory()` +
1060
+ * `startFromHistory()` first.
1061
+ *
1062
+ * A multi-call fan-out (`> 1`) cannot be resolved one result at a
1063
+ * time because each `sendToolResult` dispatch immediately re-opens
1064
+ * the assistant turn, so responding to the siblings would
1065
+ * interleave new assistant replies between the results.
1066
+ */
1067
+ assertCanSendToolResult(entryPoint) {
1068
+ const n = this.unresolvedOkToolCallCount;
1069
+ if (n === null) {
1070
+ throw new Error(`ChatSession.${entryPoint}: no outstanding ok tool call on the previous assistant turn. ` +
1071
+ `Tool-result entry points can only be called when the model has just emitted exactly one ` +
1072
+ `ok tool call that has not yet been resolved — dispatching a tool result against an empty ` +
1073
+ `or already-resolved turn would synthesize a <tool_response> delta for a call that never ` +
1074
+ `existed and corrupt the conversation structure. Call send() / sendStream() for plain user ` +
1075
+ `turns, or re-enter through primeHistory() + startFromHistory() with a conversation that ` +
1076
+ `ends on an unresolved single-call assistant turn.`);
1077
+ }
1078
+ if (n > 1) {
1079
+ throw new Error(`ChatSession.${entryPoint}: previous assistant turn emitted ${n} ok tool calls; ` +
1080
+ `the chat-session API only supports exactly one tool call per assistant turn because each tool-result ` +
1081
+ `call immediately re-opens the assistant turn — responding to the siblings would interleave new assistant ` +
1082
+ `replies between the results. Tighten the prompt / tool spec so the model produces at most one call per ` +
1083
+ `turn, reset() the session, or re-enter through primeHistory() + startFromHistory() with a resolved ` +
1084
+ `conversation.`);
1085
+ }
1086
+ }
1087
+ /**
1088
+ * Inspect a just-committed turn's tool calls and store the count of
1089
+ * `ok` entries in `unresolvedOkToolCallCount`. Any non-zero count
1090
+ * parks the session on an unresolved tool-call turn, which gates
1091
+ * the next entry point:
1092
+ *
1093
+ * - count === 0 → flag is `null`: `send`/`sendStream` ok,
1094
+ * `sendToolResult*` throws (no outstanding call to resolve)
1095
+ * - count === 1 → `send`/`sendStream` throw; `sendToolResult*` ok
1096
+ * - count > 1 → every entry point throws (fan-out unservable)
1097
+ *
1098
+ * See `assertCanSendPlain` / `assertCanSendToolResult` for the full
1099
+ * rationale.
1100
+ */
1101
+ recordToolCallFanout(toolCalls) {
1102
+ const n = countOkToolCalls(toolCalls);
1103
+ this.unresolvedOkToolCallCount = n > 0 ? n : null;
1104
+ }
1105
+ /**
1106
+ * Merge default + per-call config and force `reuseCache: true`.
1107
+ * The session path is a session-reuse operation by construction —
1108
+ * `reuseCache: false` on the continue path would wipe the very
1109
+ * cache the delta depends on.
1110
+ *
1111
+ * MTP auto-default: if neither `defaultConfig` nor `overlay`
1112
+ * sets `enableMtp` AND the underlying model exposes
1113
+ * `hasMtpWeights()` returning `true`, set `enableMtp = true` so the
1114
+ * speculative-decode path runs out of the box on MTP-capable
1115
+ * checkpoints. An explicit `false` from either source wins (the
1116
+ * undefined-check below preserves it). This duck-typed check also
1117
+ * covers Gemma4 with an external draft attached — DSpark or Google
1118
+ * assistant (`hasMtpWeights()` reports the external draft there,
1119
+ * not in-checkpoint MTP heads).
1120
+ */
1121
+ mergeConfig(overlay) {
1122
+ const merged = {
1123
+ ...this.defaultConfig,
1124
+ ...overlay,
1125
+ reuseCache: true,
1126
+ };
1127
+ if (merged.enableMtp === undefined &&
1128
+ typeof this.model.hasMtpWeights === 'function' &&
1129
+ this.model.hasMtpWeights()) {
1130
+ merged.enableMtp = true;
1131
+ }
1132
+ return merged;
1133
+ }
1134
+ /**
1135
+ * Render the exact full prompt and constrain generation to the physical KV
1136
+ * window before native code allocates a block. Models that do not expose
1137
+ * both the tokenizer seam and a load-time context snapshot retain their
1138
+ * existing behavior.
1139
+ *
1140
+ * The returned config is a copy only when `maxNewTokens` needs clamping.
1141
+ * An omitted output budget stays omitted when the native default fits; it is
1142
+ * made explicit only when the remaining window is smaller than that default.
1143
+ */
1144
+ async constrainToContextCapacity(messages, config) {
1145
+ if (typeof this.model.applyChatTemplate !== 'function' || typeof this.model.contextLimits !== 'function') {
1146
+ return config;
1147
+ }
1148
+ const limits = this.model.contextLimits();
1149
+ const capacity = Math.floor(limits.effectiveWindowTokens);
1150
+ if (!Number.isSafeInteger(capacity) || capacity <= 0) {
1151
+ return config;
1152
+ }
1153
+ const effort = config.reasoningEffort;
1154
+ const enableThinking = effort === 'none' || effort === 'low' ? false : effort === 'medium' || effort === 'high' ? true : null;
1155
+ const tokens = await this.model.applyChatTemplate(messages, true, config.tools ?? null, enableThinking);
1156
+ const hasImages = messages.some((message) => (message.images?.length ?? 0) > 0);
1157
+ let promptTokens = tokens.length;
1158
+ if (hasImages && typeof this.model.expandedPromptTokenCount === 'function') {
1159
+ promptTokens = await this.model.expandedPromptTokenCount(tokens, messages);
1160
+ if (!Number.isSafeInteger(promptTokens) || promptTokens < tokens.length) {
1161
+ throw new Error(`ChatSession: expandedPromptTokenCount returned invalid length ${promptTokens} ` +
1162
+ `(rendered template length is ${tokens.length})`);
1163
+ }
1164
+ }
1165
+ if (promptTokens > capacity) {
1166
+ throw new ContextCapacityError(promptTokens, capacity);
1167
+ }
1168
+ // The final sampled token is returned without another model forward, so N
1169
+ // generated tokens consume only N-1 additional KV positions.
1170
+ const maxOutput = capacity - promptTokens + 1;
1171
+ const requested = config.maxNewTokens;
1172
+ if (requested === undefined) {
1173
+ return maxOutput >= NATIVE_DEFAULT_MAX_NEW_TOKENS ? config : { ...config, maxNewTokens: maxOutput };
1174
+ }
1175
+ const maxNewTokens = Math.min(requested, maxOutput);
1176
+ return maxNewTokens === requested ? config : { ...config, maxNewTokens };
1177
+ }
1178
+ /** Full history that a pending user/tool turn would render, without mutation. */
1179
+ historyWithPending(pending) {
1180
+ const messages = this.history.slice();
1181
+ if (messages.length === 0 && this.system != null) {
1182
+ messages.push({ role: 'system', content: this.system });
1183
+ }
1184
+ messages.push(pending);
1185
+ return messages;
1186
+ }
1187
+ /**
1188
+ * Shared start-path logic for `send()`. Handles both the turn-0
1189
+ * first-ever-send case and the image-change mid-session restart
1190
+ * case. The image-change restart preserves prior history so the
1191
+ * native side gets the full conversation re-rendered with the new
1192
+ * image set.
1193
+ */
1194
+ async runStartPath(userMessage, images, audio, mediaChanged, isFirstTurn, config) {
1195
+ const userMsg = this.buildUserMessage(userMessage, images, audio);
1196
+ return await this.runStartPathWithMessage(userMsg, mediaChanged, isFirstTurn, config);
1197
+ }
1198
+ /**
1199
+ * Core of {@link runStartPath} that takes a PRE-BUILT pending
1200
+ * `ChatMessage` (user or tool) instead of building a user message
1201
+ * itself. The cold-restart catch in `sendToolResult()` replays the
1202
+ * conversation through this core with a pending `{ role: 'tool', ... }`
1203
+ * message so the tool-result turn is re-rendered against the full
1204
+ * history without duplicating the start-path bookkeeping.
1205
+ */
1206
+ async runStartPathWithMessage(pendingMessage, mediaChanged, isFirstTurn, config) {
1207
+ // Capture pre-state so the restart can be rolled back if the
1208
+ // native call fails. The media-change branch resets caches BEFORE
1209
+ // we know whether the new prefill will succeed, so on failure we
1210
+ // also have to drop turnCount + lastImagesKey/lastAudioKey to force
1211
+ // the next call to re-route through the start path (rather than a
1212
+ // delta continue against wiped caches).
1213
+ const wasMediaChangeRestart = mediaChanged && !isFirstTurn;
1214
+ const historyLenBefore = this.history.length;
1215
+ // Capacity validation must happen before `prepareStartPath()` because a
1216
+ // media-change restart clears native caches. A rejected oversized prompt
1217
+ // is a request error and must leave both JS history and native state intact.
1218
+ const constrainedConfig = await this.constrainToContextCapacity(this.historyWithPending(pendingMessage), config);
1219
+ this.prepareStartPath(mediaChanged, isFirstTurn);
1220
+ this.history.push(pendingMessage);
1221
+ try {
1222
+ // Pass a shallow snapshot so later pushes to `this.history`
1223
+ // (e.g. the assistant reply below) don't retroactively mutate
1224
+ // what the native side / any mock observed as its `messages`
1225
+ // argument.
1226
+ const result = await this.model.chatSessionStart(this.history.slice(), constrainedConfig);
1227
+ this.history.push(buildAssistantMessage(result.text, result.toolCalls));
1228
+ this.turnCount++;
1229
+ this.needsFullReplay = false;
1230
+ // The start path always re-renders the FULL preserved history, so the
1231
+ // post-restart sticky keys are the trailing media keys of that history,
1232
+ // not the single-turn literal args. A restart driven by a change in only
1233
+ // one modality (e.g. an audio-only turn after an earlier image turn)
1234
+ // would otherwise null the untouched modality's key even though that
1235
+ // media is still live in the native cache, causing a later same-media
1236
+ // turn to be mis-detected as a change and replayed twice.
1237
+ this.lastImagesKey = this.computeTrailingImagesKey();
1238
+ this.lastAudioKey = this.computeTrailingAudioKey();
1239
+ this.recordToolCallFanout(result.toolCalls);
1240
+ return result;
1241
+ }
1242
+ catch (err) {
1243
+ // Roll back: drop the tentative user push so history stays
1244
+ // consistent with turnCount.
1245
+ this.history.length = historyLenBefore;
1246
+ if (wasMediaChangeRestart) {
1247
+ // Caches were wiped by prepareStartPath() but the new prefill
1248
+ // failed. Force the next call to re-route through the start
1249
+ // path with the (preserved) prior history.
1250
+ this.turnCount = 0;
1251
+ this.lastImagesKey = null;
1252
+ this.lastAudioKey = null;
1253
+ }
1254
+ throw err;
1255
+ }
1256
+ }
1257
+ /** Streaming counterpart to {@link runStartPath}. */
1258
+ async *runStartStreamPath(userMessage, images, audio, mediaChanged, isFirstTurn, config, signal) {
1259
+ const userMsg = this.buildUserMessage(userMessage, images, audio);
1260
+ yield* this.runStartStreamPathWithMessage(userMsg, mediaChanged, isFirstTurn, config, signal);
1261
+ }
1262
+ /**
1263
+ * Streaming counterpart to {@link runStartPathWithMessage}: replays
1264
+ * through the cold start stream from a PRE-BUILT pending
1265
+ * `ChatMessage`. The cold-restart catch in `sendToolResultStream()`
1266
+ * delegates here with a pending `{ role: 'tool', ... }` message.
1267
+ */
1268
+ async *runStartStreamPathWithMessage(pendingMessage, mediaChanged, isFirstTurn, config, signal) {
1269
+ // Capture pre-state so any non-successful exit can roll back.
1270
+ // See `runStartPath` for the full rationale.
1271
+ const wasMediaChangeRestart = mediaChanged && !isFirstTurn;
1272
+ const historyLenBefore = this.history.length;
1273
+ // See the sync start path: reject before a media restart can clear native
1274
+ // state, and make the output budget explicit before allocating KV blocks.
1275
+ const constrainedConfig = await this.constrainToContextCapacity(this.historyWithPending(pendingMessage), config);
1276
+ this.prepareStartPath(mediaChanged, isFirstTurn);
1277
+ // Stage the pending message on the pending history BEFORE the
1278
+ // stream starts — the native call reads it synchronously via
1279
+ // `model.chatStreamSessionStart(history, config)`.
1280
+ this.history.push(pendingMessage);
1281
+ let sawFinal = false;
1282
+ let accumulated = '';
1283
+ let accumulatedVisible = '';
1284
+ let finalRaw = null;
1285
+ let finalToolCalls;
1286
+ // Snapshot the history before dispatch — see `runStartPath` for
1287
+ // the rationale.
1288
+ const historySnapshot = this.history.slice();
1289
+ try {
1290
+ for await (const event of this.model.chatStreamSessionStart(historySnapshot, constrainedConfig, signal)) {
1291
+ if (event.done) {
1292
+ if (event.finishReason !== 'error') {
1293
+ sawFinal = true;
1294
+ finalRaw = event.text;
1295
+ finalToolCalls = event.toolCalls;
1296
+ }
1297
+ }
1298
+ else {
1299
+ accumulated += event.text;
1300
+ if (event.isReasoning !== true) {
1301
+ accumulatedVisible += event.text;
1302
+ }
1303
+ }
1304
+ yield event;
1305
+ }
1306
+ }
1307
+ finally {
1308
+ // finally runs in ALL termination paths: normal completion,
1309
+ // mid-stream throw, caller `break` (which calls
1310
+ // `iterator.return()` on the generator and short-circuits the
1311
+ // suspended `yield`, skipping any post-loop code), and
1312
+ // error-finish chunks. The unified commit-or-rollback below
1313
+ // makes restart fully transactional regardless of how the
1314
+ // generator was wound down. Mid-stream throws still propagate
1315
+ // naturally — finally runs first, then the error continues up.
1316
+ if (sawFinal) {
1317
+ this.history.push(buildAssistantMessage(finalRaw || accumulatedVisible, finalToolCalls));
1318
+ this.turnCount++;
1319
+ this.needsFullReplay = false;
1320
+ // The start path always re-renders the FULL preserved history, so the
1321
+ // post-restart sticky keys are the trailing media keys of that history,
1322
+ // not the single-turn literal args. A restart driven by a change in only
1323
+ // one modality (e.g. an audio-only turn after an earlier image turn)
1324
+ // would otherwise null the untouched modality's key even though that
1325
+ // media is still live in the native cache, causing a later same-media
1326
+ // turn to be mis-detected as a change and replayed twice.
1327
+ this.lastImagesKey = this.computeTrailingImagesKey();
1328
+ this.lastAudioKey = this.computeTrailingAudioKey();
1329
+ this.recordToolCallFanout(finalToolCalls);
1330
+ }
1331
+ else {
1332
+ // Roll back: drop the tentative user push so history stays
1333
+ // consistent with turnCount.
1334
+ this.history.length = historyLenBefore;
1335
+ if (wasMediaChangeRestart) {
1336
+ // Caches were wiped by prepareStartPath() but the new
1337
+ // prefill never reached a successful done:true. Force the
1338
+ // next call to re-route through the start path with the
1339
+ // preserved prior history.
1340
+ this.turnCount = 0;
1341
+ this.lastImagesKey = null;
1342
+ this.lastAudioKey = null;
1343
+ }
1344
+ }
1345
+ }
1346
+ }
1347
+ /**
1348
+ * Shared pre-start bookkeeping for both `send()` and `sendStream()`:
1349
+ *
1350
+ * - On an image-change restart (turn >= 1), reset the native KV
1351
+ * caches so the new image set gets a fresh prefill. History is
1352
+ * intentionally preserved — `chatSessionStart` receives the full
1353
+ * accumulated conversation plus the new user turn so the jinja
1354
+ * render walks every prior turn and every prior image again
1355
+ * (see plan's Turn 3 example: "full jinja on 3-turn history +
1356
+ * image B"). `lastImagesKey` will be overwritten by the
1357
+ * successful start path right after, and `turnCount` is
1358
+ * incremented by the start path the same way as for any other
1359
+ * turn.
1360
+ * - On a fresh / reset history, re-inject the system prompt.
1361
+ */
1362
+ prepareStartPath(mediaChanged, isFirstTurn) {
1363
+ if (mediaChanged && !isFirstTurn) {
1364
+ this.model.resetCaches();
1365
+ }
1366
+ if (this.history.length === 0 && this.system != null) {
1367
+ this.history.push({ role: 'system', content: this.system });
1368
+ }
1369
+ }
1370
+ /** Build a user `ChatMessage` with or without attached images/audio. */
1371
+ buildUserMessage(userMessage, images, audio) {
1372
+ const msg = { role: 'user', content: userMessage };
1373
+ if (images && images.length > 0)
1374
+ msg.images = images;
1375
+ if (audio && audio.length > 0)
1376
+ msg.audio = audio;
1377
+ return msg;
1378
+ }
1379
+ /**
1380
+ * Walk the history backward to find the most recent user message
1381
+ * with images and return its SHA-256 key. Used by
1382
+ * {@link startFromHistory} and {@link startFromHistoryStream} to
1383
+ * hydrate `lastImagesKey` after a cold replay, so subsequent delta
1384
+ * continues correctly detect image changes.
1385
+ */
1386
+ computeTrailingImagesKey() {
1387
+ for (let i = this.history.length - 1; i >= 0; i--) {
1388
+ const msg = this.history[i];
1389
+ if (msg?.role === 'user' && msg.images && msg.images.length > 0) {
1390
+ return computeImagesKey(msg.images);
1391
+ }
1392
+ }
1393
+ return null;
1394
+ }
1395
+ /**
1396
+ * Audio counterpart of {@link computeTrailingImagesKey}: walk history
1397
+ * backward to the most recent user message carrying audio and return its
1398
+ * SHA-256 key, so a cold replay hydrates `lastAudioKey` correctly.
1399
+ */
1400
+ computeTrailingAudioKey() {
1401
+ for (let i = this.history.length - 1; i >= 0; i--) {
1402
+ const msg = this.history[i];
1403
+ if (msg?.role === 'user' && msg.audio && msg.audio.length > 0) {
1404
+ return computeAudioKey(msg.audio);
1405
+ }
1406
+ }
1407
+ return null;
1408
+ }
1409
+ /**
1410
+ * Derive the post-prime value of `unresolvedOkToolCallCount` from
1411
+ * the primed history. Walks backward to the most recent assistant
1412
+ * turn, then walks forward from that assistant to the end of history
1413
+ * subtracting any `tool:` message that references one of the turn's
1414
+ * `call_id`s. A fully-resolved history (every outstanding id matched
1415
+ * by a sibling `tool:` message) returns `null`; any leftover count is
1416
+ * the number of still-unresolved tool calls.
1417
+ *
1418
+ * Matches the runtime `recordToolCallFanout` semantics on the hot
1419
+ * path: zero unresolved → `null` (no pending obligation); one →
1420
+ * `1` (servable via `sendToolResult*()` only); two or more → the
1421
+ * count itself (unservable fan-out — must be resolved via cold
1422
+ * replay). The distinction between "ok" vs. other statuses only
1423
+ * exists in the live `ToolCallResult[]` emitted by the native side —
1424
+ * the persisted `ChatMessage.toolCalls` on an assistant message only
1425
+ * carries successfully parsed calls (i.e. what would have been "ok"
1426
+ * in the original live turn), so counting the array length is
1427
+ * equivalent. Tool calls whose `id` is missing or empty can't be
1428
+ * matched against subsequent `tool_call_id`s, so in that case we
1429
+ * fall back to returning the raw `calls.length` (err safe).
1430
+ */
1431
+ computeTrailingAssistantUnresolvedToolCallCount() {
1432
+ let assistantIdx = -1;
1433
+ for (let i = this.history.length - 1; i >= 0; i--) {
1434
+ if (this.history[i]?.role === 'assistant') {
1435
+ assistantIdx = i;
1436
+ break;
1437
+ }
1438
+ }
1439
+ if (assistantIdx === -1)
1440
+ return null;
1441
+ const assistant = this.history[assistantIdx];
1442
+ const calls = assistant.toolCalls ?? [];
1443
+ if (calls.length === 0)
1444
+ return null;
1445
+ const outstanding = new Set();
1446
+ let missingIdCount = 0;
1447
+ for (const tc of calls) {
1448
+ if (typeof tc.id === 'string' && tc.id.length > 0) {
1449
+ outstanding.add(tc.id);
1450
+ }
1451
+ else {
1452
+ missingIdCount++;
1453
+ }
1454
+ }
1455
+ // Untracked calls (no id) can't be matched against resolutions —
1456
+ // err safe by reporting the raw count.
1457
+ if (missingIdCount > 0)
1458
+ return calls.length;
1459
+ for (let j = assistantIdx + 1; j < this.history.length; j++) {
1460
+ const msg = this.history[j];
1461
+ if (msg?.role === 'tool' && typeof msg.toolCallId === 'string' && msg.toolCallId.length > 0) {
1462
+ outstanding.delete(msg.toolCallId);
1463
+ }
1464
+ }
1465
+ return outstanding.size > 0 ? outstanding.size : null;
1466
+ }
1467
+ }