@mlx-node/lm 0.0.7 → 0.0.9

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.
@@ -1,3 +1,146 @@
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 and passes the complete structured transcript
15
+ * on every turn. Native code renders that transcript with the
16
+ * checkpoint-provided chat template, verifies the completed history
17
+ * against the committed cache, and appends the template-authored suffix
18
+ * to the exact cached token IDs. Incompatible or edited history cold
19
+ * replays the complete render.
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 still gets incremental prefill
28
+ * on a token-prefix hit. Prompt structure is never reconstructed
29
+ * from Rust string literals.
30
+ *
31
+ * - `sendToolResult` always dispatches `chatSessionContinueTool`,
32
+ * since tool turns never change image state. The session enforces
33
+ * a strict unresolved-ok-tool-call contract at runtime, driven by
34
+ * `unresolvedOkToolCallCount` (derived from `ChatResult.toolCalls`
35
+ * after each turn via `countOkToolCalls` /
36
+ * `computeTrailingAssistantUnresolvedToolCallCount`):
37
+ *
38
+ * * `null` — the trailing assistant turn has no outstanding ok
39
+ * tool call. Plain `send()` / `sendStream()` are the only
40
+ * valid entry points; `sendToolResult*()` throws because
41
+ * there is nothing for the result to resolve.
42
+ * * `1` — exactly one outstanding ok tool call. Plain `send()` /
43
+ * `sendStream()` throw (they would orphan the call);
44
+ * `sendToolResult*()` is the sole valid forward step and
45
+ * dispatches the tool result through the native session.
46
+ * * `>1` — a multi-tool-call fan-out that the chat-session API
47
+ * cannot progress incrementally (each `sendToolResult*` would
48
+ * re-open the assistant turn and weave new replies between
49
+ * the sibling results). Both `send()` / `sendStream()` and
50
+ * `sendToolResult*()` throw. The only valid recovery is
51
+ * `reset()` or `primeHistory()` + `startFromHistory*()` with
52
+ * a fully-resolved conversation — there is no "advance past
53
+ * the broken turn" path.
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 native media guards use when a history cannot be continued
89
+ * from the held image/audio state. The session layer recognizes this exact
90
+ * prefix and transparently replays the complete structured conversation.
91
+ *
92
+ * MUST stay byte-for-byte identical to the Rust constant
93
+ * `IMAGE_CHANGE_RESTART_PREFIX` in
94
+ * `crates/mlx-core/src/engine/cache.rs` — it is not exported across the
95
+ * NAPI boundary, so the two literals are kept in sync by hand. The
96
+ * native message starts with this prefix and is delivered as the
97
+ * `Error.message`: on the sync path as a rejected promise, and on
98
+ * the streaming path as a thrown error on the generator's first
99
+ * iteration (the native worker-thread sink error is re-thrown by the
100
+ * `packages/lm/src/stream.ts` bridge before any chunk is yielded).
101
+ */
102
+ const IMAGE_CHANGE_RESTART_PREFIX = 'IMAGE_CHANGE_REQUIRES_SESSION_RESTART:';
103
+ /**
104
+ * Default resolved by the native shared chat engine when `maxNewTokens` is
105
+ * absent. Keep this in sync with `extract_chat_params()` in
106
+ * `crates/mlx-core/src/engine/params.rs`.
107
+ */
108
+ const NATIVE_DEFAULT_MAX_NEW_TOKENS = 2048;
109
+ /**
110
+ * Stable, provider-neutral error raised before native inference when a
111
+ * rendered prompt cannot fit in the model's physically available hot KV
112
+ * window. The marker is intentionally the canonical string recognized by
113
+ * pi's overflow recovery, so managed agent sessions compact and retry while
114
+ * stateless HTTP callers receive a clean request error instead of a native
115
+ * `BlockAllocator exhausted` failure.
116
+ */
117
+ export class ContextCapacityError extends Error {
118
+ promptTokens;
119
+ effectiveWindowTokens;
120
+ code = 'context_length_exceeded';
121
+ constructor(promptTokens, effectiveWindowTokens) {
122
+ super(`context_length_exceeded: rendered prompt uses ${promptTokens} tokens, ` +
123
+ `but this model currently has capacity for ${effectiveWindowTokens} tokens`);
124
+ this.promptTokens = promptTokens;
125
+ this.effectiveWindowTokens = effectiveWindowTokens;
126
+ this.name = 'ContextCapacityError';
127
+ }
128
+ }
129
+ /** Recognize both the typed JS preflight and the native hard backstop. */
130
+ export function isContextCapacityError(error) {
131
+ return (error instanceof ContextCapacityError ||
132
+ (error instanceof Error && error.message.startsWith('context_length_exceeded:')));
133
+ }
134
+ /**
135
+ * Whether `err` is the native media-held delta rejection (see
136
+ * {@link IMAGE_CHANGE_RESTART_PREFIX}). The native message begins with
137
+ * the literal prefix and reaches both the sync and streaming bridges
138
+ * unwrapped (NAPI surfaces `Error.from_reason` as `Error.message`
139
+ * verbatim), so a `startsWith` match is exact.
140
+ */
141
+ function isMediaHeldRestartError(err) {
142
+ return err instanceof Error && err.message.startsWith(IMAGE_CHANGE_RESTART_PREFIX);
143
+ }
1
144
  /**
2
145
  * Convert the parsed `ToolCallResult[]` emitted by the native chat
3
146
  * pipeline into the `ToolCall[]` shape expected by
@@ -45,23 +188,40 @@ function toAssistantToolCalls(toolCalls) {
45
188
  }
46
189
  /**
47
190
  * 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.
191
+ * decoded text, exact raw text, tool-call list, reasoning body, and resolved
192
+ * thinking mode. LFM2 replays the exact raw content because its checkpoint
193
+ * template does not consume structured reasoning; Qwen/Gemma retain their
194
+ * structured fields. The assistant entry is appended to `this.history` after every
195
+ * successful turn and is later read back by the native
196
+ * `chatSessionStart` cold-replay path (image-change mid-session
197
+ * restart, `startFromHistory*`, server-side `SessionRegistry`
198
+ * cache-miss rebuild). Dropping `toolCalls`, `reasoningContent`, or
199
+ * `thinkingEnabled` changes the rendered assistant bytes on replay:
200
+ * tool responses lose their declaring call, reasoning disappears, or
201
+ * an empty disabled-thinking channel is reinterpreted under the
202
+ * current turn's mode.
58
203
  */
59
- function buildAssistantMessage(text, toolCalls) {
204
+ function buildAssistantMessage(text, toolCalls, thinking, thinkingEnabled, rawText, replayRawText) {
205
+ if (replayRawText) {
206
+ return {
207
+ role: 'assistant',
208
+ content: rawText ?? text,
209
+ thinkingEnabled,
210
+ };
211
+ }
60
212
  const calls = toAssistantToolCalls(toolCalls);
213
+ const message = {
214
+ role: 'assistant',
215
+ content: text,
216
+ thinkingEnabled,
217
+ };
61
218
  if (calls) {
62
- return { role: 'assistant', content: text, toolCalls: calls };
219
+ message.toolCalls = calls;
63
220
  }
64
- return { role: 'assistant', content: text };
221
+ if (thinking != null) {
222
+ message.reasoningContent = thinking;
223
+ }
224
+ return message;
65
225
  }
66
226
  /**
67
227
  * Count the `ok`-status tool calls in a `ChatResult.toolCalls` /
@@ -83,6 +243,63 @@ function countOkToolCalls(toolCalls) {
83
243
  }
84
244
  return n;
85
245
  }
246
+ /**
247
+ * Select the assistant text committed after a successful stream.
248
+ *
249
+ * Default emitters expose parsed text on the terminal event, so an empty
250
+ * value is authoritative for a tool-only turn and must not fall back to raw
251
+ * streamed tool markup. Gemma's channel-aware emitter instead sends visible
252
+ * text as deltas and an empty terminal value for ordinary no-tool replies, so
253
+ * retain the accumulated visible text in that distinct shape.
254
+ */
255
+ function selectCommittedStreamText(finalText, accumulatedVisible, terminalTextAuthoritative) {
256
+ if (finalText == null)
257
+ return accumulatedVisible;
258
+ if (terminalTextAuthoritative === true)
259
+ return finalText;
260
+ if (terminalTextAuthoritative === false)
261
+ return accumulatedVisible;
262
+ if (finalText !== '')
263
+ return finalText;
264
+ return accumulatedVisible;
265
+ }
266
+ /** Mirror the native default used by `resolve_include_reasoning`. */
267
+ function includesReasoning(config) {
268
+ return config.includeReasoning ?? config.reasoningEffort !== 'none';
269
+ }
270
+ /**
271
+ * Session history must retain reasoning even when the caller hides it. Ask
272
+ * native finalization for the full parsed turn without mutating the committed
273
+ * request config; the public view is redacted again below.
274
+ */
275
+ function withReplayReasoning(config, model) {
276
+ if (includesReasoning(config))
277
+ return config;
278
+ if (model.supportsReplayReasoningCapture?.() !== true)
279
+ return config;
280
+ // A zero-budget "none" turn cannot produce a reasoning body worth
281
+ // replaying. Preserve the caller's native suppression flag for this common
282
+ // short-generation path (for example title generation).
283
+ if (config.reasoningEffort === 'none' && (config.thinkingTokenBudget ?? 0) <= 0) {
284
+ return config;
285
+ }
286
+ return { ...config, includeReasoning: true };
287
+ }
288
+ function publicChatResult(result, config) {
289
+ if (includesReasoning(config))
290
+ return result;
291
+ const safeRaw = result.publicRawText;
292
+ return { ...result, thinking: undefined, rawText: safeRaw ?? result.text };
293
+ }
294
+ function publicStreamEvent(event, config) {
295
+ if (includesReasoning(config))
296
+ return event;
297
+ if (!event.done) {
298
+ return event.isReasoning === true ? null : event;
299
+ }
300
+ const safeRaw = event.publicRawText;
301
+ return { ...event, thinking: null, rawText: safeRaw ?? event.text };
302
+ }
86
303
  /**
87
304
  * Compute a stable hex-encoded identity key for a list of image
88
305
  * byte buffers.
@@ -94,75 +311,58 @@ function countOkToolCalls(toolCalls) {
94
311
  * template.
95
312
  *
96
313
  * 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.
314
+ * decide whether to restart the server-side session, so any
315
+ * collision-resistant digest is sufficient. We use SHA-256 (native
316
+ * `node:crypto`) with a length-prefixed framing so different image
317
+ * counts and different byte lengths cannot collide by accident.
101
318
  *
102
319
  * 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.
320
+ * `send()` can stay synchronous in its routing decision. `node:crypto`
321
+ * is a Node built-in, so this adds no external runtime dependency
322
+ * beyond `@mlx-node/core` and the existing stream bridge.
106
323
  */
107
324
  function computeImagesKey(images) {
108
- if (!images || images.length === 0)
325
+ return computeByteListKey(images);
326
+ }
327
+ /**
328
+ * Audio counterpart of {@link computeImagesKey}: a stable, order-sensitive
329
+ * byte-identity key for a list of encoded audio buffers. Used by `send()` /
330
+ * `sendStream()` to decide whether a new audio set must cold-restart the
331
+ * server-side session. Shares the exact SHA-256 framing as the image key.
332
+ */
333
+ function computeAudioKey(audio) {
334
+ return computeByteListKey(audio);
335
+ }
336
+ /**
337
+ * SHA-256 byte-identity key for a length-framed list of byte buffers.
338
+ * Returns `null` for an empty/absent list so callers can distinguish
339
+ * "no media" from "media changed". Shared by the image and audio keys.
340
+ *
341
+ * Uses `node:crypto`'s native SHA-256 rather than a hand-rolled JS hash
342
+ * loop: hashing large image/audio buffers byte-at-a-time in JS is
343
+ * 25-60x slower than the native digest (measured: 5MB ~105ms JS loop
344
+ * vs ~1.8ms native) and this runs synchronously on the event loop
345
+ * before any `await` in `send()`/`sendStream()`, so the JS loop's cost
346
+ * was a real head-of-line-blocking stall for every other request
347
+ * handled by the same process.
348
+ */
349
+ function computeByteListKey(buffers) {
350
+ if (!buffers || buffers.length === 0)
109
351
  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');
352
+ const hash = createHash('sha256');
353
+ // Frame each buffer with a 4-byte little-endian length prefix (and a
354
+ // leading count prefix) so `[ab, c]` and `[a, bc]` — and different
355
+ // buffer countshash to distinct values.
356
+ const prefix = new Uint8Array(4);
357
+ const prefixView = new DataView(prefix.buffer);
358
+ prefixView.setUint32(0, buffers.length, true);
359
+ hash.update(prefix);
360
+ for (const buf of buffers) {
361
+ prefixView.setUint32(0, buf.byteLength, true);
362
+ hash.update(prefix);
363
+ hash.update(buf);
364
+ }
365
+ return hash.digest('hex');
166
366
  }
167
367
  /**
168
368
  * Cross-model chat session. See module docstring for design notes.
@@ -176,22 +376,32 @@ export class ChatSession {
176
376
  model;
177
377
  system;
178
378
  defaultConfig;
379
+ /** Tool definitions are conversation state for deterministic template replay. */
380
+ activeTools;
179
381
  /**
180
382
  * 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.
383
+ * every successful turn and sent on every role-aware native turn so
384
+ * the model-provided template remains the sole prompt authority.
184
385
  */
185
386
  history = [];
186
387
  /**
187
388
  * Hex-encoded byte-identity key of the image set currently bound
188
- * to the server's KV cache (FNV-1a 64-bit; see `computeImagesKey`).
389
+ * to the server's KV cache (SHA-256; see `computeImagesKey`).
189
390
  * `null` when no images are cached. A `send()` whose new key
190
391
  * differs triggers a full `chatSessionStart` restart.
191
392
  */
192
393
  lastImagesKey = null;
394
+ /**
395
+ * Hex-encoded byte-identity key of the audio set currently bound to the
396
+ * server's KV cache (see {@link computeAudioKey}). `null` when no audio is
397
+ * cached. A `send()` whose new key differs triggers a full
398
+ * `chatSessionStart` restart — the audio counterpart of `lastImagesKey`.
399
+ */
400
+ lastAudioKey = null;
193
401
  turnCount = 0;
194
402
  inFlight = false;
403
+ /** A failed/abandoned native turn must be followed by a full replay. */
404
+ needsFullReplay = false;
195
405
  /**
196
406
  * Count of `ok` tool calls emitted by the prior assistant turn, or
197
407
  * `null` when the prior turn produced none. Gates every continuation
@@ -217,6 +427,7 @@ export class ChatSession {
217
427
  this.model = model;
218
428
  this.system = options.system;
219
429
  this.defaultConfig = options.defaultConfig ?? {};
430
+ this.activeTools = this.defaultConfig.tools;
220
431
  }
221
432
  /**
222
433
  * Number of completed turns. Increments only after a successful
@@ -229,6 +440,55 @@ export class ChatSession {
229
440
  get hasImages() {
230
441
  return this.lastImagesKey !== null;
231
442
  }
443
+ /** Load-time physical context snapshot, when exposed by the model. */
444
+ contextLimits() {
445
+ return this.model.contextLimits?.();
446
+ }
447
+ /** Authoritative image-input capability of the loaded native model. */
448
+ supportsImages() {
449
+ return this.model.supportsImages?.() === true;
450
+ }
451
+ /**
452
+ * Render and validate a complete message list against the model's physical
453
+ * context window without starting inference or mutating session/native cache
454
+ * state.
455
+ *
456
+ * HTTP streaming callers use this before committing SSE headers so an
457
+ * oversized prompt can still receive a protocol-shaped 400 response without
458
+ * delaying those headers until image processing, prefill, or the first
459
+ * generated token. The returned config carries the same output-budget clamp
460
+ * applied by the send entry points; those entry points intentionally repeat
461
+ * the check against their own authoritative history before native dispatch.
462
+ */
463
+ async preflightContextCapacity(messages, config) {
464
+ if (this.inFlight) {
465
+ throw new Error('ChatSession: cannot preflight context capacity while a send() is in flight');
466
+ }
467
+ return await this.constrainToContextCapacity(messages.slice(), this.mergeConfig(config));
468
+ }
469
+ /**
470
+ * Capacity-preflight one pending user/tool message against this session's
471
+ * preserved history without starting inference or mutating cache state.
472
+ *
473
+ * This is the exact counterpart of the delta `send*` paths. It matters for
474
+ * server-side prompt-cache hits where the HTTP request contains only the new
475
+ * message while the leased ChatSession owns the earlier conversation.
476
+ */
477
+ async preflightPendingContextCapacity(pending, config) {
478
+ if (this.inFlight) {
479
+ throw new Error('ChatSession: cannot preflight pending context capacity while a send() is in flight');
480
+ }
481
+ if (pending.role === 'user') {
482
+ this.assertCanSendPlain('sendStream');
483
+ }
484
+ else if (pending.role === 'tool') {
485
+ this.assertCanSendToolResult('sendToolResultStream');
486
+ }
487
+ else {
488
+ throw new Error('ChatSession: pending context capacity preflight requires a user or tool message');
489
+ }
490
+ return await this.constrainToContextCapacity(this.historyWithPending(pending), this.mergeConfig(config));
491
+ }
232
492
  /**
233
493
  * Count of `ok` tool calls from the most recent assistant turn, or
234
494
  * `null` when the trailing turn produced none. Non-null means the
@@ -252,9 +512,10 @@ export class ChatSession {
252
512
  /**
253
513
  * Send a user message and resolve with the assistant reply.
254
514
  *
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.
515
+ * Turn 0 and any turn whose image set changed dispatch through
516
+ * `chatSessionStart`. Later turns pass the same complete structured
517
+ * history through `chatSessionContinue`; native code renders the
518
+ * model template and reuses KV on an exact token-prefix match.
258
519
  */
259
520
  async send(userMessage, opts = {}) {
260
521
  if (this.inFlight) {
@@ -265,27 +526,49 @@ export class ChatSession {
265
526
  try {
266
527
  const mergedConfig = this.mergeConfig(opts.config);
267
528
  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.
529
+ const newAudioKey = computeAudioKey(opts.audio);
530
+ // Only an explicit NEW image/audio set can trigger a forced restart. Omitting
531
+ // `images`/`audio` (key === null) is interpreted as "keep the current
532
+ // media cache state" the server-side cache already holds any prior
533
+ // media context, so a text-only follow-up like "what about the
534
+ // top-right?" can ask native code to verify/reuse the templated history.
274
535
  const imageChanged = newImagesKey !== null && newImagesKey !== this.lastImagesKey;
536
+ const audioChanged = newAudioKey !== null && newAudioKey !== this.lastAudioKey;
275
537
  const isFirstTurn = this.turnCount === 0;
276
- if (isFirstTurn || imageChanged) {
277
- return await this.runStartPath(userMessage, opts.images, newImagesKey, imageChanged, isFirstTurn, mergedConfig);
538
+ const replayRequired = this.needsFullReplay;
539
+ if (isFirstTurn || imageChanged || audioChanged || replayRequired) {
540
+ return await this.runStartPath(userMessage, opts.images, opts.audio, imageChanged || audioChanged || replayRequired, isFirstTurn, mergedConfig);
278
541
  }
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));
542
+ // Role-aware continuation: pass the complete structured transcript.
543
+ // Native code renders it with the checkpoint template and only reuses
544
+ // the live cache when the resulting tokens exactly extend that cache.
545
+ const pendingUser = { role: 'user', content: userMessage };
546
+ const pendingHistory = this.historyWithPending(pendingUser);
547
+ const constrainedConfig = await this.constrainToContextCapacity(pendingHistory, mergedConfig);
548
+ let result;
549
+ try {
550
+ result = await this.model.chatSessionContinue(pendingHistory, withReplayReasoning(constrainedConfig, this.model));
551
+ }
552
+ catch (err) {
553
+ if (!isMediaHeldRestartError(err)) {
554
+ this.needsFullReplay = true;
555
+ throw err;
556
+ }
557
+ // The native session holds media KV (gemma4 after an image/audio
558
+ // turn) and refused the continuation. Transparently replay the full
559
+ // conversation through the cold start path. The earlier media turn
560
+ // already lives in `this.history`, so the start path re-renders it;
561
+ // the trailing-media keys keep `lastImagesKey`/`lastAudioKey`
562
+ // consistent across the replay. The continuation path has NOT pushed
563
+ // `userMessage` yet, so `runStartPath` pushing it adds no duplicate.
564
+ return await this.runStartPath(userMessage, undefined, undefined, true, false, constrainedConfig);
565
+ }
566
+ this.history.push(pendingUser);
567
+ this.history.push(buildAssistantMessage(result.text, result.toolCalls, result.thinking, result.thinkingEnabled, result.rawText, this.model.replaysAssistantRawText?.() === true));
286
568
  this.turnCount++;
569
+ this.commitActiveTools(constrainedConfig);
287
570
  this.recordToolCallFanout(result.toolCalls);
288
- return result;
571
+ return publicChatResult(result, constrainedConfig);
289
572
  }
290
573
  finally {
291
574
  this.inFlight = false;
@@ -311,36 +594,78 @@ export class ChatSession {
311
594
  try {
312
595
  const mergedConfig = this.mergeConfig(opts.config);
313
596
  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.
597
+ const newAudioKey = computeAudioKey(opts.audio);
598
+ // Only an explicit NEW image/audio set can trigger a restart. Omitting
599
+ // `images`/`audio` (key === null) is interpreted as "keep the current
600
+ // media cache state" the server-side cache already holds any prior
601
+ // media context, so a text-only follow-up like "what about the
602
+ // top-right?" can stay on the cheap delta path even after a media turn.
320
603
  const imageChanged = newImagesKey !== null && newImagesKey !== this.lastImagesKey;
604
+ const audioChanged = newAudioKey !== null && newAudioKey !== this.lastAudioKey;
321
605
  const isFirstTurn = this.turnCount === 0;
322
- if (isFirstTurn || imageChanged) {
323
- yield* this.runStartStreamPath(userMessage, opts.images, newImagesKey, imageChanged, isFirstTurn, mergedConfig, opts.signal);
606
+ const replayRequired = this.needsFullReplay;
607
+ if (isFirstTurn || imageChanged || audioChanged || replayRequired) {
608
+ yield* this.runStartStreamPath(userMessage, opts.images, opts.audio, imageChanged || audioChanged || replayRequired, isFirstTurn, mergedConfig, opts.signal);
324
609
  return;
325
610
  }
326
611
  // Delta continue stream: text-only.
612
+ const pendingUser = { role: 'user', content: userMessage };
613
+ const pendingHistory = this.historyWithPending(pendingUser);
614
+ const constrainedConfig = await this.constrainToContextCapacity(pendingHistory, mergedConfig);
327
615
  let sawFinal = false;
328
616
  let accumulated = '';
617
+ let accumulatedVisible = '';
329
618
  let finalRaw = null;
619
+ let finalReplayRaw = null;
620
+ let finalTextAuthoritative;
330
621
  let finalToolCalls;
622
+ let finalThinking = null;
623
+ let finalThinkingEnabled = false;
624
+ // Set when the media-held rejection re-routes this turn through the
625
+ // cold start stream. The replay path owns the history push, turnCount
626
+ // increment, and media-key rehydration, so the commit `finally` below
627
+ // must NOT also fire.
628
+ let delegated = false;
331
629
  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;
630
+ try {
631
+ for await (const event of this.model.chatStreamSessionContinue(pendingHistory, withReplayReasoning(constrainedConfig, this.model), opts.signal)) {
632
+ if (event.done) {
633
+ if (event.finishReason !== 'error') {
634
+ sawFinal = true;
635
+ finalRaw = event.text;
636
+ finalReplayRaw = event.rawText;
637
+ finalTextAuthoritative = event.textAuthoritative;
638
+ finalToolCalls = event.toolCalls;
639
+ finalThinking = event.thinking;
640
+ finalThinkingEnabled = event.thinkingEnabled;
641
+ }
642
+ }
643
+ else {
644
+ accumulated += event.text;
645
+ if (event.isReasoning !== true) {
646
+ accumulatedVisible += event.text;
647
+ }
338
648
  }
649
+ const publicEvent = publicStreamEvent(event, constrainedConfig);
650
+ if (publicEvent !== null)
651
+ yield publicEvent;
339
652
  }
340
- else {
341
- accumulated += event.text;
653
+ }
654
+ catch (err) {
655
+ // The native session holds media KV (gemma4 after an image/audio
656
+ // turn) and refused the text delta. The streaming bridge re-throws
657
+ // that rejection on the first iteration, BEFORE any chunk is
658
+ // emitted — the native guard fires ahead of any prefill, so
659
+ // `!sawFinal && accumulated === ''` is guaranteed here. Replay the
660
+ // full conversation through the cold start stream. Any non-prefix
661
+ // error, or any error after tokens were already emitted, must
662
+ // propagate unchanged.
663
+ if (!isMediaHeldRestartError(err) || sawFinal || accumulated !== '') {
664
+ throw err;
342
665
  }
343
- yield event;
666
+ delegated = true;
667
+ yield* this.runStartStreamPath(userMessage, undefined, undefined, true, false, constrainedConfig, opts.signal);
668
+ return;
344
669
  }
345
670
  }
346
671
  finally {
@@ -350,13 +675,22 @@ export class ChatSession {
350
675
  // chunks alike. The delta path doesn't push to history until
351
676
  // commit, so the rollback branch is a no-op: nothing to
352
677
  // 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));
678
+ // save_cache_state path on its own. When the media-held
679
+ // rejection delegated to the replay stream, that path already
680
+ // committed (or rolled back) so this commit must stay off.
681
+ if (sawFinal && !delegated) {
682
+ this.history.push(pendingUser);
683
+ this.history.push(buildAssistantMessage(selectCommittedStreamText(finalRaw, accumulatedVisible, finalTextAuthoritative), finalToolCalls, finalThinking, finalThinkingEnabled, finalReplayRaw, this.model.replaysAssistantRawText?.() === true));
357
684
  this.turnCount++;
685
+ this.commitActiveTools(constrainedConfig);
358
686
  this.recordToolCallFanout(finalToolCalls);
359
687
  }
688
+ else if (!delegated) {
689
+ // Qwen commits cancelled/failed delta tokens to its native cached
690
+ // history even though this JS turn is intentionally uncommitted.
691
+ // The next plain turn must reset and replay the preserved history.
692
+ this.needsFullReplay = true;
693
+ }
360
694
  }
361
695
  }
362
696
  finally {
@@ -364,9 +698,9 @@ export class ChatSession {
364
698
  }
365
699
  }
366
700
  /**
367
- * Send a tool-result turn. Always dispatches
368
- * `chatSessionContinueTool` tool turns never change image state,
369
- * so there is no restart path here.
701
+ * Send a tool-result turn. The declaring assistant tool call and the
702
+ * pending result are both included in the full history passed to
703
+ * `chatSessionContinueTool`.
370
704
  *
371
705
  * Rejects if the prior assistant turn emitted more than one `ok`
372
706
  * tool call: the chat-session API only supports exactly one tool
@@ -377,6 +711,18 @@ export class ChatSession {
377
711
  * hit this must tighten the prompt / tool spec or reset the
378
712
  * session.
379
713
  *
714
+ * `isError` is the structured tool-error signal. When `true`, the
715
+ * native renderer prepends a short, model-facing error marker to
716
+ * `content` inside the wire-format tool block so the model
717
+ * receives a clear text-level cue that the tool result represents
718
+ * a failure. The structured field is stored verbatim on the
719
+ * appended `{ role: 'tool', ... }` history entry so cold-replay
720
+ * (image-change restart, `startFromHistory*`, server-side
721
+ * `SessionRegistry` cache-miss rebuild) re-renders the marker
722
+ * consistently with the live turn. Defaults to `undefined` (no
723
+ * marker). Pass through verbatim — we do NOT infer error from
724
+ * `content`.
725
+ *
380
726
  * Appends a `{ role: 'tool', ... }` message to history on success.
381
727
  */
382
728
  async sendToolResult(toolCallId, content, opts = {}) {
@@ -386,19 +732,84 @@ export class ChatSession {
386
732
  this.assertCanSendToolResult('sendToolResult');
387
733
  this.inFlight = true;
388
734
  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;
735
+ const { isError, config } = opts;
736
+ const mergedConfig = this.mergeConfig(config);
737
+ const toolMsg = {
738
+ role: 'tool',
739
+ content,
740
+ toolCallId,
741
+ isError,
742
+ };
743
+ const pendingHistory = this.historyWithPending(toolMsg);
744
+ const constrainedConfig = await this.constrainToContextCapacity(pendingHistory, mergedConfig);
745
+ // A cold native session (turnCount===0) has no live KV to delta
746
+ // against — the typical cause is an interrupted media-held replay
747
+ // whose rollback wiped the cache and reset the counter while
748
+ // leaving the unresolved tool-call flag set. Mirror `send()`'s
749
+ // turn-0 routing: replay the preserved history through the cold
750
+ // start path instead of dispatching a delta that the native side
751
+ // would reject with an un-prefixed "requires an initialized
752
+ // session" error. A normal tool result always follows a prior
753
+ // tool-call turn (turnCount>=1), so this never fires on the happy
754
+ // path.
755
+ if (this.turnCount === 0 || this.needsFullReplay) {
756
+ return await this.replayToolResultThroughStartPath(toolMsg, constrainedConfig);
757
+ }
758
+ try {
759
+ const result = await this.model.chatSessionContinueTool(pendingHistory, withReplayReasoning(constrainedConfig, this.model));
760
+ this.history.push({ role: 'tool', content, toolCallId, isError });
761
+ this.history.push(buildAssistantMessage(result.text, result.toolCalls, result.thinking, result.thinkingEnabled, result.rawText, this.model.replaysAssistantRawText?.() === true));
762
+ this.turnCount++;
763
+ this.commitActiveTools(constrainedConfig);
764
+ this.recordToolCallFanout(result.toolCalls);
765
+ return publicChatResult(result, constrainedConfig);
766
+ }
767
+ catch (err) {
768
+ if (!isMediaHeldRestartError(err)) {
769
+ this.needsFullReplay = true;
770
+ throw err;
771
+ }
772
+ // The native session holds media KV (gemma4 after an image/audio
773
+ // turn) and refused the tool-result delta. Transparently replay
774
+ // the full conversation through the cold start path. The prior
775
+ // media turn already lives in `this.history`, so the start path
776
+ // re-renders it; the trailing-media keys keep
777
+ // `lastImagesKey`/`lastAudioKey` consistent across the replay.
778
+ // The delta path threw before pushing the tool message, so the
779
+ // restart core pushes it — `isError` rides on that message so the
780
+ // wire-format error marker is re-rendered, and a tool result
781
+ // always follows >=1 prior turn so `isFirstTurn` is false.
782
+ return await this.replayToolResultThroughStartPath(toolMsg, constrainedConfig);
783
+ }
396
784
  }
397
785
  finally {
398
786
  this.inFlight = false;
399
787
  }
400
788
  }
401
- /** Streaming variant of {@link ChatSession#sendToolResult}. */
789
+ /**
790
+ * Cold-replay a tool result through the start path: re-render the
791
+ * full preserved history (including the prior media turn and the
792
+ * unresolved tool-call assistant turn) plus this tool message. Used
793
+ * when the native session is cold (turnCount===0 — e.g. after an
794
+ * interrupted media-held replay rolled the cache back) and by the
795
+ * media-held rejection catch. `mediaChanged=true` forces a
796
+ * resetCaches so the prefill always starts from a guaranteed-clean
797
+ * cache; `isFirstTurn=false` because a tool result always follows a
798
+ * prior tool-call turn.
799
+ */
800
+ async replayToolResultThroughStartPath(toolMsg, config) {
801
+ return await this.runStartPathWithMessage(toolMsg, true, false, config);
802
+ }
803
+ /**
804
+ * Streaming variant of {@link ChatSession#sendToolResult}.
805
+ *
806
+ * `isError` mirrors the non-streaming entry point — when `true`,
807
+ * the native renderer prepends a short, model-facing error marker
808
+ * to `content` inside the wire-format tool block. The structured
809
+ * field is stored verbatim on the appended `{ role: 'tool', ... }`
810
+ * history entry so cold-replay re-renders the marker consistently
811
+ * with the live streaming turn.
812
+ */
402
813
  async *sendToolResultStream(toolCallId, content, opts = {}) {
403
814
  if (this.inFlight) {
404
815
  throw new Error('ChatSession: concurrent send() not allowed; await the previous call first');
@@ -406,43 +817,124 @@ export class ChatSession {
406
817
  this.assertCanSendToolResult('sendToolResultStream');
407
818
  this.inFlight = true;
408
819
  try {
409
- const mergedConfig = this.mergeConfig(opts.config);
820
+ const { isError, config, signal } = opts;
821
+ const mergedConfig = this.mergeConfig(config);
822
+ const toolMsg = {
823
+ role: 'tool',
824
+ content,
825
+ toolCallId,
826
+ isError,
827
+ };
828
+ const pendingHistory = this.historyWithPending(toolMsg);
829
+ const constrainedConfig = await this.constrainToContextCapacity(pendingHistory, mergedConfig);
830
+ // A cold native session (turnCount===0) has no live KV to delta
831
+ // against — typically the residue of an interrupted media-held
832
+ // replay whose rollback wiped the cache and reset the counter
833
+ // while leaving the unresolved tool-call flag set. Mirror
834
+ // `sendStream()`'s turn-0 routing: replay the preserved history
835
+ // through the cold start stream and return before the
836
+ // delta/commit machinery so the start path owns the history push,
837
+ // turnCount increment, and media-key rehydration. A normal tool
838
+ // result always follows a prior tool-call turn (turnCount>=1), so
839
+ // this never fires on the happy path.
840
+ if (this.turnCount === 0 || this.needsFullReplay) {
841
+ yield* this.replayToolResultThroughStartStreamPath(toolMsg, constrainedConfig, signal);
842
+ return;
843
+ }
410
844
  let sawFinal = false;
411
845
  let accumulated = '';
846
+ let accumulatedVisible = '';
412
847
  let finalRaw = null;
848
+ let finalReplayRaw = null;
849
+ let finalTextAuthoritative;
413
850
  let finalToolCalls;
851
+ let finalThinking = null;
852
+ let finalThinkingEnabled = false;
853
+ // Set when the media-held rejection re-routes this tool turn
854
+ // through the cold start stream. The replay path owns the history
855
+ // push, turnCount increment, and media-key rehydration, so the
856
+ // commit `finally` below must NOT also fire.
857
+ let delegated = false;
414
858
  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;
859
+ try {
860
+ for await (const event of this.model.chatStreamSessionContinueTool(pendingHistory, withReplayReasoning(constrainedConfig, this.model), signal)) {
861
+ if (event.done) {
862
+ if (event.finishReason !== 'error') {
863
+ sawFinal = true;
864
+ finalRaw = event.text;
865
+ finalReplayRaw = event.rawText;
866
+ finalTextAuthoritative = event.textAuthoritative;
867
+ finalToolCalls = event.toolCalls;
868
+ finalThinking = event.thinking;
869
+ finalThinkingEnabled = event.thinkingEnabled;
870
+ }
421
871
  }
872
+ else {
873
+ accumulated += event.text;
874
+ if (event.isReasoning !== true) {
875
+ accumulatedVisible += event.text;
876
+ }
877
+ }
878
+ const publicEvent = publicStreamEvent(event, constrainedConfig);
879
+ if (publicEvent !== null)
880
+ yield publicEvent;
422
881
  }
423
- else {
424
- accumulated += event.text;
882
+ }
883
+ catch (err) {
884
+ // The native session holds media KV (gemma4 after an image/audio
885
+ // turn) and refused the tool-result delta. The streaming bridge
886
+ // re-throws that rejection on the first iteration, BEFORE any
887
+ // chunk is emitted — the native guard fires ahead of any
888
+ // prefill, so `!sawFinal && accumulated === ''` is guaranteed
889
+ // here. Replay the full conversation through the cold start
890
+ // stream with the pending tool message; `isError` rides on it so
891
+ // the wire-format error marker is re-rendered. Any non-prefix
892
+ // error, or any error after tokens were already emitted, must
893
+ // propagate unchanged.
894
+ if (!isMediaHeldRestartError(err) || sawFinal || accumulated !== '') {
895
+ throw err;
425
896
  }
426
- yield event;
897
+ delegated = true;
898
+ yield* this.replayToolResultThroughStartStreamPath(toolMsg, constrainedConfig, signal);
899
+ return;
427
900
  }
428
901
  }
429
902
  finally {
430
903
  // finally runs for normal completion, mid-stream throw,
431
904
  // caller `break` (iterator.return() short-circuits the yield),
432
905
  // 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));
906
+ // history until commit, so the rollback branch is a no-op. When
907
+ // the media-held rejection delegated to the replay stream, that
908
+ // path already committed (or rolled back), so this commit stays
909
+ // off.
910
+ if (sawFinal && !delegated) {
911
+ this.history.push({ role: 'tool', content, toolCallId, isError });
912
+ this.history.push(buildAssistantMessage(selectCommittedStreamText(finalRaw, accumulatedVisible, finalTextAuthoritative), finalToolCalls, finalThinking, finalThinkingEnabled, finalReplayRaw, this.model.replaysAssistantRawText?.() === true));
437
913
  this.turnCount++;
914
+ this.commitActiveTools(constrainedConfig);
438
915
  this.recordToolCallFanout(finalToolCalls);
439
916
  }
917
+ else if (!delegated) {
918
+ this.needsFullReplay = true;
919
+ }
440
920
  }
441
921
  }
442
922
  finally {
443
923
  this.inFlight = false;
444
924
  }
445
925
  }
926
+ /**
927
+ * Streaming counterpart of {@link replayToolResultThroughStartPath}:
928
+ * cold-replay a tool result through the start stream. Used by the
929
+ * turn-0 precheck and the media-held rejection catch in
930
+ * {@link sendToolResultStream}. The start stream owns the history
931
+ * push, turnCount increment, and media-key rehydration; callers keep
932
+ * `delegated`/early-return semantics so the commit `finally` stays
933
+ * off.
934
+ */
935
+ async *replayToolResultThroughStartStreamPath(toolMsg, config, signal) {
936
+ yield* this.runStartStreamPathWithMessage(toolMsg, true, false, config, signal);
937
+ }
446
938
  /**
447
939
  * Reset the session state.
448
940
  *
@@ -450,6 +942,23 @@ export class ChatSession {
450
942
  * image key, and turn counter so the next `send()` goes through
451
943
  * `chatSessionStart` again.
452
944
  *
945
+ * This is a full wipe — safe default for public callers. It always
946
+ * calls `model.resetCaches()`, which is the ONLY behavior exposed
947
+ * on the public API because the underlying `SessionCapableModel`
948
+ * is shared across every `ChatSession` lifetime via the native
949
+ * `ModelRegistry`: a partial wipe that leaves the shared native
950
+ * KV cache intact would leak a previous (unrelated) request's
951
+ * cached prefix into the next `chat_session_start_sync` call. The
952
+ * server-side warm-lease replay path (where preserving the native
953
+ * cache is correct) uses its own server-private helper gated by
954
+ * the `SessionRegistry` HIT signal — the only authoritative proof
955
+ * that the native cache genuinely belongs to this chain. That
956
+ * helper lives inside `@mlx-node/server`, never touches the
957
+ * `@mlx-node/lm` export map, and is not reachable from downstream
958
+ * consumers. Public consumers of `@mlx-node/lm` have no such HIT
959
+ * signal, so the public API intentionally offers only the full-wipe
960
+ * option.
961
+ *
453
962
  * Returns `Promise<void>` for an async-friendly signature even
454
963
  * though `resetCaches()` is currently synchronous.
455
964
  */
@@ -460,8 +969,11 @@ export class ChatSession {
460
969
  this.model.resetCaches();
461
970
  this.history = [];
462
971
  this.lastImagesKey = null;
972
+ this.lastAudioKey = null;
463
973
  this.turnCount = 0;
464
974
  this.unresolvedOkToolCallCount = null;
975
+ this.needsFullReplay = false;
976
+ this.activeTools = this.defaultConfig.tools;
465
977
  }
466
978
  /**
467
979
  * Prime the session history without running inference.
@@ -527,12 +1039,17 @@ export class ChatSession {
527
1039
  this.inFlight = true;
528
1040
  try {
529
1041
  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));
1042
+ const historySnapshot = this.history.slice();
1043
+ const constrainedConfig = await this.constrainToContextCapacity(historySnapshot, mergedConfig);
1044
+ const result = await this.model.chatSessionStart(historySnapshot, withReplayReasoning(constrainedConfig, this.model));
1045
+ this.history.push(buildAssistantMessage(result.text, result.toolCalls, result.thinking, result.thinkingEnabled, result.rawText, this.model.replaysAssistantRawText?.() === true));
532
1046
  this.turnCount++;
1047
+ this.needsFullReplay = false;
533
1048
  this.lastImagesKey = this.computeTrailingImagesKey();
1049
+ this.lastAudioKey = this.computeTrailingAudioKey();
1050
+ this.commitActiveTools(constrainedConfig);
534
1051
  this.recordToolCallFanout(result.toolCalls);
535
- return result;
1052
+ return publicChatResult(result, constrainedConfig);
536
1053
  }
537
1054
  finally {
538
1055
  this.inFlight = false;
@@ -562,23 +1079,38 @@ export class ChatSession {
562
1079
  try {
563
1080
  const mergedConfig = this.mergeConfig(config);
564
1081
  const historySnapshot = this.history.slice();
1082
+ const constrainedConfig = await this.constrainToContextCapacity(historySnapshot, mergedConfig);
565
1083
  let sawFinal = false;
566
1084
  let accumulated = '';
1085
+ let accumulatedVisible = '';
567
1086
  let finalRaw = null;
1087
+ let finalReplayRaw = null;
1088
+ let finalTextAuthoritative;
568
1089
  let finalToolCalls;
1090
+ let finalThinking = null;
1091
+ let finalThinkingEnabled = false;
569
1092
  try {
570
- for await (const event of this.model.chatStreamSessionStart(historySnapshot, mergedConfig, signal)) {
1093
+ for await (const event of this.model.chatStreamSessionStart(historySnapshot, withReplayReasoning(constrainedConfig, this.model), signal)) {
571
1094
  if (event.done) {
572
1095
  if (event.finishReason !== 'error') {
573
1096
  sawFinal = true;
574
1097
  finalRaw = event.text;
1098
+ finalReplayRaw = event.rawText;
1099
+ finalTextAuthoritative = event.textAuthoritative;
575
1100
  finalToolCalls = event.toolCalls;
1101
+ finalThinking = event.thinking;
1102
+ finalThinkingEnabled = event.thinkingEnabled;
576
1103
  }
577
1104
  }
578
1105
  else {
579
1106
  accumulated += event.text;
1107
+ if (event.isReasoning !== true) {
1108
+ accumulatedVisible += event.text;
1109
+ }
580
1110
  }
581
- yield event;
1111
+ const publicEvent = publicStreamEvent(event, constrainedConfig);
1112
+ if (publicEvent !== null)
1113
+ yield publicEvent;
582
1114
  }
583
1115
  }
584
1116
  finally {
@@ -588,9 +1120,12 @@ export class ChatSession {
588
1120
  // mutated on a successful commit — on any non-success exit,
589
1121
  // the primed state is left intact so the caller can retry.
590
1122
  if (sawFinal) {
591
- this.history.push(buildAssistantMessage(finalRaw ?? accumulated, finalToolCalls));
1123
+ this.history.push(buildAssistantMessage(selectCommittedStreamText(finalRaw, accumulatedVisible, finalTextAuthoritative), finalToolCalls, finalThinking, finalThinkingEnabled, finalReplayRaw, this.model.replaysAssistantRawText?.() === true));
592
1124
  this.turnCount++;
1125
+ this.needsFullReplay = false;
593
1126
  this.lastImagesKey = this.computeTrailingImagesKey();
1127
+ this.lastAudioKey = this.computeTrailingAudioKey();
1128
+ this.commitActiveTools(constrainedConfig);
594
1129
  this.recordToolCallFanout(finalToolCalls);
595
1130
  }
596
1131
  }
@@ -691,18 +1226,108 @@ export class ChatSession {
691
1226
  const n = countOkToolCalls(toolCalls);
692
1227
  this.unresolvedOkToolCallCount = n > 0 ? n : null;
693
1228
  }
1229
+ /**
1230
+ * Persist the effective tools only when their turn commits successfully.
1231
+ * Preflights and failed/abandoned turns intentionally never call this.
1232
+ */
1233
+ commitActiveTools(config) {
1234
+ if (config.tools !== undefined) {
1235
+ this.activeTools = config.tools;
1236
+ }
1237
+ }
694
1238
  /**
695
1239
  * Merge default + per-call config and force `reuseCache: true`.
696
1240
  * The session path is a session-reuse operation by construction —
697
1241
  * `reuseCache: false` on the continue path would wipe the very
698
1242
  * cache the delta depends on.
1243
+ *
1244
+ * Tool resolution here is side-effect free because public capacity
1245
+ * preflights use this same merge path. Successful turn commit sites call
1246
+ * {@link commitActiveTools} after native inference finishes.
1247
+ *
1248
+ * MTP auto-default: if neither `defaultConfig` nor `overlay`
1249
+ * sets `enableMtp` AND the underlying model exposes
1250
+ * `hasMtpWeights()` returning `true`, set `enableMtp = true` so the
1251
+ * speculative-decode path runs out of the box on MTP-capable
1252
+ * checkpoints. An explicit `false` from either source wins (the
1253
+ * undefined-check below preserves it). This duck-typed check also
1254
+ * covers Gemma4 with an external draft attached — DSpark or Google
1255
+ * assistant (`hasMtpWeights()` reports the external draft there,
1256
+ * not in-checkpoint MTP heads).
699
1257
  */
700
1258
  mergeConfig(overlay) {
701
- return {
1259
+ const merged = {
702
1260
  ...this.defaultConfig,
703
1261
  ...overlay,
704
1262
  reuseCache: true,
705
1263
  };
1264
+ // Tools are part of the committed conversation state. Constructor
1265
+ // defaults seed that state, but must not overwrite a tool set committed by
1266
+ // a later successful turn. A current-call overlay is the only higher
1267
+ // precedence source; it remains provisional until a success site calls
1268
+ // commitActiveTools().
1269
+ if (overlay?.tools === undefined && this.activeTools !== undefined) {
1270
+ merged.tools = this.activeTools;
1271
+ }
1272
+ if (merged.enableMtp === undefined &&
1273
+ typeof this.model.hasMtpWeights === 'function' &&
1274
+ this.model.hasMtpWeights()) {
1275
+ merged.enableMtp = true;
1276
+ }
1277
+ return merged;
1278
+ }
1279
+ /**
1280
+ * Render the exact full prompt and constrain generation to the physical KV
1281
+ * window before native code allocates a block. Models that do not expose
1282
+ * both the tokenizer seam and a load-time context snapshot retain their
1283
+ * existing behavior.
1284
+ *
1285
+ * The returned config is a copy only when `maxNewTokens` needs clamping.
1286
+ * An omitted output budget stays omitted when the native default fits; it is
1287
+ * made explicit only when the remaining window is smaller than that default.
1288
+ */
1289
+ async constrainToContextCapacity(messages, config) {
1290
+ if (typeof this.model.applyChatTemplate !== 'function' || typeof this.model.contextLimits !== 'function') {
1291
+ return config;
1292
+ }
1293
+ const limits = this.model.contextLimits();
1294
+ const capacity = Math.floor(limits.effectiveWindowTokens);
1295
+ if (!Number.isSafeInteger(capacity) || capacity <= 0) {
1296
+ return config;
1297
+ }
1298
+ const effort = config.reasoningEffort;
1299
+ const enableThinking = effort === 'none' || effort === 'low' ? false : effort === 'medium' || effort === 'high' ? true : null;
1300
+ const tokens = await this.model.applyChatTemplate(messages, true, config.tools ?? null, enableThinking);
1301
+ const hasImages = messages.some((message) => (message.images?.length ?? 0) > 0);
1302
+ let promptTokens = tokens.length;
1303
+ if (hasImages && typeof this.model.expandedPromptTokenCount === 'function') {
1304
+ promptTokens = await this.model.expandedPromptTokenCount(tokens, messages);
1305
+ if (!Number.isSafeInteger(promptTokens) || promptTokens < tokens.length) {
1306
+ throw new Error(`ChatSession: expandedPromptTokenCount returned invalid length ${promptTokens} ` +
1307
+ `(rendered template length is ${tokens.length})`);
1308
+ }
1309
+ }
1310
+ if (promptTokens > capacity) {
1311
+ throw new ContextCapacityError(promptTokens, capacity);
1312
+ }
1313
+ // The final sampled token is returned without another model forward, so N
1314
+ // generated tokens consume only N-1 additional KV positions.
1315
+ const maxOutput = capacity - promptTokens + 1;
1316
+ const requested = config.maxNewTokens;
1317
+ if (requested === undefined) {
1318
+ return maxOutput >= NATIVE_DEFAULT_MAX_NEW_TOKENS ? config : { ...config, maxNewTokens: maxOutput };
1319
+ }
1320
+ const maxNewTokens = Math.min(requested, maxOutput);
1321
+ return maxNewTokens === requested ? config : { ...config, maxNewTokens };
1322
+ }
1323
+ /** Full history that a pending user/tool turn would render, without mutation. */
1324
+ historyWithPending(pending) {
1325
+ const messages = this.history.slice();
1326
+ if (messages.length === 0 && this.system != null) {
1327
+ messages.push({ role: 'system', content: this.system });
1328
+ }
1329
+ messages.push(pending);
1330
+ return messages;
706
1331
  }
707
1332
  /**
708
1333
  * Shared start-path logic for `send()`. Handles both the turn-0
@@ -711,76 +1336,128 @@ export class ChatSession {
711
1336
  * native side gets the full conversation re-rendered with the new
712
1337
  * image set.
713
1338
  */
714
- async runStartPath(userMessage, images, newImagesKey, imageChanged, isFirstTurn, config) {
1339
+ async runStartPath(userMessage, images, audio, mediaChanged, isFirstTurn, config) {
1340
+ const userMsg = this.buildUserMessage(userMessage, images, audio);
1341
+ return await this.runStartPathWithMessage(userMsg, mediaChanged, isFirstTurn, config);
1342
+ }
1343
+ /**
1344
+ * Core of {@link runStartPath} that takes a PRE-BUILT pending
1345
+ * `ChatMessage` (user or tool) instead of building a user message
1346
+ * itself. The cold-restart catch in `sendToolResult()` replays the
1347
+ * conversation through this core with a pending `{ role: 'tool', ... }`
1348
+ * message so the tool-result turn is re-rendered against the full
1349
+ * history without duplicating the start-path bookkeeping.
1350
+ */
1351
+ async runStartPathWithMessage(pendingMessage, mediaChanged, isFirstTurn, config) {
715
1352
  // Capture pre-state so the restart can be rolled back if the
716
- // native call fails. The image-change branch resets caches BEFORE
1353
+ // native call fails. The media-change branch resets caches BEFORE
717
1354
  // 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;
1355
+ // also have to drop turnCount + lastImagesKey/lastAudioKey to force
1356
+ // the next call to re-route through the start path (rather than a
1357
+ // delta continue against wiped caches).
1358
+ const wasMediaChangeRestart = mediaChanged && !isFirstTurn;
722
1359
  const historyLenBefore = this.history.length;
723
- this.prepareStartPath(imageChanged, isFirstTurn);
724
- const userMsg = this.buildUserMessage(userMessage, images);
725
- this.history.push(userMsg);
1360
+ // Capacity validation must happen before `prepareStartPath()` because a
1361
+ // media-change restart clears native caches. A rejected oversized prompt
1362
+ // is a request error and must leave both JS history and native state intact.
1363
+ const constrainedConfig = await this.constrainToContextCapacity(this.historyWithPending(pendingMessage), config);
1364
+ this.prepareStartPath(mediaChanged, isFirstTurn);
1365
+ this.history.push(pendingMessage);
726
1366
  try {
727
1367
  // Pass a shallow snapshot so later pushes to `this.history`
728
1368
  // (e.g. the assistant reply below) don't retroactively mutate
729
1369
  // what the native side / any mock observed as its `messages`
730
1370
  // argument.
731
- const result = await this.model.chatSessionStart(this.history.slice(), config);
732
- this.history.push(buildAssistantMessage(result.text, result.toolCalls));
1371
+ const result = await this.model.chatSessionStart(this.history.slice(), withReplayReasoning(constrainedConfig, this.model));
1372
+ this.history.push(buildAssistantMessage(result.text, result.toolCalls, result.thinking, result.thinkingEnabled, result.rawText, this.model.replaysAssistantRawText?.() === true));
733
1373
  this.turnCount++;
734
- this.lastImagesKey = newImagesKey;
1374
+ this.needsFullReplay = false;
1375
+ // The start path always re-renders the FULL preserved history, so the
1376
+ // post-restart sticky keys are the trailing media keys of that history,
1377
+ // not the single-turn literal args. A restart driven by a change in only
1378
+ // one modality (e.g. an audio-only turn after an earlier image turn)
1379
+ // would otherwise null the untouched modality's key even though that
1380
+ // media is still live in the native cache, causing a later same-media
1381
+ // turn to be mis-detected as a change and replayed twice.
1382
+ this.lastImagesKey = this.computeTrailingImagesKey();
1383
+ this.lastAudioKey = this.computeTrailingAudioKey();
1384
+ this.commitActiveTools(constrainedConfig);
735
1385
  this.recordToolCallFanout(result.toolCalls);
736
- return result;
1386
+ return publicChatResult(result, constrainedConfig);
737
1387
  }
738
1388
  catch (err) {
739
1389
  // Roll back: drop the tentative user push so history stays
740
1390
  // consistent with turnCount.
741
1391
  this.history.length = historyLenBefore;
742
- if (wasImageChangeRestart) {
1392
+ if (wasMediaChangeRestart) {
743
1393
  // Caches were wiped by prepareStartPath() but the new prefill
744
1394
  // failed. Force the next call to re-route through the start
745
1395
  // path with the (preserved) prior history.
746
1396
  this.turnCount = 0;
747
1397
  this.lastImagesKey = null;
1398
+ this.lastAudioKey = null;
748
1399
  }
749
1400
  throw err;
750
1401
  }
751
1402
  }
752
1403
  /** Streaming counterpart to {@link runStartPath}. */
753
- async *runStartStreamPath(userMessage, images, newImagesKey, imageChanged, isFirstTurn, config, signal) {
1404
+ async *runStartStreamPath(userMessage, images, audio, mediaChanged, isFirstTurn, config, signal) {
1405
+ const userMsg = this.buildUserMessage(userMessage, images, audio);
1406
+ yield* this.runStartStreamPathWithMessage(userMsg, mediaChanged, isFirstTurn, config, signal);
1407
+ }
1408
+ /**
1409
+ * Streaming counterpart to {@link runStartPathWithMessage}: replays
1410
+ * through the cold start stream from a PRE-BUILT pending
1411
+ * `ChatMessage`. The cold-restart catch in `sendToolResultStream()`
1412
+ * delegates here with a pending `{ role: 'tool', ... }` message.
1413
+ */
1414
+ async *runStartStreamPathWithMessage(pendingMessage, mediaChanged, isFirstTurn, config, signal) {
754
1415
  // Capture pre-state so any non-successful exit can roll back.
755
1416
  // See `runStartPath` for the full rationale.
756
- const wasImageChangeRestart = imageChanged && !isFirstTurn;
1417
+ const wasMediaChangeRestart = mediaChanged && !isFirstTurn;
757
1418
  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
1419
+ // See the sync start path: reject before a media restart can clear native
1420
+ // state, and make the output budget explicit before allocating KV blocks.
1421
+ const constrainedConfig = await this.constrainToContextCapacity(this.historyWithPending(pendingMessage), config);
1422
+ this.prepareStartPath(mediaChanged, isFirstTurn);
1423
+ // Stage the pending message on the pending history BEFORE the
761
1424
  // stream starts — the native call reads it synchronously via
762
1425
  // `model.chatStreamSessionStart(history, config)`.
763
- this.history.push(userMsg);
1426
+ this.history.push(pendingMessage);
764
1427
  let sawFinal = false;
765
1428
  let accumulated = '';
1429
+ let accumulatedVisible = '';
766
1430
  let finalRaw = null;
1431
+ let finalReplayRaw = null;
1432
+ let finalTextAuthoritative;
767
1433
  let finalToolCalls;
1434
+ let finalThinking = null;
1435
+ let finalThinkingEnabled = false;
768
1436
  // Snapshot the history before dispatch — see `runStartPath` for
769
1437
  // the rationale.
770
1438
  const historySnapshot = this.history.slice();
771
1439
  try {
772
- for await (const event of this.model.chatStreamSessionStart(historySnapshot, config, signal)) {
1440
+ for await (const event of this.model.chatStreamSessionStart(historySnapshot, withReplayReasoning(constrainedConfig, this.model), signal)) {
773
1441
  if (event.done) {
774
1442
  if (event.finishReason !== 'error') {
775
1443
  sawFinal = true;
776
1444
  finalRaw = event.text;
1445
+ finalReplayRaw = event.rawText;
1446
+ finalTextAuthoritative = event.textAuthoritative;
777
1447
  finalToolCalls = event.toolCalls;
1448
+ finalThinking = event.thinking;
1449
+ finalThinkingEnabled = event.thinkingEnabled;
778
1450
  }
779
1451
  }
780
1452
  else {
781
1453
  accumulated += event.text;
1454
+ if (event.isReasoning !== true) {
1455
+ accumulatedVisible += event.text;
1456
+ }
782
1457
  }
783
- yield event;
1458
+ const publicEvent = publicStreamEvent(event, constrainedConfig);
1459
+ if (publicEvent !== null)
1460
+ yield publicEvent;
784
1461
  }
785
1462
  }
786
1463
  finally {
@@ -793,22 +1470,33 @@ export class ChatSession {
793
1470
  // generator was wound down. Mid-stream throws still propagate
794
1471
  // naturally — finally runs first, then the error continues up.
795
1472
  if (sawFinal) {
796
- this.history.push(buildAssistantMessage(finalRaw ?? accumulated, finalToolCalls));
1473
+ this.history.push(buildAssistantMessage(selectCommittedStreamText(finalRaw, accumulatedVisible, finalTextAuthoritative), finalToolCalls, finalThinking, finalThinkingEnabled, finalReplayRaw, this.model.replaysAssistantRawText?.() === true));
797
1474
  this.turnCount++;
798
- this.lastImagesKey = newImagesKey;
1475
+ this.needsFullReplay = false;
1476
+ // The start path always re-renders the FULL preserved history, so the
1477
+ // post-restart sticky keys are the trailing media keys of that history,
1478
+ // not the single-turn literal args. A restart driven by a change in only
1479
+ // one modality (e.g. an audio-only turn after an earlier image turn)
1480
+ // would otherwise null the untouched modality's key even though that
1481
+ // media is still live in the native cache, causing a later same-media
1482
+ // turn to be mis-detected as a change and replayed twice.
1483
+ this.lastImagesKey = this.computeTrailingImagesKey();
1484
+ this.lastAudioKey = this.computeTrailingAudioKey();
1485
+ this.commitActiveTools(constrainedConfig);
799
1486
  this.recordToolCallFanout(finalToolCalls);
800
1487
  }
801
1488
  else {
802
1489
  // Roll back: drop the tentative user push so history stays
803
1490
  // consistent with turnCount.
804
1491
  this.history.length = historyLenBefore;
805
- if (wasImageChangeRestart) {
1492
+ if (wasMediaChangeRestart) {
806
1493
  // Caches were wiped by prepareStartPath() but the new
807
1494
  // prefill never reached a successful done:true. Force the
808
1495
  // next call to re-route through the start path with the
809
1496
  // preserved prior history.
810
1497
  this.turnCount = 0;
811
1498
  this.lastImagesKey = null;
1499
+ this.lastAudioKey = null;
812
1500
  }
813
1501
  }
814
1502
  }
@@ -828,24 +1516,26 @@ export class ChatSession {
828
1516
  * turn.
829
1517
  * - On a fresh / reset history, re-inject the system prompt.
830
1518
  */
831
- prepareStartPath(imageChanged, isFirstTurn) {
832
- if (imageChanged && !isFirstTurn) {
1519
+ prepareStartPath(mediaChanged, isFirstTurn) {
1520
+ if (mediaChanged && !isFirstTurn) {
833
1521
  this.model.resetCaches();
834
1522
  }
835
1523
  if (this.history.length === 0 && this.system != null) {
836
1524
  this.history.push({ role: 'system', content: this.system });
837
1525
  }
838
1526
  }
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 };
1527
+ /** Build a user `ChatMessage` with or without attached images/audio. */
1528
+ buildUserMessage(userMessage, images, audio) {
1529
+ const msg = { role: 'user', content: userMessage };
1530
+ if (images && images.length > 0)
1531
+ msg.images = images;
1532
+ if (audio && audio.length > 0)
1533
+ msg.audio = audio;
1534
+ return msg;
845
1535
  }
846
1536
  /**
847
1537
  * Walk the history backward to find the most recent user message
848
- * with images and return its FNV-1a key. Used by
1538
+ * with images and return its SHA-256 key. Used by
849
1539
  * {@link startFromHistory} and {@link startFromHistoryStream} to
850
1540
  * hydrate `lastImagesKey` after a cold replay, so subsequent delta
851
1541
  * continues correctly detect image changes.
@@ -859,6 +1549,20 @@ export class ChatSession {
859
1549
  }
860
1550
  return null;
861
1551
  }
1552
+ /**
1553
+ * Audio counterpart of {@link computeTrailingImagesKey}: walk history
1554
+ * backward to the most recent user message carrying audio and return its
1555
+ * SHA-256 key, so a cold replay hydrates `lastAudioKey` correctly.
1556
+ */
1557
+ computeTrailingAudioKey() {
1558
+ for (let i = this.history.length - 1; i >= 0; i--) {
1559
+ const msg = this.history[i];
1560
+ if (msg?.role === 'user' && msg.audio && msg.audio.length > 0) {
1561
+ return computeAudioKey(msg.audio);
1562
+ }
1563
+ }
1564
+ return null;
1565
+ }
862
1566
  /**
863
1567
  * Derive the post-prime value of `unresolvedOkToolCallCount` from
864
1568
  * the primed history. Walks backward to the most recent assistant