@mlx-node/lm 0.0.7 → 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.
- package/README.md +11 -11
- package/dist/chat-session.d.ts +340 -95
- package/dist/chat-session.d.ts.map +1 -1
- package/dist/chat-session.js +704 -157
- package/dist/index.d.ts +12 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -7
- package/dist/interfaces.d.ts +2 -17
- package/dist/interfaces.d.ts.map +1 -1
- package/dist/models/lfm2-configs.d.ts.map +1 -1
- package/dist/models/lfm2-configs.js +59 -0
- package/dist/models/model-loader.d.ts +173 -4
- package/dist/models/model-loader.d.ts.map +1 -1
- package/dist/models/model-loader.js +226 -49
- package/dist/models/paged-config-override.d.ts +60 -0
- package/dist/models/paged-config-override.d.ts.map +1 -0
- package/dist/models/paged-config-override.js +254 -0
- package/dist/models/qwen3_5-configs.d.ts.map +1 -1
- package/dist/models/qwen3_5-configs.js +5 -0
- package/dist/stream.d.ts +181 -104
- package/dist/stream.d.ts.map +1 -1
- package/dist/stream.js +160 -227
- package/package.json +3 -3
package/dist/chat-session.js
CHANGED
|
@@ -1,3 +1,150 @@
|
|
|
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
|
+
}
|
|
1
148
|
/**
|
|
2
149
|
* Convert the parsed `ToolCallResult[]` emitted by the native chat
|
|
3
150
|
* pipeline into the `ToolCall[]` shape expected by
|
|
@@ -94,75 +241,58 @@ function countOkToolCalls(toolCalls) {
|
|
|
94
241
|
* template.
|
|
95
242
|
*
|
|
96
243
|
* This is a byte-identity check — callers use the key solely to
|
|
97
|
-
* decide whether to restart the server-side session, so
|
|
98
|
-
*
|
|
99
|
-
* length-prefixed framing so different image
|
|
100
|
-
* byte lengths cannot collide by accident.
|
|
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.
|
|
101
248
|
*
|
|
102
249
|
* Implementation note: kept fully sync + self-contained so
|
|
103
|
-
* `send()` can stay synchronous in its routing decision
|
|
104
|
-
*
|
|
105
|
-
* and the existing stream bridge.
|
|
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.
|
|
106
253
|
*/
|
|
107
254
|
function computeImagesKey(images) {
|
|
108
|
-
|
|
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)
|
|
109
281
|
return null;
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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');
|
|
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');
|
|
166
296
|
}
|
|
167
297
|
/**
|
|
168
298
|
* Cross-model chat session. See module docstring for design notes.
|
|
@@ -185,13 +315,22 @@ export class ChatSession {
|
|
|
185
315
|
history = [];
|
|
186
316
|
/**
|
|
187
317
|
* Hex-encoded byte-identity key of the image set currently bound
|
|
188
|
-
* to the server's KV cache (
|
|
318
|
+
* to the server's KV cache (SHA-256; see `computeImagesKey`).
|
|
189
319
|
* `null` when no images are cached. A `send()` whose new key
|
|
190
320
|
* differs triggers a full `chatSessionStart` restart.
|
|
191
321
|
*/
|
|
192
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;
|
|
193
330
|
turnCount = 0;
|
|
194
331
|
inFlight = false;
|
|
332
|
+
/** A failed/abandoned native delta must be followed by a full replay. */
|
|
333
|
+
needsFullReplay = false;
|
|
195
334
|
/**
|
|
196
335
|
* Count of `ok` tool calls emitted by the prior assistant turn, or
|
|
197
336
|
* `null` when the prior turn produced none. Gates every continuation
|
|
@@ -229,6 +368,55 @@ export class ChatSession {
|
|
|
229
368
|
get hasImages() {
|
|
230
369
|
return this.lastImagesKey !== null;
|
|
231
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
|
+
}
|
|
232
420
|
/**
|
|
233
421
|
* Count of `ok` tool calls from the most recent assistant turn, or
|
|
234
422
|
* `null` when the trailing turn produced none. Non-null means the
|
|
@@ -265,23 +453,43 @@ export class ChatSession {
|
|
|
265
453
|
try {
|
|
266
454
|
const mergedConfig = this.mergeConfig(opts.config);
|
|
267
455
|
const newImagesKey = computeImagesKey(opts.images);
|
|
268
|
-
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
// even after
|
|
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.
|
|
274
462
|
const imageChanged = newImagesKey !== null && newImagesKey !== this.lastImagesKey;
|
|
463
|
+
const audioChanged = newAudioKey !== null && newAudioKey !== this.lastAudioKey;
|
|
275
464
|
const isFirstTurn = this.turnCount === 0;
|
|
276
|
-
|
|
277
|
-
|
|
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);
|
|
278
491
|
}
|
|
279
|
-
|
|
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 });
|
|
492
|
+
this.history.push(pendingUser);
|
|
285
493
|
this.history.push(buildAssistantMessage(result.text, result.toolCalls));
|
|
286
494
|
this.turnCount++;
|
|
287
495
|
this.recordToolCallFanout(result.toolCalls);
|
|
@@ -311,36 +519,67 @@ export class ChatSession {
|
|
|
311
519
|
try {
|
|
312
520
|
const mergedConfig = this.mergeConfig(opts.config);
|
|
313
521
|
const newImagesKey = computeImagesKey(opts.images);
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
// even after
|
|
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.
|
|
320
528
|
const imageChanged = newImagesKey !== null && newImagesKey !== this.lastImagesKey;
|
|
529
|
+
const audioChanged = newAudioKey !== null && newAudioKey !== this.lastAudioKey;
|
|
321
530
|
const isFirstTurn = this.turnCount === 0;
|
|
322
|
-
|
|
323
|
-
|
|
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);
|
|
324
534
|
return;
|
|
325
535
|
}
|
|
326
536
|
// Delta continue stream: text-only.
|
|
537
|
+
const pendingUser = { role: 'user', content: userMessage };
|
|
538
|
+
const constrainedConfig = await this.constrainToContextCapacity(this.historyWithPending(pendingUser), mergedConfig);
|
|
327
539
|
let sawFinal = false;
|
|
328
540
|
let accumulated = '';
|
|
541
|
+
let accumulatedVisible = '';
|
|
329
542
|
let finalRaw = null;
|
|
330
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;
|
|
331
549
|
try {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if (event.
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
+
}
|
|
338
564
|
}
|
|
565
|
+
yield event;
|
|
339
566
|
}
|
|
340
|
-
|
|
341
|
-
|
|
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;
|
|
342
579
|
}
|
|
343
|
-
|
|
580
|
+
delegated = true;
|
|
581
|
+
yield* this.runStartStreamPath(userMessage, undefined, undefined, true, false, constrainedConfig, opts.signal);
|
|
582
|
+
return;
|
|
344
583
|
}
|
|
345
584
|
}
|
|
346
585
|
finally {
|
|
@@ -350,13 +589,21 @@ export class ChatSession {
|
|
|
350
589
|
// chunks alike. The delta path doesn't push to history until
|
|
351
590
|
// commit, so the rollback branch is a no-op: nothing to
|
|
352
591
|
// undo, and the native cache state is managed by the Rust
|
|
353
|
-
// save_cache_state path on its own.
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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));
|
|
357
598
|
this.turnCount++;
|
|
358
599
|
this.recordToolCallFanout(finalToolCalls);
|
|
359
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
|
+
}
|
|
360
607
|
}
|
|
361
608
|
}
|
|
362
609
|
finally {
|
|
@@ -377,6 +624,18 @@ export class ChatSession {
|
|
|
377
624
|
* hit this must tighten the prompt / tool spec or reset the
|
|
378
625
|
* session.
|
|
379
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
|
+
*
|
|
380
639
|
* Appends a `{ role: 'tool', ... }` message to history on success.
|
|
381
640
|
*/
|
|
382
641
|
async sendToolResult(toolCallId, content, opts = {}) {
|
|
@@ -386,19 +645,77 @@ export class ChatSession {
|
|
|
386
645
|
this.assertCanSendToolResult('sendToolResult');
|
|
387
646
|
this.inFlight = true;
|
|
388
647
|
try {
|
|
389
|
-
const
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
this.
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
+
}
|
|
396
690
|
}
|
|
397
691
|
finally {
|
|
398
692
|
this.inFlight = false;
|
|
399
693
|
}
|
|
400
694
|
}
|
|
401
|
-
/**
|
|
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
|
+
*/
|
|
402
719
|
async *sendToolResultStream(toolCallId, content, opts = {}) {
|
|
403
720
|
if (this.inFlight) {
|
|
404
721
|
throw new Error('ChatSession: concurrent send() not allowed; await the previous call first');
|
|
@@ -406,43 +723,107 @@ export class ChatSession {
|
|
|
406
723
|
this.assertCanSendToolResult('sendToolResultStream');
|
|
407
724
|
this.inFlight = true;
|
|
408
725
|
try {
|
|
409
|
-
const
|
|
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
|
+
}
|
|
410
744
|
let sawFinal = false;
|
|
411
745
|
let accumulated = '';
|
|
746
|
+
let accumulatedVisible = '';
|
|
412
747
|
let finalRaw = null;
|
|
413
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;
|
|
414
754
|
try {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
if (event.
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
+
}
|
|
421
763
|
}
|
|
764
|
+
else {
|
|
765
|
+
accumulated += event.text;
|
|
766
|
+
if (event.isReasoning !== true) {
|
|
767
|
+
accumulatedVisible += event.text;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
yield event;
|
|
422
771
|
}
|
|
423
|
-
|
|
424
|
-
|
|
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;
|
|
425
786
|
}
|
|
426
|
-
|
|
787
|
+
delegated = true;
|
|
788
|
+
yield* this.replayToolResultThroughStartStreamPath(toolMsg, constrainedConfig, signal);
|
|
789
|
+
return;
|
|
427
790
|
}
|
|
428
791
|
}
|
|
429
792
|
finally {
|
|
430
793
|
// finally runs for normal completion, mid-stream throw,
|
|
431
794
|
// caller `break` (iterator.return() short-circuits the yield),
|
|
432
795
|
// and error-finish chunks alike. Tool turns never touch
|
|
433
|
-
// history until commit, so the rollback branch is a no-op.
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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));
|
|
437
803
|
this.turnCount++;
|
|
438
804
|
this.recordToolCallFanout(finalToolCalls);
|
|
439
805
|
}
|
|
806
|
+
else if (!delegated) {
|
|
807
|
+
this.needsFullReplay = true;
|
|
808
|
+
}
|
|
440
809
|
}
|
|
441
810
|
}
|
|
442
811
|
finally {
|
|
443
812
|
this.inFlight = false;
|
|
444
813
|
}
|
|
445
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
|
+
}
|
|
446
827
|
/**
|
|
447
828
|
* Reset the session state.
|
|
448
829
|
*
|
|
@@ -450,6 +831,23 @@ export class ChatSession {
|
|
|
450
831
|
* image key, and turn counter so the next `send()` goes through
|
|
451
832
|
* `chatSessionStart` again.
|
|
452
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
|
+
*
|
|
453
851
|
* Returns `Promise<void>` for an async-friendly signature even
|
|
454
852
|
* though `resetCaches()` is currently synchronous.
|
|
455
853
|
*/
|
|
@@ -460,8 +858,10 @@ export class ChatSession {
|
|
|
460
858
|
this.model.resetCaches();
|
|
461
859
|
this.history = [];
|
|
462
860
|
this.lastImagesKey = null;
|
|
861
|
+
this.lastAudioKey = null;
|
|
463
862
|
this.turnCount = 0;
|
|
464
863
|
this.unresolvedOkToolCallCount = null;
|
|
864
|
+
this.needsFullReplay = false;
|
|
465
865
|
}
|
|
466
866
|
/**
|
|
467
867
|
* Prime the session history without running inference.
|
|
@@ -527,10 +927,14 @@ export class ChatSession {
|
|
|
527
927
|
this.inFlight = true;
|
|
528
928
|
try {
|
|
529
929
|
const mergedConfig = this.mergeConfig(config);
|
|
530
|
-
const
|
|
930
|
+
const historySnapshot = this.history.slice();
|
|
931
|
+
const constrainedConfig = await this.constrainToContextCapacity(historySnapshot, mergedConfig);
|
|
932
|
+
const result = await this.model.chatSessionStart(historySnapshot, constrainedConfig);
|
|
531
933
|
this.history.push(buildAssistantMessage(result.text, result.toolCalls));
|
|
532
934
|
this.turnCount++;
|
|
935
|
+
this.needsFullReplay = false;
|
|
533
936
|
this.lastImagesKey = this.computeTrailingImagesKey();
|
|
937
|
+
this.lastAudioKey = this.computeTrailingAudioKey();
|
|
534
938
|
this.recordToolCallFanout(result.toolCalls);
|
|
535
939
|
return result;
|
|
536
940
|
}
|
|
@@ -562,12 +966,14 @@ export class ChatSession {
|
|
|
562
966
|
try {
|
|
563
967
|
const mergedConfig = this.mergeConfig(config);
|
|
564
968
|
const historySnapshot = this.history.slice();
|
|
969
|
+
const constrainedConfig = await this.constrainToContextCapacity(historySnapshot, mergedConfig);
|
|
565
970
|
let sawFinal = false;
|
|
566
971
|
let accumulated = '';
|
|
972
|
+
let accumulatedVisible = '';
|
|
567
973
|
let finalRaw = null;
|
|
568
974
|
let finalToolCalls;
|
|
569
975
|
try {
|
|
570
|
-
for await (const event of this.model.chatStreamSessionStart(historySnapshot,
|
|
976
|
+
for await (const event of this.model.chatStreamSessionStart(historySnapshot, constrainedConfig, signal)) {
|
|
571
977
|
if (event.done) {
|
|
572
978
|
if (event.finishReason !== 'error') {
|
|
573
979
|
sawFinal = true;
|
|
@@ -577,6 +983,9 @@ export class ChatSession {
|
|
|
577
983
|
}
|
|
578
984
|
else {
|
|
579
985
|
accumulated += event.text;
|
|
986
|
+
if (event.isReasoning !== true) {
|
|
987
|
+
accumulatedVisible += event.text;
|
|
988
|
+
}
|
|
580
989
|
}
|
|
581
990
|
yield event;
|
|
582
991
|
}
|
|
@@ -588,9 +997,11 @@ export class ChatSession {
|
|
|
588
997
|
// mutated on a successful commit — on any non-success exit,
|
|
589
998
|
// the primed state is left intact so the caller can retry.
|
|
590
999
|
if (sawFinal) {
|
|
591
|
-
this.history.push(buildAssistantMessage(finalRaw
|
|
1000
|
+
this.history.push(buildAssistantMessage(finalRaw || accumulatedVisible, finalToolCalls));
|
|
592
1001
|
this.turnCount++;
|
|
1002
|
+
this.needsFullReplay = false;
|
|
593
1003
|
this.lastImagesKey = this.computeTrailingImagesKey();
|
|
1004
|
+
this.lastAudioKey = this.computeTrailingAudioKey();
|
|
594
1005
|
this.recordToolCallFanout(finalToolCalls);
|
|
595
1006
|
}
|
|
596
1007
|
}
|
|
@@ -696,13 +1107,82 @@ export class ChatSession {
|
|
|
696
1107
|
* The session path is a session-reuse operation by construction —
|
|
697
1108
|
* `reuseCache: false` on the continue path would wipe the very
|
|
698
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).
|
|
699
1120
|
*/
|
|
700
1121
|
mergeConfig(overlay) {
|
|
701
|
-
|
|
1122
|
+
const merged = {
|
|
702
1123
|
...this.defaultConfig,
|
|
703
1124
|
...overlay,
|
|
704
1125
|
reuseCache: true,
|
|
705
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;
|
|
706
1186
|
}
|
|
707
1187
|
/**
|
|
708
1188
|
* Shared start-path logic for `send()`. Handles both the turn-0
|
|
@@ -711,27 +1191,51 @@ export class ChatSession {
|
|
|
711
1191
|
* native side gets the full conversation re-rendered with the new
|
|
712
1192
|
* image set.
|
|
713
1193
|
*/
|
|
714
|
-
async runStartPath(userMessage, images,
|
|
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) {
|
|
715
1207
|
// Capture pre-state so the restart can be rolled back if the
|
|
716
|
-
// native call fails. The
|
|
1208
|
+
// native call fails. The media-change branch resets caches BEFORE
|
|
717
1209
|
// we know whether the new prefill will succeed, so on failure we
|
|
718
|
-
// also have to drop turnCount + lastImagesKey to force
|
|
719
|
-
// call to re-route through the start path (rather than a
|
|
720
|
-
// continue against wiped caches).
|
|
721
|
-
const
|
|
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;
|
|
722
1214
|
const historyLenBefore = this.history.length;
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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);
|
|
726
1221
|
try {
|
|
727
1222
|
// Pass a shallow snapshot so later pushes to `this.history`
|
|
728
1223
|
// (e.g. the assistant reply below) don't retroactively mutate
|
|
729
1224
|
// what the native side / any mock observed as its `messages`
|
|
730
1225
|
// argument.
|
|
731
|
-
const result = await this.model.chatSessionStart(this.history.slice(),
|
|
1226
|
+
const result = await this.model.chatSessionStart(this.history.slice(), constrainedConfig);
|
|
732
1227
|
this.history.push(buildAssistantMessage(result.text, result.toolCalls));
|
|
733
1228
|
this.turnCount++;
|
|
734
|
-
this.
|
|
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();
|
|
735
1239
|
this.recordToolCallFanout(result.toolCalls);
|
|
736
1240
|
return result;
|
|
737
1241
|
}
|
|
@@ -739,37 +1243,51 @@ export class ChatSession {
|
|
|
739
1243
|
// Roll back: drop the tentative user push so history stays
|
|
740
1244
|
// consistent with turnCount.
|
|
741
1245
|
this.history.length = historyLenBefore;
|
|
742
|
-
if (
|
|
1246
|
+
if (wasMediaChangeRestart) {
|
|
743
1247
|
// Caches were wiped by prepareStartPath() but the new prefill
|
|
744
1248
|
// failed. Force the next call to re-route through the start
|
|
745
1249
|
// path with the (preserved) prior history.
|
|
746
1250
|
this.turnCount = 0;
|
|
747
1251
|
this.lastImagesKey = null;
|
|
1252
|
+
this.lastAudioKey = null;
|
|
748
1253
|
}
|
|
749
1254
|
throw err;
|
|
750
1255
|
}
|
|
751
1256
|
}
|
|
752
1257
|
/** Streaming counterpart to {@link runStartPath}. */
|
|
753
|
-
async *runStartStreamPath(userMessage, images,
|
|
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) {
|
|
754
1269
|
// Capture pre-state so any non-successful exit can roll back.
|
|
755
1270
|
// See `runStartPath` for the full rationale.
|
|
756
|
-
const
|
|
1271
|
+
const wasMediaChangeRestart = mediaChanged && !isFirstTurn;
|
|
757
1272
|
const historyLenBefore = this.history.length;
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
|
761
1278
|
// stream starts — the native call reads it synchronously via
|
|
762
1279
|
// `model.chatStreamSessionStart(history, config)`.
|
|
763
|
-
this.history.push(
|
|
1280
|
+
this.history.push(pendingMessage);
|
|
764
1281
|
let sawFinal = false;
|
|
765
1282
|
let accumulated = '';
|
|
1283
|
+
let accumulatedVisible = '';
|
|
766
1284
|
let finalRaw = null;
|
|
767
1285
|
let finalToolCalls;
|
|
768
1286
|
// Snapshot the history before dispatch — see `runStartPath` for
|
|
769
1287
|
// the rationale.
|
|
770
1288
|
const historySnapshot = this.history.slice();
|
|
771
1289
|
try {
|
|
772
|
-
for await (const event of this.model.chatStreamSessionStart(historySnapshot,
|
|
1290
|
+
for await (const event of this.model.chatStreamSessionStart(historySnapshot, constrainedConfig, signal)) {
|
|
773
1291
|
if (event.done) {
|
|
774
1292
|
if (event.finishReason !== 'error') {
|
|
775
1293
|
sawFinal = true;
|
|
@@ -779,6 +1297,9 @@ export class ChatSession {
|
|
|
779
1297
|
}
|
|
780
1298
|
else {
|
|
781
1299
|
accumulated += event.text;
|
|
1300
|
+
if (event.isReasoning !== true) {
|
|
1301
|
+
accumulatedVisible += event.text;
|
|
1302
|
+
}
|
|
782
1303
|
}
|
|
783
1304
|
yield event;
|
|
784
1305
|
}
|
|
@@ -793,22 +1314,32 @@ export class ChatSession {
|
|
|
793
1314
|
// generator was wound down. Mid-stream throws still propagate
|
|
794
1315
|
// naturally — finally runs first, then the error continues up.
|
|
795
1316
|
if (sawFinal) {
|
|
796
|
-
this.history.push(buildAssistantMessage(finalRaw
|
|
1317
|
+
this.history.push(buildAssistantMessage(finalRaw || accumulatedVisible, finalToolCalls));
|
|
797
1318
|
this.turnCount++;
|
|
798
|
-
this.
|
|
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();
|
|
799
1329
|
this.recordToolCallFanout(finalToolCalls);
|
|
800
1330
|
}
|
|
801
1331
|
else {
|
|
802
1332
|
// Roll back: drop the tentative user push so history stays
|
|
803
1333
|
// consistent with turnCount.
|
|
804
1334
|
this.history.length = historyLenBefore;
|
|
805
|
-
if (
|
|
1335
|
+
if (wasMediaChangeRestart) {
|
|
806
1336
|
// Caches were wiped by prepareStartPath() but the new
|
|
807
1337
|
// prefill never reached a successful done:true. Force the
|
|
808
1338
|
// next call to re-route through the start path with the
|
|
809
1339
|
// preserved prior history.
|
|
810
1340
|
this.turnCount = 0;
|
|
811
1341
|
this.lastImagesKey = null;
|
|
1342
|
+
this.lastAudioKey = null;
|
|
812
1343
|
}
|
|
813
1344
|
}
|
|
814
1345
|
}
|
|
@@ -828,24 +1359,26 @@ export class ChatSession {
|
|
|
828
1359
|
* turn.
|
|
829
1360
|
* - On a fresh / reset history, re-inject the system prompt.
|
|
830
1361
|
*/
|
|
831
|
-
prepareStartPath(
|
|
832
|
-
if (
|
|
1362
|
+
prepareStartPath(mediaChanged, isFirstTurn) {
|
|
1363
|
+
if (mediaChanged && !isFirstTurn) {
|
|
833
1364
|
this.model.resetCaches();
|
|
834
1365
|
}
|
|
835
1366
|
if (this.history.length === 0 && this.system != null) {
|
|
836
1367
|
this.history.push({ role: 'system', content: this.system });
|
|
837
1368
|
}
|
|
838
1369
|
}
|
|
839
|
-
/** Build a user `ChatMessage` with or without attached images. */
|
|
840
|
-
buildUserMessage(userMessage, images) {
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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;
|
|
845
1378
|
}
|
|
846
1379
|
/**
|
|
847
1380
|
* Walk the history backward to find the most recent user message
|
|
848
|
-
* with images and return its
|
|
1381
|
+
* with images and return its SHA-256 key. Used by
|
|
849
1382
|
* {@link startFromHistory} and {@link startFromHistoryStream} to
|
|
850
1383
|
* hydrate `lastImagesKey` after a cold replay, so subsequent delta
|
|
851
1384
|
* continues correctly detect image changes.
|
|
@@ -859,6 +1392,20 @@ export class ChatSession {
|
|
|
859
1392
|
}
|
|
860
1393
|
return null;
|
|
861
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
|
+
}
|
|
862
1409
|
/**
|
|
863
1410
|
* Derive the post-prime value of `unresolvedOkToolCallCount` from
|
|
864
1411
|
* the primed history. Walks backward to the most recent assistant
|