@mlx-node/lm 0.0.13 → 0.0.15

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.
Files changed (42) hide show
  1. package/dist/chat-session.d.ts +1 -1
  2. package/dist/chat-session.d.ts.map +1 -1
  3. package/dist/chat-session.js +2 -2
  4. package/dist/draft-companion.d.ts +16 -0
  5. package/dist/draft-companion.d.ts.map +1 -0
  6. package/dist/draft-companion.js +76 -0
  7. package/dist/family-data.d.ts +2 -0
  8. package/dist/family-data.d.ts.map +1 -1
  9. package/dist/family-data.js +2 -0
  10. package/dist/gguf-metadata.d.ts +2 -0
  11. package/dist/gguf-metadata.d.ts.map +1 -0
  12. package/dist/gguf-metadata.js +128 -0
  13. package/dist/model-detection.d.ts +6 -0
  14. package/dist/model-detection.d.ts.map +1 -0
  15. package/dist/model-detection.js +38 -0
  16. package/dist/model-discovery.d.ts +24 -0
  17. package/dist/model-discovery.d.ts.map +1 -0
  18. package/dist/model-discovery.js +274 -0
  19. package/dist/models/model-loader.d.ts +6 -0
  20. package/dist/models/model-loader.d.ts.map +1 -1
  21. package/dist/models/model-loader.js +10 -32
  22. package/dist/models/paged-config-override.d.ts.map +1 -1
  23. package/dist/models/paged-config-override.js +21 -1
  24. package/dist/stream.d.ts.map +1 -1
  25. package/dist/stream.js +5 -5
  26. package/package.json +21 -3
  27. package/src/chat-session.ts +2369 -0
  28. package/src/draft-companion.ts +74 -0
  29. package/src/family-data.ts +542 -0
  30. package/src/gguf-metadata.ts +117 -0
  31. package/src/index.ts +151 -0
  32. package/src/model-detection.ts +46 -0
  33. package/src/model-discovery.ts +329 -0
  34. package/src/models/lfm2-configs.ts +110 -0
  35. package/src/models/model-loader.ts +256 -0
  36. package/src/models/paged-config-override.ts +387 -0
  37. package/src/models/qwen3-configs.ts +113 -0
  38. package/src/models/qwen3_5-configs.ts +60 -0
  39. package/src/profiling.ts +69 -0
  40. package/src/stream.ts +960 -0
  41. package/src/tools/index.ts +58 -0
  42. package/src/tools/types.ts +215 -0
@@ -0,0 +1,2369 @@
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: release this session's native cache owner → push the new user
25
+ * message (with images) to history → `chatSessionStart(history)`. Native
26
+ * models without owner-scoped release retain the exclusive-model
27
+ * `resetCaches()` fallback.
28
+ *
29
+ * - Text-only `send()` on turn >= 1 still gets incremental prefill
30
+ * on a token-prefix hit. Prompt structure is never reconstructed
31
+ * from Rust string literals.
32
+ *
33
+ * - `sendToolResult` always dispatches `chatSessionContinueTool`,
34
+ * since tool turns never change image state. The session enforces
35
+ * a strict unresolved-ok-tool-call contract at runtime, driven by
36
+ * `unresolvedOkToolCallCount` (derived from `ChatResult.toolCalls`
37
+ * after each turn via `countOkToolCalls` /
38
+ * `computeTrailingAssistantUnresolvedToolCallCount`):
39
+ *
40
+ * * `null` — the trailing assistant turn has no outstanding ok
41
+ * tool call. Plain `send()` / `sendStream()` are the only
42
+ * valid entry points; `sendToolResult*()` throws because
43
+ * there is nothing for the result to resolve.
44
+ * * `1` — exactly one outstanding ok tool call. Plain `send()` /
45
+ * `sendStream()` throw (they would orphan the call);
46
+ * `sendToolResult*()` is the sole valid forward step and
47
+ * dispatches the tool result through the native session.
48
+ * * `>1` — a multi-tool-call fan-out that the chat-session API
49
+ * cannot progress incrementally (each `sendToolResult*` would
50
+ * re-open the assistant turn and weave new replies between
51
+ * the sibling results). Both `send()` / `sendStream()` and
52
+ * `sendToolResult*()` throw. The only valid recovery is
53
+ * `reset()` or `primeHistory()` + `startFromHistory*()` with
54
+ * a fully-resolved conversation — there is no "advance past
55
+ * the broken turn" path.
56
+ *
57
+ * - `sawFinal` gates `turnCount` advance on the streaming path, so
58
+ * the session refuses to advance when the stream throws
59
+ * mid-decode or yields a final chunk with
60
+ * `finishReason: 'error'`.
61
+ *
62
+ * - The `inFlight` guard rejects concurrent `send()` /
63
+ * `sendStream()` calls at the class level. The native side
64
+ * serializes cache mutation on a single worker thread, so a
65
+ * second in-flight call would race the first's cache-save step.
66
+ *
67
+ * - **Cold-restart primitives.** `primeHistory()` plus
68
+ * `startFromHistory()` / `startFromHistoryStream()` let a caller
69
+ * seed a fresh session with an externally-reconstructed history
70
+ * (e.g. a server `ResponseStore` chain) and replay it through the
71
+ * native `chatSessionStart` path without going through `send()`.
72
+ * These are intended for server-side `SessionRegistry` cache-miss
73
+ * cold-start; normal usage stays on `send` / `sendStream` /
74
+ * `sendToolResult` / `reset`.
75
+ *
76
+ * ## Typical usage
77
+ *
78
+ * ```typescript
79
+ * import { Qwen35Model, ChatSession } from '@mlx-node/lm';
80
+ *
81
+ * const model = await Qwen35Model.load('./models/qwen3.5-0.8b');
82
+ * const session = new ChatSession(model, { system: 'Be concise.' });
83
+ * const r1 = await session.send('Say hi in one word.');
84
+ * const r2 = await session.send('Another word?');
85
+ * await session.reset();
86
+ * ```
87
+ */
88
+ import { createHash, randomUUID } from 'node:crypto';
89
+
90
+ import type { ChatConfig, ChatMessage, ChatResult, ToolCall, ToolCallResult, ToolDefinition } from '@mlx-node/core';
91
+
92
+ import type { ChatStreamEvent } from './stream.js';
93
+
94
+ /**
95
+ * Typed prefix native media guards use when a history cannot be continued
96
+ * from the held image/audio state. The session layer recognizes this exact
97
+ * prefix and transparently replays the complete structured conversation.
98
+ *
99
+ * MUST stay byte-for-byte identical to the Rust constant
100
+ * `IMAGE_CHANGE_RESTART_PREFIX` in
101
+ * `crates/mlx-core/src/engine/cache.rs` — it is not exported across the
102
+ * NAPI boundary, so the two literals are kept in sync by hand. The
103
+ * native message starts with this prefix and is delivered as the
104
+ * `Error.message`: on the sync path as a rejected promise, and on
105
+ * the streaming path as a thrown error on the generator's first
106
+ * iteration (the native worker-thread sink error is re-thrown by the
107
+ * `packages/lm/src/stream.ts` bridge before any chunk is yielded).
108
+ */
109
+ const IMAGE_CHANGE_RESTART_PREFIX = 'IMAGE_CHANGE_REQUIRES_SESSION_RESTART:';
110
+
111
+ /**
112
+ * Default resolved by the native shared chat engine when `maxNewTokens` is
113
+ * absent. Keep this in sync with `extract_chat_params()` in
114
+ * `crates/mlx-core/src/engine/params.rs`.
115
+ */
116
+ const NATIVE_DEFAULT_MAX_NEW_TOKENS = 2048;
117
+
118
+ /**
119
+ * Model wrapper class names whose in-checkpoint MTP head must NOT be
120
+ * auto-enabled. Fallback only: {@link ChatSession#mtpAutoDefaultAllowed}
121
+ * prefers the native {@link SessionCapableModel.mtpAutoEnabled} getter in BOTH
122
+ * directions whenever the binding exposes it.
123
+ *
124
+ * NemotronH ships a complete MTP head on every checkpoint, so
125
+ * `hasMtpWeights()` is unconditionally `true`. Setting `enable_mtp` forces the
126
+ * turn into the exclusive/barrier scheduler lane, which takes the session OUT
127
+ * of continuous batching so concurrent sessions serialize; a streaming MTP turn
128
+ * additionally has no flat-core streaming arm and falls back to paged AR — no
129
+ * speculation and no batching. The BARRIER is the reason, not the head's speed:
130
+ * MTP is roughly perf-neutral on this family, so an explicit per-session
131
+ * `enableMtp: true` costs nothing. Drop the family from this set once an MTP
132
+ * turn can share the continuous-batching lane.
133
+ */
134
+ const MTP_AUTO_DEFAULT_SUPPRESSED_MODELS: ReadonlySet<string> = new Set(['NemotronHModel']);
135
+
136
+ /**
137
+ * Stable, provider-neutral error raised before native inference when a
138
+ * rendered prompt cannot fit in the model's physically available hot KV
139
+ * window. The marker is intentionally the canonical string recognized by
140
+ * pi's overflow recovery, so managed agent sessions compact and retry while
141
+ * stateless HTTP callers receive a clean request error instead of a native
142
+ * `BlockAllocator exhausted` failure.
143
+ */
144
+ export class ContextCapacityError extends Error {
145
+ readonly code = 'context_length_exceeded';
146
+
147
+ constructor(
148
+ readonly promptTokens: number,
149
+ readonly effectiveWindowTokens: number,
150
+ ) {
151
+ super(
152
+ `context_length_exceeded: rendered prompt uses ${promptTokens} tokens, ` +
153
+ `but this model currently has capacity for ${effectiveWindowTokens} tokens`,
154
+ );
155
+ this.name = 'ContextCapacityError';
156
+ }
157
+ }
158
+
159
+ /** Recognize both the typed JS preflight and the native hard backstop. */
160
+ export function isContextCapacityError(error: unknown): boolean {
161
+ return (
162
+ error instanceof ContextCapacityError ||
163
+ (error instanceof Error && error.message.startsWith('context_length_exceeded:'))
164
+ );
165
+ }
166
+
167
+ /** Physical and trained context limits captured by a native model at load. */
168
+ export interface SessionContextLimits {
169
+ trainedWindowTokens: number;
170
+ effectiveWindowTokens: number;
171
+ pagedBlockCapacity: number;
172
+ pagedBlockSize: number;
173
+ }
174
+
175
+ /**
176
+ * Whether `err` is the native media-held delta rejection (see
177
+ * {@link IMAGE_CHANGE_RESTART_PREFIX}). The native message begins with
178
+ * the literal prefix and reaches both the sync and streaming bridges
179
+ * unwrapped (NAPI surfaces `Error.from_reason` as `Error.message`
180
+ * verbatim), so a `startsWith` match is exact.
181
+ */
182
+ function isMediaHeldRestartError(err: unknown): boolean {
183
+ return err instanceof Error && err.message.startsWith(IMAGE_CHANGE_RESTART_PREFIX);
184
+ }
185
+
186
+ /**
187
+ * Convert the parsed `ToolCallResult[]` emitted by the native chat
188
+ * pipeline into the `ToolCall[]` shape expected by
189
+ * `ChatMessage.toolCalls` (and, by extension, the jinja chat
190
+ * templates on cold replay).
191
+ *
192
+ * Two shape differences to bridge:
193
+ *
194
+ * 1. `ToolCallResult.arguments` is `Record<string, unknown> | string`
195
+ * (already parsed by the native parser when status is "ok",
196
+ * preserved as the original string on parse failure). The
197
+ * `ChatMessage.toolCalls` contract is `arguments: string`, and
198
+ * the native tokenizer's `render_chat_template` pre-parses that
199
+ * string back into a `serde_json::Value` before handing it to
200
+ * jinja. We therefore `JSON.stringify` any non-string argument
201
+ * so the round-trip is lossless. Strings are passed through
202
+ * verbatim so a failed-to-parse payload retains its original
203
+ * bytes (the template then sees it as a quoted string, which is
204
+ * the safest available fallback).
205
+ * 2. Only `status === "ok"` calls carry a well-formed
206
+ * `(name, arguments)` pair — the other statuses (`invalid_json`,
207
+ * `missing_name`, `parse_error`) are informational diagnostics
208
+ * that the native parser emits for observability and that the
209
+ * downstream chat template has no way to render. Preserving them
210
+ * on the replay path would inject garbage tool-call tags into
211
+ * the jinja output. We filter to `ok` entries only — matching the
212
+ * filter every other consumer (server response mapper, tool-use
213
+ * examples, README guidance) already applies.
214
+ *
215
+ * Returns `undefined` when the input is absent or yields no `ok`
216
+ * entries so the assistant `ChatMessage` stays minimal (no empty
217
+ * `toolCalls: []` field polluting the history).
218
+ */
219
+ function toAssistantToolCalls(toolCalls: readonly ToolCallResult[] | undefined): ToolCall[] | undefined {
220
+ if (!toolCalls || toolCalls.length === 0) return undefined;
221
+ const out: ToolCall[] = [];
222
+ for (const tc of toolCalls) {
223
+ if (tc.status !== 'ok') continue;
224
+ const argsStr = typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments);
225
+ out.push({ id: tc.id, name: tc.name, arguments: argsStr });
226
+ }
227
+ return out.length > 0 ? out : undefined;
228
+ }
229
+
230
+ /**
231
+ * Build an assistant `ChatMessage` from a just-completed turn's
232
+ * decoded text, exact raw text, tool-call list, reasoning body, and resolved
233
+ * thinking mode. LFM2 replays the exact raw content because its checkpoint
234
+ * template does not consume structured reasoning; Qwen/Gemma retain their
235
+ * structured fields. The assistant entry is appended to `this.history` after every
236
+ * successful turn and is later read back by the native
237
+ * `chatSessionStart` cold-replay path (image-change mid-session
238
+ * restart, `startFromHistory*`, server-side `SessionRegistry`
239
+ * cache-miss rebuild). Dropping `toolCalls`, `reasoningContent`, or
240
+ * `thinkingEnabled` changes the rendered assistant bytes on replay:
241
+ * tool responses lose their declaring call, reasoning disappears, or
242
+ * an empty disabled-thinking channel is reinterpreted under the
243
+ * current turn's mode.
244
+ */
245
+ function buildAssistantMessage(
246
+ text: string,
247
+ toolCalls: readonly ToolCallResult[] | undefined,
248
+ thinking: string | null | undefined,
249
+ thinkingEnabled: boolean,
250
+ rawText: string | null | undefined,
251
+ replayRawText: boolean,
252
+ ): ChatMessage {
253
+ if (replayRawText) {
254
+ return {
255
+ role: 'assistant',
256
+ content: rawText ?? text,
257
+ thinkingEnabled,
258
+ };
259
+ }
260
+ const calls = toAssistantToolCalls(toolCalls);
261
+ const message: ChatMessage = {
262
+ role: 'assistant',
263
+ content: text,
264
+ thinkingEnabled,
265
+ };
266
+ if (calls) {
267
+ message.toolCalls = calls;
268
+ }
269
+ if (thinking != null) {
270
+ message.reasoningContent = thinking;
271
+ }
272
+ return message;
273
+ }
274
+
275
+ /**
276
+ * Count the `ok`-status tool calls in a `ChatResult.toolCalls` /
277
+ * terminal stream chunk. Used to detect the unsupported multi-call
278
+ * fan-out pattern — the chat-session API only serves one tool call
279
+ * per assistant turn because each `sendToolResult` dispatch
280
+ * immediately re-opens the assistant turn, so a second result would
281
+ * land after a new assistant reply and corrupt the conversation
282
+ * structure. Non-`ok` entries (`parse_error`, `invalid_json`, etc.)
283
+ * are ignored because the caller cannot respond to them anyway.
284
+ */
285
+ function countOkToolCalls(toolCalls: readonly ToolCallResult[] | undefined): number {
286
+ if (!toolCalls || toolCalls.length === 0) return 0;
287
+ let n = 0;
288
+ for (const c of toolCalls) {
289
+ if (c.status === 'ok') n++;
290
+ }
291
+ return n;
292
+ }
293
+
294
+ /**
295
+ * Select the assistant text committed after a successful stream.
296
+ *
297
+ * Default emitters expose parsed text on the terminal event, so an empty
298
+ * value is authoritative for a tool-only turn and must not fall back to raw
299
+ * streamed tool markup. Gemma's channel-aware emitter instead sends visible
300
+ * text as deltas and an empty terminal value for ordinary no-tool replies, so
301
+ * retain the accumulated visible text in that distinct shape.
302
+ */
303
+ function selectCommittedStreamText(
304
+ finalText: string | null,
305
+ accumulatedVisible: string,
306
+ terminalTextAuthoritative: boolean | undefined,
307
+ ): string {
308
+ if (finalText == null) return accumulatedVisible;
309
+ if (terminalTextAuthoritative === true) return finalText;
310
+ if (terminalTextAuthoritative === false) return accumulatedVisible;
311
+ if (finalText !== '') return finalText;
312
+ return accumulatedVisible;
313
+ }
314
+
315
+ type ReplayCaptureResult = ChatResult & { publicRawText?: string };
316
+ type ReplayCaptureStreamEvent = ChatStreamEvent & {
317
+ publicRawText?: string;
318
+ textAuthoritative?: boolean;
319
+ };
320
+
321
+ /** Mirror the native default used by `resolve_include_reasoning`. */
322
+ function includesReasoning(config: ChatConfig): boolean {
323
+ return config.includeReasoning ?? config.reasoningEffort !== 'none';
324
+ }
325
+
326
+ /**
327
+ * Session history must retain reasoning even when the caller hides it. Ask
328
+ * native finalization for the full parsed turn without mutating the committed
329
+ * request config; the public view is redacted again below.
330
+ */
331
+ function withReplayReasoning(config: ChatConfig, model: SessionCapableModel): ChatConfig {
332
+ if (includesReasoning(config)) return config;
333
+ if (model.supportsReplayReasoningCapture?.() !== true) return config;
334
+ // A zero-budget "none" turn cannot produce a reasoning body worth
335
+ // replaying. Preserve the caller's native suppression flag for this common
336
+ // short-generation path (for example title generation).
337
+ if (config.reasoningEffort === 'none' && (config.thinkingTokenBudget ?? 0) <= 0) {
338
+ return config;
339
+ }
340
+ return { ...config, includeReasoning: true };
341
+ }
342
+
343
+ function publicChatResult(result: ChatResult, config: ChatConfig): ChatResult {
344
+ if (includesReasoning(config)) return result;
345
+ const safeRaw = (result as ReplayCaptureResult).publicRawText;
346
+ return { ...result, thinking: undefined, rawText: safeRaw ?? result.text };
347
+ }
348
+
349
+ function publicStreamEvent(event: ChatStreamEvent, config: ChatConfig): ChatStreamEvent | null {
350
+ if (includesReasoning(config)) return event;
351
+ if (!event.done) {
352
+ return event.isReasoning === true ? null : event;
353
+ }
354
+ const safeRaw = (event as ReplayCaptureStreamEvent).publicRawText;
355
+ return { ...event, thinking: null, rawText: safeRaw ?? event.text };
356
+ }
357
+
358
+ /**
359
+ * Structural interface matched by every generative model wrapper
360
+ * (`Qwen35Model`, `Qwen35MoeModel`, `Lfm2Model`, `Gemma4Model`,
361
+ * `Qwen3Model`, and the Qianfan-OCR VLM wrapper). `ChatSession<M>` is
362
+ * generic over `M extends SessionCapableModel` so each session
363
+ * instance statically binds to a specific model's concrete type
364
+ * (handy for IDE autocomplete) while the implementation remains
365
+ * fully structural.
366
+ */
367
+ export interface SessionCapableModel {
368
+ /**
369
+ * Optional non-generating chat-template tokenizer. Exposed by
370
+ * wrappers that can count prompt tokens without running inference
371
+ * (used by Anthropic `/v1/messages/count_tokens`).
372
+ */
373
+ applyChatTemplate?(
374
+ messages: ChatMessage[],
375
+ addGenerationPrompt?: boolean | null,
376
+ tools?: ToolDefinition[] | null,
377
+ enableThinking?: boolean | null,
378
+ reasoningEffort?: string | null,
379
+ ): Promise<Uint32Array> | Uint32Array;
380
+ /**
381
+ * Optional model-native planner for prompt formats whose media placeholders
382
+ * expand after chat-template rendering. It returns the exact token length
383
+ * that inference will prefill, without mutating model/session state.
384
+ *
385
+ * Qwen3.5 dense/MoE implement this with their loaded image processor. Models
386
+ * without post-template expansion omit it and retain raw template counting.
387
+ */
388
+ expandedPromptTokenCount?(promptTokens: Uint32Array, messages: ChatMessage[]): Promise<number> | number;
389
+ /**
390
+ * Optional synchronous load-time snapshot of the model's usable context.
391
+ * Qwen3.5 dense/MoE and Muse-Glimmer expose this when paged-cache sizing is
392
+ * active.
393
+ */
394
+ contextLimits?(): SessionContextLimits;
395
+ /**
396
+ * Whether this loaded model instance has a complete image-input path.
397
+ *
398
+ * Optional so text-only and older wrappers continue to satisfy the
399
+ * structural contract. Supporting native wrappers snapshot this value after
400
+ * load, once the vision encoder/processor and any required cache backend are
401
+ * known to be available.
402
+ */
403
+ supportsImages?(): boolean;
404
+ /**
405
+ * Whether this bundled wrapper can capture full reasoning internally while
406
+ * returning a privacy-safe public result. Third-party implementations omit
407
+ * this capability and keep their original `includeReasoning` config.
408
+ */
409
+ supportsReplayReasoningCapture?(): boolean;
410
+ /**
411
+ * Whether this model's checkpoint template expects historical reasoning
412
+ * embedded in `message.content` instead of the structured
413
+ * `reasoningContent` field.
414
+ */
415
+ replaysAssistantRawText?(): boolean;
416
+ /**
417
+ * Non-streaming entry points accept the platform-native AbortSignal.
418
+ * The bundled wrappers translate it to the Rust atomic cancellation
419
+ * flag without exposing the native two-phase handle API.
420
+ */
421
+ chatSessionStart(messages: ChatMessage[], config?: ChatConfig | null, signal?: AbortSignal): Promise<ChatResult>;
422
+ chatSessionContinue(messages: ChatMessage[], config?: ChatConfig | null, signal?: AbortSignal): Promise<ChatResult>;
423
+ chatSessionContinueTool(
424
+ messages: ChatMessage[],
425
+ config?: ChatConfig | null,
426
+ signal?: AbortSignal,
427
+ ): Promise<ChatResult>;
428
+ /**
429
+ * The optional `signal` parameter on every streaming entry point is
430
+ * plumbed into the `_runChatStream` fast-abort path in the wrapper
431
+ * implementations. Callers that need client-disconnect-aware
432
+ * cancellation (e.g. HTTP endpoints flipping an AbortController on
433
+ * socket close) can attach one here and the native decode unwinds
434
+ * at the next safepoint via `ChatStreamHandle.cancel()`. Non-signal
435
+ * callers (the common direct-use path) just omit it.
436
+ */
437
+ chatStreamSessionStart(
438
+ messages: ChatMessage[],
439
+ config?: ChatConfig | null,
440
+ signal?: AbortSignal,
441
+ ): AsyncGenerator<ChatStreamEvent>;
442
+ chatStreamSessionContinue(
443
+ messages: ChatMessage[],
444
+ config?: ChatConfig | null,
445
+ signal?: AbortSignal,
446
+ ): AsyncGenerator<ChatStreamEvent>;
447
+ chatStreamSessionContinueTool(
448
+ messages: ChatMessage[],
449
+ config?: ChatConfig | null,
450
+ signal?: AbortSignal,
451
+ ): AsyncGenerator<ChatStreamEvent>;
452
+ /**
453
+ * Wipe the native KV caches and cached token history.
454
+ *
455
+ * Native models return a `Promise<void>` (the reset is dispatched
456
+ * onto the model thread's command queue and resolves once
457
+ * processed — H1a: a reset queued behind an in-flight turn must
458
+ * park a promise, never the Node event loop). The union keeps
459
+ * synchronous test doubles valid; every consumer in this file
460
+ * `await`s the result so command-queue ordering is preserved
461
+ * relative to subsequent session calls.
462
+ */
463
+ resetCaches(): void | Promise<void>;
464
+ /** Release scheduler-owned state for one logical session owner. */
465
+ releaseCacheOwner?(ownerId: string): void | Promise<void>;
466
+ /**
467
+ * Whether the underlying native model has the block-paged KV cache
468
+ * adapter (`PagedKVCacheAdapter` + `BlockAllocator` + `LayerKVPool`)
469
+ * active.
470
+ *
471
+ * `true` iff the adapter was successfully constructed at load time
472
+ * (driven by the per-model `use_block_paged_cache` config flag, which
473
+ * defaults to ON for Qwen3, LFM2, Gemma4, and Qwen3.5 dense/MoE after
474
+ * parity validation). Qwen3.5 VLM instances expose the loaded paged
475
+ * text lane while media/MTP requests retain their model-specific ordered
476
+ * execution path.
477
+ *
478
+ * When `true`, the native cache reuses SYS blocks across requests via
479
+ * content-addressing in the `BlockAllocator`'s prefix-hash table —
480
+ * the JS-side warm slot in
481
+ * `SessionRegistry.getOrCreateWarmAny(requestedSystem, cacheSalt)` becomes
482
+ * redundant for stateless `/v1/messages` traffic. The server
483
+ * endpoint reads this getter to decide whether to allocate a fresh
484
+ * `ChatSession` per request (paged-active) or to lease the warm slot
485
+ * (non-paged); see `packages/server/src/endpoints/messages.ts`.
486
+ *
487
+ * Optional on the structural interface so models that pre-date the
488
+ * NAPI getter (notably `QianfanOCRModel` from `@mlx-node/vlm`, which
489
+ * has no paged-adapter wiring) still satisfy the type contract — a
490
+ * missing getter is treated as `false` (not paged) by callers.
491
+ * Surfaced as a synchronous method on every native wrapper that DOES
492
+ * support paged so the routing decision in the server doesn't need a
493
+ * model-thread roundtrip per request — the value is captured at load
494
+ * time and never changes for a given model instance.
495
+ */
496
+ hasBlockPagedCache?(): boolean;
497
+ /**
498
+ * Maximum number of independent chat sequences this model can advance in
499
+ * one scheduler lane. Models without a continuous-batching scheduler omit
500
+ * the method and remain on the server's single-dispatch lane.
501
+ *
502
+ * The value is synchronous and fixed for normal server operation so the
503
+ * per-model admission semaphore can be constructed without a model-thread
504
+ * round trip. A value below two is treated as exclusive execution.
505
+ */
506
+ maxConcurrentSequences?(): number;
507
+ /**
508
+ * MTP: whether the underlying native model can run speculative
509
+ * decoding. Surfaced by `Qwen3_5Model` / `Qwen3_5MoeModel` (an MTP
510
+ * head shipped in the checkpoint, or an external DFlash2 companion on
511
+ * dense Qwen3.5) and by
512
+ * `Gemma4Model` (an external draft model — DSpark or Google gemma-4
513
+ * assistant, auto-detected from the draft's config.json — attached
514
+ * via `loadModel` / `loadSession` `draftModelPath`; NOT in-checkpoint
515
+ * MTP heads); all other native wrappers omit the method, in
516
+ * which case callers treat a missing getter as `false` (no MTP).
517
+ *
518
+ * When `true`, {@link ChatSession#mergeConfig} auto-defaults the
519
+ * per-request `enableMtp` flag to `true` — the speculative-decode
520
+ * path takes over unless the caller explicitly opts out by passing
521
+ * `enableMtp: false` in their `ChatConfig` overlay. When `false`
522
+ * (or the method is missing), `enableMtp` is left untouched.
523
+ *
524
+ * A pure CAPABILITY query: `true` means speculative decoding is available,
525
+ * not that it is a win. A family with a complete but unprofitable head
526
+ * suppresses the auto-default via {@link SessionCapableModel.mtpAutoEnabled}
527
+ * while still reporting `true` here.
528
+ *
529
+ * Synchronous on every supporting wrapper so the auto-default check
530
+ * doesn't need a model-thread roundtrip per call — the value is
531
+ * captured at load time and never changes for a given model
532
+ * instance.
533
+ *
534
+ * ## Companion `ChatConfig` knobs (only meaningful when `enableMtp` is on)
535
+ *
536
+ * Two related `ChatConfig` fields tune the speculative-decode loop.
537
+ * They are forwarded verbatim to the native side via
538
+ * {@link SendOptions.config} (per-call overlay) or
539
+ * {@link ChatSessionOptions.defaultConfig} (session default), and
540
+ * `mergeConfig` shallow-merges per-call over per-session so an
541
+ * explicit per-send value always wins over the session default for
542
+ * the same field.
543
+ *
544
+ * - **`mtpDepth`** — pins the MTP draft depth per speculative cycle.
545
+ * On Qwen3.5 native MTP heads it is clamped to `[1, 5]` by the
546
+ * verify FFI contract, and when unset native code currently pins
547
+ * depth 1. Setting `mtpDepth` explicitly pins that value unless
548
+ * the caller also passes `mtpAdaptiveDepth: true` to opt into
549
+ * adaptive depth with the supplied maximum/seed. External draft models
550
+ * resolve the field against their checkpoint width instead — see below.
551
+ * - **`mtpAdaptiveDepth`** — toggles the adaptive depth policy.
552
+ * Defaults to OFF for native MTP and assistants (Gemma4 DSpark has the
553
+ * family override documented below). When ON, the native-MTP default
554
+ * mode runs a 5-state machine
555
+ * (`Explore` → `Full` → {`NeighborProbe` | `Reduced` → `Probe`})
556
+ * with per-depth EMA tracking of
557
+ * `accepted_tokens / cycle_wall_ns` and picks the depth that
558
+ * maximizes that rate (DFlash-style, EMA decay α=0.3, drop-back
559
+ * threshold 0.75). `MLX_MTP_ADAPTIVE_DEPTH_MODE=expected-value`
560
+ * instead uses the MTPLX-style intra-cycle expected-value gate; by
561
+ * default it stops at its base depth, with deeper expansion kept
562
+ * research-only behind `MLX_MTP_EV_ALLOW_DEEPEN=1`.
563
+ * An explicit `false` always wins, pinning the chosen `mtpDepth` for
564
+ * every cycle. When `enableMtp` is false (or the model has no MTP
565
+ * head) the field is ignored.
566
+ *
567
+ * The defaults for both `mtpDepth` and `mtpAdaptiveDepth` are
568
+ * applied on the native side (see
569
+ * `crates/mlx-core/src/models/qwen3_5/chat_common.rs` MTP runtime
570
+ * flag inventory), so omitting them from `defaultConfig` /
571
+ * `SendOptions.config` is the recommended path for callers that
572
+ * just want speculative decoding "on with sensible defaults".
573
+ *
574
+ * ## Qwen3.8 DFlash2 (`draftModelPath`)
575
+ *
576
+ * A dense Qwen3.8 target can attach `z-lab/Qwen3.8-27B-DFlash2`. Its
577
+ * checkpoint block size is 8 total target rows: one anchor plus seven
578
+ * proposals. With `mtpDepth` unset all seven proposals are used; an
579
+ * explicit value clamps to `[1, 7]`. `mtpAdaptiveDepth` is off by default
580
+ * and may be enabled explicitly for the engine's measured AR fallback.
581
+ * DFlash2 uses flat target caches so hybrid GDN state can be rewound by
582
+ * snapshot plus tape replay after verification.
583
+ *
584
+ * ## Gemma4 external drafts (`draftModelPath`)
585
+ *
586
+ * Gemma4 reinterprets the knobs per draft variant (resolved in
587
+ * `gemma4/model.rs` `resolve_params`, always from the RAW config
588
+ * value — the engine's central `[1, 5]` clamp is an MTP-head
589
+ * contract that does not apply to external drafts):
590
+ *
591
+ * - **DSpark**: with both knobs unset, full draft blocks (the
592
+ * checkpoint's block size — 7 tokens on
593
+ * `dspark_gemma4_12b_block7`) run behind a short per-turn measurement
594
+ * against target-only AR. If DSpark loses on the current host/context,
595
+ * that turn permanently falls back to exact target-only decoding. An
596
+ * insufficiently long generation budget preserves the fixed-block path.
597
+ * An explicit `mtpDepth` acts as a CAP on the block (clamped to
598
+ * `[1, blockSize]`) and pins it unless `mtpAdaptiveDepth: true` opts the
599
+ * guard back in. Explicit `mtpAdaptiveDepth: false` disables the guard.
600
+ * - **Assistant** (Google `gemma-4-*-it-assistant`): chained AR
601
+ * drafting has no checkpoint-pinned block size — an unset
602
+ * `mtpDepth` drafts 3 tokens per cycle (`ASSISTANT_DEFAULT_DEPTH`,
603
+ * a quality/latency tradeoff, not a checkpoint contract), and an
604
+ * explicit `mtpDepth` clamps to `[1, 8]` (`ASSISTANT_MAX_DEPTH`).
605
+ *
606
+ * `mtpAdaptiveDepth` remains ignored for the assistant variant. Qwen3.5
607
+ * native-MTP semantics above are unchanged.
608
+ */
609
+ hasMtpWeights?(): boolean;
610
+ /**
611
+ * Whether {@link ChatSession#mergeConfig} should turn `enableMtp` ON for this
612
+ * model when the caller sets nothing. Separate from
613
+ * {@link SessionCapableModel.hasMtpWeights}, which stays a pure capability
614
+ * query — a family that routes `enableMtp` turns into an exclusive scheduler
615
+ * lane and out of continuous batching can be a net loss at default settings.
616
+ *
617
+ * Absent or `true` → auto-default whenever `hasMtpWeights()` is `true`, so
618
+ * every family predating this method behaves identically. `false` → leave
619
+ * `enableMtp` undefined. A DEFAULT, not a ban: an explicit `enableMtp: true`
620
+ * still enables speculation.
621
+ */
622
+ mtpAutoEnabled?(): boolean;
623
+ }
624
+
625
+ /** Per-call options for {@link ChatSession#send} / `sendStream`. */
626
+ export interface SendOptions {
627
+ /**
628
+ * Optional image bytes attached to this user turn. When the image
629
+ * set differs from the session's current `lastImagesKey`, the
630
+ * session forcibly restarts via `chatSessionStart`.
631
+ */
632
+ images?: Uint8Array[];
633
+ /**
634
+ * Optional audio bytes (encoded WAV) attached to this user turn. When
635
+ * the audio set differs from the session's current `lastAudioKey`, the
636
+ * session forcibly restarts via `chatSessionStart` (mirrors `images`).
637
+ * Only the unified Gemma 4 audio checkpoint consumes this.
638
+ */
639
+ audio?: Uint8Array[];
640
+ /**
641
+ * Per-call `ChatConfig` overlay applied on top of the session's
642
+ * `defaultConfig`. `reuseCache` is always forced on regardless of
643
+ * what the caller passes. The overlay is shallow-merged on top of
644
+ * the session default, so per-call values always win over per-session
645
+ * values for the same field — including the speculative-decode
646
+ * knobs `enableMtp`, `mtpDepth`, and `mtpAdaptiveDepth`. See
647
+ * {@link SessionCapableModel.hasMtpWeights} for the full MTP knob
648
+ * surface and default-resolution rules.
649
+ */
650
+ config?: ChatConfig;
651
+ /**
652
+ * Optional AbortSignal for client-disconnect-aware cancellation.
653
+ *
654
+ * Streaming entry points (`sendStream`, `sendToolResultStream`,
655
+ * `startFromHistoryStream`): the inner `_runChatStream` adapter wakes
656
+ * from `waitForItem()` on abort, calls `handle.cancel()` on the
657
+ * native handle, and unwinds the stream without throwing an
658
+ * AbortError — the outer consumer's `for await` just ends early.
659
+ *
660
+ * Non-streaming entry points (`send` / `sendToolResult` /
661
+ * `startFromHistory`) honor it too (H2): an already-aborted signal
662
+ * rejects before dispatch, and the bundled model wrappers translate a
663
+ * mid-turn abort to the native turn flag. The public method remains one
664
+ * ordinary Promise and rejects with `"chat session cancelled"`.
665
+ *
666
+ * Intended for HTTP endpoints that flip a controller on
667
+ * `res.once('close', …)` so client disconnect stops the native
668
+ * decode at the next safepoint rather than running it to completion
669
+ * under the per-model mutex.
670
+ */
671
+ signal?: AbortSignal;
672
+ }
673
+
674
+ /** Constructor options for {@link ChatSession}. */
675
+ export interface ChatSessionOptions {
676
+ /**
677
+ * Optional system prompt prepended as the first message on turn 1.
678
+ * Subsequent turns don't re-inject the system prompt — the cache
679
+ * already holds it.
680
+ */
681
+ system?: string;
682
+ /**
683
+ * Default `ChatConfig` applied to every `send()` / `sendStream()`
684
+ * / `sendToolResult()` call. Per-call config is shallow-merged on
685
+ * top of this, and `reuseCache` is forced on. Speculative-decode
686
+ * knobs (`enableMtp`, `mtpDepth`, `mtpAdaptiveDepth`) can be parked
687
+ * here as session-wide defaults and overridden per call via
688
+ * {@link SendOptions.config}; see
689
+ * {@link SessionCapableModel.hasMtpWeights} for the MTP knob surface
690
+ * and default-resolution rules.
691
+ */
692
+ defaultConfig?: ChatConfig;
693
+ }
694
+
695
+ /**
696
+ * SHA-256 byte-identity key for a length-framed list of byte buffers.
697
+ * Returns `null` for an empty/absent list so callers can distinguish
698
+ * "no media" from "media changed". Shared by the image and audio keys.
699
+ *
700
+ * Uses `node:crypto`'s native SHA-256 rather than a hand-rolled JS hash
701
+ * loop: hashing large image/audio buffers byte-at-a-time in JS is
702
+ * 25-60x slower than the native digest (measured: 5MB ~105ms JS loop
703
+ * vs ~1.8ms native) and this runs synchronously on the event loop
704
+ * before any `await` in `send()`/`sendStream()`, so the JS loop's cost
705
+ * was a real head-of-line-blocking stall for every other request
706
+ * handled by the same process.
707
+ *
708
+ * The key is order-sensitive: `[A, B]` and `[B, A]` produce different keys,
709
+ * matching the positional semantics of the underlying VLM chat template.
710
+ * Kept fully sync so `send()` can stay synchronous in its routing decision.
711
+ */
712
+ function computeByteListKey(buffers: Uint8Array[] | undefined): string | null {
713
+ if (!buffers || buffers.length === 0) return null;
714
+ const hash = createHash('sha256');
715
+ // Frame each buffer with a 4-byte little-endian length prefix (and a
716
+ // leading count prefix) so `[ab, c]` and `[a, bc]` — and different
717
+ // buffer counts — hash to distinct values.
718
+ const prefix = new Uint8Array(4);
719
+ const prefixView = new DataView(prefix.buffer);
720
+ prefixView.setUint32(0, buffers.length, true);
721
+ hash.update(prefix);
722
+ for (const buf of buffers) {
723
+ prefixView.setUint32(0, buf.byteLength, true);
724
+ hash.update(prefix);
725
+ hash.update(buf);
726
+ }
727
+ return hash.digest('hex');
728
+ }
729
+
730
+ /**
731
+ * Cross-model chat session. See module docstring for design notes.
732
+ *
733
+ * The generic parameter `M` statically captures the concrete model
734
+ * type so the structural interface stays as expressive as the
735
+ * concrete one. Internally the class only uses the
736
+ * `SessionCapableModel` surface.
737
+ */
738
+ export class ChatSession<M extends SessionCapableModel = SessionCapableModel> {
739
+ private readonly model: M;
740
+ private readonly system: string | undefined;
741
+ private readonly defaultConfig: ChatConfig;
742
+ /**
743
+ * Stable native cache/scheduler identity for this JS session.
744
+ *
745
+ * Direct HTTP callers do not carry the agent provider's cacheOwnerId. If
746
+ * they reach a paged model without an owner, native must conservatively use
747
+ * the legacy exclusive lane because sequence zero cannot represent two live
748
+ * requests. Give every ChatSession its own identity while still allowing an
749
+ * explicit provider identity to override it.
750
+ */
751
+ private cacheOwnerId: string | null = null;
752
+ /** The native owner used by this session, retained as a set for retryable release. */
753
+ private readonly nativeCacheOwnerIds = new Set<string>();
754
+ private disposed = false;
755
+ /** Tool definitions are conversation state for deterministic template replay. */
756
+ private activeTools: ToolDefinition[] | undefined;
757
+
758
+ /**
759
+ * Full conversation history tracked on the TS side. Appended to on
760
+ * every successful turn and sent on every role-aware native turn so
761
+ * the model-provided template remains the sole prompt authority.
762
+ */
763
+ private history: ChatMessage[] = [];
764
+
765
+ /**
766
+ * Hex-encoded byte-identity key of the image set currently bound
767
+ * to the server's KV cache (SHA-256; see `computeByteListKey`).
768
+ * `null` when no images are cached. A `send()` whose new key
769
+ * differs triggers a full `chatSessionStart` restart.
770
+ */
771
+ private lastImagesKey: string | null = null;
772
+
773
+ /**
774
+ * Hex-encoded byte-identity key of the audio set currently bound to the
775
+ * server's KV cache (see {@link computeByteListKey}). `null` when no audio is
776
+ * cached. A `send()` whose new key differs triggers a full
777
+ * `chatSessionStart` restart — the audio counterpart of `lastImagesKey`.
778
+ */
779
+ private lastAudioKey: string | null = null;
780
+
781
+ private turnCount = 0;
782
+ private inFlight = false;
783
+ /** A failed/abandoned native turn must be followed by a full replay. */
784
+ private needsFullReplay = false;
785
+
786
+ /**
787
+ * Count of `ok` tool calls emitted by the prior assistant turn, or
788
+ * `null` when the prior turn produced none. Gates every continuation
789
+ * entry point on the tool-call resolution invariant because each
790
+ * native `chat_session_continue*` dispatch re-opens the assistant
791
+ * turn:
792
+ *
793
+ * - A plain text `send` / `sendStream` after ANY outstanding tool
794
+ * call would orphan the call(s) by weaving a fresh user turn
795
+ * between the assistant's `tool_call` and any response.
796
+ * - A `sendToolResult` / `sendToolResultStream` is only servable
797
+ * when exactly one tool call is outstanding. A multi-call
798
+ * fan-out (`> 1`) cannot be resolved one result at a time — the
799
+ * siblings would be separated by fresh assistant replies — so
800
+ * those entry points also reject.
801
+ *
802
+ * Cleared on every successful commit whose new turn emits zero `ok`
803
+ * tool calls, and on `reset()`. See `assertCanSendPlain` /
804
+ * `assertCanSendToolResult` for the per-entry-point gate logic.
805
+ */
806
+ private unresolvedOkToolCallCount: number | null = null;
807
+
808
+ constructor(model: M, options: ChatSessionOptions = {}) {
809
+ this.model = model;
810
+ this.system = options.system;
811
+ this.defaultConfig = options.defaultConfig ?? {};
812
+ this.activeTools = this.defaultConfig.tools;
813
+ }
814
+
815
+ /**
816
+ * Number of completed turns. Increments only after a successful
817
+ * round-trip — in-flight or failed calls leave this untouched.
818
+ */
819
+ get turns(): number {
820
+ return this.turnCount;
821
+ }
822
+
823
+ /** Whether the session currently has images bound to its cache. */
824
+ get hasImages(): boolean {
825
+ return this.lastImagesKey !== null;
826
+ }
827
+
828
+ /** Load-time physical context snapshot, when exposed by the model. */
829
+ contextLimits(): SessionContextLimits | undefined {
830
+ return this.model.contextLimits?.();
831
+ }
832
+
833
+ /** Authoritative image-input capability of the loaded native model. */
834
+ supportsImages(): boolean {
835
+ return this.model.supportsImages?.() === true;
836
+ }
837
+
838
+ /**
839
+ * Render and validate a complete message list against the model's physical
840
+ * context window without starting inference or mutating session/native cache
841
+ * state.
842
+ *
843
+ * HTTP streaming callers use this before committing SSE headers so an
844
+ * oversized prompt can still receive a protocol-shaped 400 response without
845
+ * delaying those headers until image processing, prefill, or the first
846
+ * generated token. The returned config carries the same output-budget clamp
847
+ * applied by the send entry points; those entry points intentionally repeat
848
+ * the check against their own authoritative history before native dispatch.
849
+ */
850
+ async preflightContextCapacity(messages: readonly ChatMessage[], config?: ChatConfig): Promise<ChatConfig> {
851
+ if (this.inFlight) {
852
+ throw new Error('ChatSession: cannot preflight context capacity while a send() is in flight');
853
+ }
854
+ return await this.constrainToContextCapacity(messages.slice(), this.mergeConfig(config, false));
855
+ }
856
+
857
+ /**
858
+ * Capacity-preflight one pending user/tool message against this session's
859
+ * preserved history without starting inference or mutating cache state.
860
+ *
861
+ * This is the exact counterpart of the delta `send*` paths. It matters for
862
+ * server-side prompt-cache hits where the HTTP request contains only the new
863
+ * message while the leased ChatSession owns the earlier conversation.
864
+ */
865
+ async preflightPendingContextCapacity(pending: ChatMessage, config?: ChatConfig): Promise<ChatConfig> {
866
+ if (this.inFlight) {
867
+ throw new Error('ChatSession: cannot preflight pending context capacity while a send() is in flight');
868
+ }
869
+ if (pending.role === 'user') {
870
+ this.assertCanSendPlain('sendStream');
871
+ } else if (pending.role === 'tool') {
872
+ this.assertCanSendToolResult('sendToolResultStream');
873
+ } else {
874
+ throw new Error('ChatSession: pending context capacity preflight requires a user or tool message');
875
+ }
876
+ return await this.constrainToContextCapacity(this.historyWithPending(pending), this.mergeConfig(config, false));
877
+ }
878
+
879
+ /**
880
+ * Count of `ok` tool calls from the most recent assistant turn, or
881
+ * `null` when the trailing turn produced none. Non-null means the
882
+ * session is parked on an unresolved tool-call turn and the only
883
+ * forward-progress move is `sendToolResult*()` against one of the
884
+ * outstanding ids — and only when the count is exactly 1. A
885
+ * multi-call fan-out (`> 1`) cannot be served by the chat-session
886
+ * API at all; server endpoints should pre-check this getter and
887
+ * route around a fan-out via `reset()` + `primeHistory()` +
888
+ * `startFromHistory()` cold replay that resolves every sibling in
889
+ * one atomic jinja render.
890
+ *
891
+ * The flag updates after every successful `send` / `sendStream` /
892
+ * `sendToolResult` / `sendToolResultStream` / `startFromHistory*`
893
+ * commit, and after `primeHistory()` (from the trailing assistant
894
+ * message's `toolCalls.length`). `reset()` clears it.
895
+ */
896
+ get pendingUnresolvedToolCallCount(): number | null {
897
+ return this.unresolvedOkToolCallCount;
898
+ }
899
+
900
+ /**
901
+ * Send a user message and resolve with the assistant reply.
902
+ *
903
+ * Turn 0 and any turn whose image set changed dispatch through
904
+ * `chatSessionStart`. Later turns pass the same complete structured
905
+ * history through `chatSessionContinue`; native code renders the
906
+ * model template and reuses KV on an exact token-prefix match.
907
+ */
908
+ async send(userMessage: string, opts: SendOptions = {}): Promise<ChatResult> {
909
+ if (this.inFlight) {
910
+ throw new Error('ChatSession: concurrent send() not allowed; await the previous call first');
911
+ }
912
+ this.assertCanSendPlain('send');
913
+ this.inFlight = true;
914
+ try {
915
+ const mergedConfig = this.mergeConfig(opts.config);
916
+ const newImagesKey = computeByteListKey(opts.images);
917
+ const newAudioKey = computeByteListKey(opts.audio);
918
+ // Only an explicit NEW image/audio set can trigger a forced restart. Omitting
919
+ // `images`/`audio` (key === null) is interpreted as "keep the current
920
+ // media cache state" — the server-side cache already holds any prior
921
+ // media context, so a text-only follow-up like "what about the
922
+ // top-right?" can ask native code to verify/reuse the templated history.
923
+ const imageChanged = newImagesKey !== null && newImagesKey !== this.lastImagesKey;
924
+ const audioChanged = newAudioKey !== null && newAudioKey !== this.lastAudioKey;
925
+ const isFirstTurn = this.turnCount === 0;
926
+ const replayRequired = this.needsFullReplay;
927
+
928
+ if (isFirstTurn || imageChanged || audioChanged || replayRequired) {
929
+ return await this.runStartPath(
930
+ userMessage,
931
+ opts.images,
932
+ opts.audio,
933
+ imageChanged || audioChanged || replayRequired,
934
+ isFirstTurn,
935
+ mergedConfig,
936
+ opts.signal,
937
+ );
938
+ }
939
+
940
+ // Role-aware continuation: pass the complete structured transcript.
941
+ // Native code renders it with the checkpoint template and only reuses
942
+ // the live cache when the resulting tokens exactly extend that cache.
943
+ const pendingUser: ChatMessage = { role: 'user', content: userMessage };
944
+ const pendingHistory = this.historyWithPending(pendingUser);
945
+ const constrainedConfig = await this.constrainToContextCapacity(pendingHistory, mergedConfig);
946
+ let result: ChatResult;
947
+ try {
948
+ result = await this.runNonStreamingNative(
949
+ 'continue',
950
+ pendingHistory,
951
+ withReplayReasoning(constrainedConfig, this.model),
952
+ opts.signal,
953
+ );
954
+ } catch (err) {
955
+ if (!isMediaHeldRestartError(err)) {
956
+ this.needsFullReplay = true;
957
+ throw err;
958
+ }
959
+ // The native session holds media KV (gemma4 after an image/audio
960
+ // turn) and refused the continuation. Transparently replay the full
961
+ // conversation through the cold start path. The earlier media turn
962
+ // already lives in `this.history`, so the start path re-renders it;
963
+ // the trailing-media keys keep `lastImagesKey`/`lastAudioKey`
964
+ // consistent across the replay. The continuation path has NOT pushed
965
+ // `userMessage` yet, so `runStartPath` pushing it adds no duplicate.
966
+ return await this.runStartPath(userMessage, undefined, undefined, true, false, constrainedConfig, opts.signal);
967
+ }
968
+ this.history.push(pendingUser);
969
+ this.history.push(
970
+ buildAssistantMessage(
971
+ result.text,
972
+ result.toolCalls,
973
+ result.thinking,
974
+ result.thinkingEnabled,
975
+ result.rawText,
976
+ this.model.replaysAssistantRawText?.() === true,
977
+ ),
978
+ );
979
+ this.turnCount++;
980
+ this.commitActiveTools(constrainedConfig);
981
+ this.recordToolCallFanout(result.toolCalls);
982
+ return publicChatResult(result, constrainedConfig);
983
+ } finally {
984
+ this.inFlight = false;
985
+ }
986
+ }
987
+
988
+ /**
989
+ * Streaming variant of {@link ChatSession#send}.
990
+ *
991
+ * Routing matches `send()`. The assistant reply is accumulated
992
+ * from stream deltas and pushed to `history` only after a
993
+ * successful terminal chunk (`done: true` with non-error
994
+ * `finishReason`). Caller break, mid-stream exceptions, and error
995
+ * finishes all leave `turnCount` untouched and the history
996
+ * un-appended for the turn so the next call re-routes through the
997
+ * start path.
998
+ */
999
+ async *sendStream(userMessage: string, opts: SendOptions = {}): AsyncGenerator<ChatStreamEvent> {
1000
+ if (this.inFlight) {
1001
+ throw new Error('ChatSession: concurrent send() not allowed; await the previous call first');
1002
+ }
1003
+ this.assertCanSendPlain('sendStream');
1004
+ this.inFlight = true;
1005
+ try {
1006
+ const mergedConfig = this.mergeConfig(opts.config);
1007
+ const newImagesKey = computeByteListKey(opts.images);
1008
+ const newAudioKey = computeByteListKey(opts.audio);
1009
+ // Only an explicit NEW image/audio set can trigger a restart. Omitting
1010
+ // `images`/`audio` (key === null) is interpreted as "keep the current
1011
+ // media cache state" — the server-side cache already holds any prior
1012
+ // media context, so a text-only follow-up like "what about the
1013
+ // top-right?" can stay on the cheap delta path even after a media turn.
1014
+ const imageChanged = newImagesKey !== null && newImagesKey !== this.lastImagesKey;
1015
+ const audioChanged = newAudioKey !== null && newAudioKey !== this.lastAudioKey;
1016
+ const isFirstTurn = this.turnCount === 0;
1017
+ const replayRequired = this.needsFullReplay;
1018
+
1019
+ if (isFirstTurn || imageChanged || audioChanged || replayRequired) {
1020
+ yield* this.runStartStreamPath(
1021
+ userMessage,
1022
+ opts.images,
1023
+ opts.audio,
1024
+ imageChanged || audioChanged || replayRequired,
1025
+ isFirstTurn,
1026
+ mergedConfig,
1027
+ opts.signal,
1028
+ );
1029
+ return;
1030
+ }
1031
+
1032
+ // Delta continue stream: text-only.
1033
+ const pendingUser: ChatMessage = { role: 'user', content: userMessage };
1034
+ const pendingHistory = this.historyWithPending(pendingUser);
1035
+ const constrainedConfig = await this.constrainToContextCapacity(pendingHistory, mergedConfig);
1036
+ let sawFinal = false;
1037
+ let accumulated = '';
1038
+ let accumulatedVisible = '';
1039
+ let finalRaw: string | null = null;
1040
+ let finalReplayRaw: string | null = null;
1041
+ let finalTextAuthoritative: boolean | undefined;
1042
+ let finalToolCalls: readonly ToolCallResult[] | undefined;
1043
+ let finalThinking: string | null = null;
1044
+ let finalThinkingEnabled = false;
1045
+ // Set when the media-held rejection re-routes this turn through the
1046
+ // cold start stream. The replay path owns the history push, turnCount
1047
+ // increment, and media-key rehydration, so the commit `finally` below
1048
+ // must NOT also fire.
1049
+ let delegated = false;
1050
+ try {
1051
+ try {
1052
+ for await (const event of this.model.chatStreamSessionContinue(
1053
+ pendingHistory,
1054
+ withReplayReasoning(constrainedConfig, this.model),
1055
+ opts.signal,
1056
+ )) {
1057
+ if (event.done) {
1058
+ if (event.finishReason !== 'error') {
1059
+ sawFinal = true;
1060
+ finalRaw = event.text;
1061
+ finalReplayRaw = event.rawText;
1062
+ finalTextAuthoritative = (event as ReplayCaptureStreamEvent).textAuthoritative;
1063
+ finalToolCalls = event.toolCalls;
1064
+ finalThinking = event.thinking;
1065
+ finalThinkingEnabled = event.thinkingEnabled;
1066
+ }
1067
+ } else {
1068
+ accumulated += event.text;
1069
+ if (event.isReasoning !== true) {
1070
+ accumulatedVisible += event.text;
1071
+ }
1072
+ }
1073
+ const publicEvent = publicStreamEvent(event, constrainedConfig);
1074
+ if (publicEvent !== null) yield publicEvent;
1075
+ }
1076
+ } catch (err) {
1077
+ // The native session holds media KV (gemma4 after an image/audio
1078
+ // turn) and refused the text delta. The streaming bridge re-throws
1079
+ // that rejection on the first iteration, BEFORE any chunk is
1080
+ // emitted — the native guard fires ahead of any prefill, so
1081
+ // `!sawFinal && accumulated === ''` is guaranteed here. Replay the
1082
+ // full conversation through the cold start stream. Any non-prefix
1083
+ // error, or any error after tokens were already emitted, must
1084
+ // propagate unchanged.
1085
+ if (!isMediaHeldRestartError(err) || sawFinal || accumulated !== '') {
1086
+ throw err;
1087
+ }
1088
+ delegated = true;
1089
+ yield* this.runStartStreamPath(
1090
+ userMessage,
1091
+ undefined,
1092
+ undefined,
1093
+ true,
1094
+ false,
1095
+ constrainedConfig,
1096
+ opts.signal,
1097
+ );
1098
+ return;
1099
+ }
1100
+ } finally {
1101
+ // finally runs for normal completion, mid-stream throw,
1102
+ // caller `break` (which calls `iterator.return()` and
1103
+ // short-circuits the suspended yield), and error-finish
1104
+ // chunks alike. The delta path doesn't push to history until
1105
+ // commit, so the rollback branch is a no-op: nothing to
1106
+ // undo, and the native cache state is managed by the Rust
1107
+ // save_cache_state path on its own. When the media-held
1108
+ // rejection delegated to the replay stream, that path already
1109
+ // committed (or rolled back) — so this commit must stay off.
1110
+ if (sawFinal && !delegated) {
1111
+ this.history.push(pendingUser);
1112
+ this.history.push(
1113
+ buildAssistantMessage(
1114
+ selectCommittedStreamText(finalRaw, accumulatedVisible, finalTextAuthoritative),
1115
+ finalToolCalls,
1116
+ finalThinking,
1117
+ finalThinkingEnabled,
1118
+ finalReplayRaw,
1119
+ this.model.replaysAssistantRawText?.() === true,
1120
+ ),
1121
+ );
1122
+ this.turnCount++;
1123
+ this.commitActiveTools(constrainedConfig);
1124
+ this.recordToolCallFanout(finalToolCalls);
1125
+ } else if (!delegated) {
1126
+ // Qwen commits cancelled/failed delta tokens to its native cached
1127
+ // history even though this JS turn is intentionally uncommitted.
1128
+ // The next plain turn must reset and replay the preserved history.
1129
+ this.needsFullReplay = true;
1130
+ }
1131
+ }
1132
+ } finally {
1133
+ this.inFlight = false;
1134
+ }
1135
+ }
1136
+
1137
+ /**
1138
+ * Send a tool-result turn. The declaring assistant tool call and the
1139
+ * pending result are both included in the full history passed to
1140
+ * `chatSessionContinueTool`.
1141
+ *
1142
+ * Rejects if the prior assistant turn emitted more than one `ok`
1143
+ * tool call: the chat-session API only supports exactly one tool
1144
+ * call per assistant turn because each `sendToolResult` dispatch
1145
+ * immediately re-opens the assistant turn, so responding to the
1146
+ * remaining calls would interleave new assistant replies between
1147
+ * the results and corrupt the conversation structure. Callers that
1148
+ * hit this must tighten the prompt / tool spec or reset the
1149
+ * session.
1150
+ *
1151
+ * `isError` is the structured tool-error signal. When `true`, the
1152
+ * native renderer prepends a short, model-facing error marker to
1153
+ * `content` inside the wire-format tool block so the model
1154
+ * receives a clear text-level cue that the tool result represents
1155
+ * a failure. The structured field is stored verbatim on the
1156
+ * appended `{ role: 'tool', ... }` history entry so cold-replay
1157
+ * (image-change restart, `startFromHistory*`, server-side
1158
+ * `SessionRegistry` cache-miss rebuild) re-renders the marker
1159
+ * consistently with the live turn. Defaults to `undefined` (no
1160
+ * marker). Pass through verbatim — we do NOT infer error from
1161
+ * `content`.
1162
+ *
1163
+ * Appends a `{ role: 'tool', ... }` message to history on success.
1164
+ */
1165
+ async sendToolResult(
1166
+ toolCallId: string,
1167
+ content: string,
1168
+ opts: { isError?: boolean; config?: ChatConfig; signal?: AbortSignal } = {},
1169
+ ): Promise<ChatResult> {
1170
+ if (this.inFlight) {
1171
+ throw new Error('ChatSession: concurrent send() not allowed; await the previous call first');
1172
+ }
1173
+ this.assertCanSendToolResult('sendToolResult');
1174
+ this.inFlight = true;
1175
+ try {
1176
+ const { isError, config, signal } = opts;
1177
+ const mergedConfig = this.mergeConfig(config);
1178
+ const toolMsg: ChatMessage = {
1179
+ role: 'tool',
1180
+ content,
1181
+ toolCallId,
1182
+ isError,
1183
+ };
1184
+ const pendingHistory = this.historyWithPending(toolMsg);
1185
+ const constrainedConfig = await this.constrainToContextCapacity(pendingHistory, mergedConfig);
1186
+ // A cold native session (turnCount===0) has no live KV to delta
1187
+ // against — the typical cause is an interrupted media-held replay
1188
+ // whose rollback wiped the cache and reset the counter while
1189
+ // leaving the unresolved tool-call flag set. Mirror `send()`'s
1190
+ // turn-0 routing: replay the preserved history through the cold
1191
+ // start path instead of dispatching a delta that the native side
1192
+ // would reject with an un-prefixed "requires an initialized
1193
+ // session" error. A normal tool result always follows a prior
1194
+ // tool-call turn (turnCount>=1), so this never fires on the happy
1195
+ // path.
1196
+ if (this.turnCount === 0 || this.needsFullReplay) {
1197
+ return await this.replayToolResultThroughStartPath(toolMsg, constrainedConfig, signal);
1198
+ }
1199
+ try {
1200
+ const result = await this.runNonStreamingNative(
1201
+ 'continueTool',
1202
+ pendingHistory,
1203
+ withReplayReasoning(constrainedConfig, this.model),
1204
+ signal,
1205
+ );
1206
+ this.history.push({ role: 'tool', content, toolCallId, isError });
1207
+ this.history.push(
1208
+ buildAssistantMessage(
1209
+ result.text,
1210
+ result.toolCalls,
1211
+ result.thinking,
1212
+ result.thinkingEnabled,
1213
+ result.rawText,
1214
+ this.model.replaysAssistantRawText?.() === true,
1215
+ ),
1216
+ );
1217
+ this.turnCount++;
1218
+ this.commitActiveTools(constrainedConfig);
1219
+ this.recordToolCallFanout(result.toolCalls);
1220
+ return publicChatResult(result, constrainedConfig);
1221
+ } catch (err) {
1222
+ if (!isMediaHeldRestartError(err)) {
1223
+ this.needsFullReplay = true;
1224
+ throw err;
1225
+ }
1226
+ // The native session holds media KV (gemma4 after an image/audio
1227
+ // turn) and refused the tool-result delta. Transparently replay
1228
+ // the full conversation through the cold start path. The prior
1229
+ // media turn already lives in `this.history`, so the start path
1230
+ // re-renders it; the trailing-media keys keep
1231
+ // `lastImagesKey`/`lastAudioKey` consistent across the replay.
1232
+ // The delta path threw before pushing the tool message, so the
1233
+ // restart core pushes it — `isError` rides on that message so the
1234
+ // wire-format error marker is re-rendered, and a tool result
1235
+ // always follows >=1 prior turn so `isFirstTurn` is false.
1236
+ return await this.replayToolResultThroughStartPath(toolMsg, constrainedConfig, signal);
1237
+ }
1238
+ } finally {
1239
+ this.inFlight = false;
1240
+ }
1241
+ }
1242
+
1243
+ /**
1244
+ * Cold-replay a tool result through the start path: re-render the
1245
+ * full preserved history (including the prior media turn and the
1246
+ * unresolved tool-call assistant turn) plus this tool message. Used
1247
+ * when the native session is cold (turnCount===0 — e.g. after an
1248
+ * interrupted media-held replay rolled the cache back) and by the
1249
+ * media-held rejection catch. `mediaChanged=true` forces a
1250
+ * native cache invalidation so the prefill starts from a guaranteed-clean
1251
+ * owner; `isFirstTurn=false` because a tool result always follows a prior
1252
+ * tool-call turn.
1253
+ */
1254
+ private async replayToolResultThroughStartPath(
1255
+ toolMsg: ChatMessage,
1256
+ config: ChatConfig,
1257
+ signal?: AbortSignal,
1258
+ ): Promise<ChatResult> {
1259
+ return await this.runStartPathWithMessage(toolMsg, true, false, config, signal);
1260
+ }
1261
+
1262
+ /**
1263
+ * Streaming variant of {@link ChatSession#sendToolResult}.
1264
+ *
1265
+ * `isError` mirrors the non-streaming entry point — when `true`,
1266
+ * the native renderer prepends a short, model-facing error marker
1267
+ * to `content` inside the wire-format tool block. The structured
1268
+ * field is stored verbatim on the appended `{ role: 'tool', ... }`
1269
+ * history entry so cold-replay re-renders the marker consistently
1270
+ * with the live streaming turn.
1271
+ */
1272
+ async *sendToolResultStream(
1273
+ toolCallId: string,
1274
+ content: string,
1275
+ opts: { isError?: boolean; config?: ChatConfig; signal?: AbortSignal } = {},
1276
+ ): AsyncGenerator<ChatStreamEvent> {
1277
+ if (this.inFlight) {
1278
+ throw new Error('ChatSession: concurrent send() not allowed; await the previous call first');
1279
+ }
1280
+ this.assertCanSendToolResult('sendToolResultStream');
1281
+ this.inFlight = true;
1282
+ try {
1283
+ const { isError, config, signal } = opts;
1284
+ const mergedConfig = this.mergeConfig(config);
1285
+ const toolMsg: ChatMessage = {
1286
+ role: 'tool',
1287
+ content,
1288
+ toolCallId,
1289
+ isError,
1290
+ };
1291
+ const pendingHistory = this.historyWithPending(toolMsg);
1292
+ const constrainedConfig = await this.constrainToContextCapacity(pendingHistory, mergedConfig);
1293
+ // A cold native session (turnCount===0) has no live KV to delta
1294
+ // against — typically the residue of an interrupted media-held
1295
+ // replay whose rollback wiped the cache and reset the counter
1296
+ // while leaving the unresolved tool-call flag set. Mirror
1297
+ // `sendStream()`'s turn-0 routing: replay the preserved history
1298
+ // through the cold start stream and return before the
1299
+ // delta/commit machinery so the start path owns the history push,
1300
+ // turnCount increment, and media-key rehydration. A normal tool
1301
+ // result always follows a prior tool-call turn (turnCount>=1), so
1302
+ // this never fires on the happy path.
1303
+ if (this.turnCount === 0 || this.needsFullReplay) {
1304
+ yield* this.replayToolResultThroughStartStreamPath(toolMsg, constrainedConfig, signal);
1305
+ return;
1306
+ }
1307
+ let sawFinal = false;
1308
+ let accumulated = '';
1309
+ let accumulatedVisible = '';
1310
+ let finalRaw: string | null = null;
1311
+ let finalReplayRaw: string | null = null;
1312
+ let finalTextAuthoritative: boolean | undefined;
1313
+ let finalToolCalls: readonly ToolCallResult[] | undefined;
1314
+ let finalThinking: string | null = null;
1315
+ let finalThinkingEnabled = false;
1316
+ // Set when the media-held rejection re-routes this tool turn
1317
+ // through the cold start stream. The replay path owns the history
1318
+ // push, turnCount increment, and media-key rehydration, so the
1319
+ // commit `finally` below must NOT also fire.
1320
+ let delegated = false;
1321
+ try {
1322
+ try {
1323
+ for await (const event of this.model.chatStreamSessionContinueTool(
1324
+ pendingHistory,
1325
+ withReplayReasoning(constrainedConfig, this.model),
1326
+ signal,
1327
+ )) {
1328
+ if (event.done) {
1329
+ if (event.finishReason !== 'error') {
1330
+ sawFinal = true;
1331
+ finalRaw = event.text;
1332
+ finalReplayRaw = event.rawText;
1333
+ finalTextAuthoritative = (event as ReplayCaptureStreamEvent).textAuthoritative;
1334
+ finalToolCalls = event.toolCalls;
1335
+ finalThinking = event.thinking;
1336
+ finalThinkingEnabled = event.thinkingEnabled;
1337
+ }
1338
+ } else {
1339
+ accumulated += event.text;
1340
+ if (event.isReasoning !== true) {
1341
+ accumulatedVisible += event.text;
1342
+ }
1343
+ }
1344
+ const publicEvent = publicStreamEvent(event, constrainedConfig);
1345
+ if (publicEvent !== null) yield publicEvent;
1346
+ }
1347
+ } catch (err) {
1348
+ // The native session holds media KV (gemma4 after an image/audio
1349
+ // turn) and refused the tool-result delta. The streaming bridge
1350
+ // re-throws that rejection on the first iteration, BEFORE any
1351
+ // chunk is emitted — the native guard fires ahead of any
1352
+ // prefill, so `!sawFinal && accumulated === ''` is guaranteed
1353
+ // here. Replay the full conversation through the cold start
1354
+ // stream with the pending tool message; `isError` rides on it so
1355
+ // the wire-format error marker is re-rendered. Any non-prefix
1356
+ // error, or any error after tokens were already emitted, must
1357
+ // propagate unchanged.
1358
+ if (!isMediaHeldRestartError(err) || sawFinal || accumulated !== '') {
1359
+ throw err;
1360
+ }
1361
+ delegated = true;
1362
+ yield* this.replayToolResultThroughStartStreamPath(toolMsg, constrainedConfig, signal);
1363
+ return;
1364
+ }
1365
+ } finally {
1366
+ // finally runs for normal completion, mid-stream throw,
1367
+ // caller `break` (iterator.return() short-circuits the yield),
1368
+ // and error-finish chunks alike. Tool turns never touch
1369
+ // history until commit, so the rollback branch is a no-op. When
1370
+ // the media-held rejection delegated to the replay stream, that
1371
+ // path already committed (or rolled back), so this commit stays
1372
+ // off.
1373
+ if (sawFinal && !delegated) {
1374
+ this.history.push({ role: 'tool', content, toolCallId, isError });
1375
+ this.history.push(
1376
+ buildAssistantMessage(
1377
+ selectCommittedStreamText(finalRaw, accumulatedVisible, finalTextAuthoritative),
1378
+ finalToolCalls,
1379
+ finalThinking,
1380
+ finalThinkingEnabled,
1381
+ finalReplayRaw,
1382
+ this.model.replaysAssistantRawText?.() === true,
1383
+ ),
1384
+ );
1385
+ this.turnCount++;
1386
+ this.commitActiveTools(constrainedConfig);
1387
+ this.recordToolCallFanout(finalToolCalls);
1388
+ } else if (!delegated) {
1389
+ this.needsFullReplay = true;
1390
+ }
1391
+ }
1392
+ } finally {
1393
+ this.inFlight = false;
1394
+ }
1395
+ }
1396
+
1397
+ /**
1398
+ * Streaming counterpart of {@link replayToolResultThroughStartPath}:
1399
+ * cold-replay a tool result through the start stream. Used by the
1400
+ * turn-0 precheck and the media-held rejection catch in
1401
+ * {@link sendToolResultStream}. The start stream owns the history
1402
+ * push, turnCount increment, and media-key rehydration; callers keep
1403
+ * `delegated`/early-return semantics so the commit `finally` stays
1404
+ * off.
1405
+ */
1406
+ private async *replayToolResultThroughStartStreamPath(
1407
+ toolMsg: ChatMessage,
1408
+ config: ChatConfig,
1409
+ signal: AbortSignal | undefined,
1410
+ ): AsyncGenerator<ChatStreamEvent> {
1411
+ yield* this.runStartStreamPathWithMessage(toolMsg, true, false, config, signal);
1412
+ }
1413
+
1414
+ /**
1415
+ * Reset the session state.
1416
+ *
1417
+ * Invalidates this session's native KV state and wipes local history,
1418
+ * media keys, and turn counter so the next `send()` goes through
1419
+ * `chatSessionStart` again. Block-paged models release only this stable
1420
+ * session owner; clearing the model-wide scheduler would invalidate every
1421
+ * other live `ChatSession`. Exclusive/flat models retain the model-wide
1422
+ * `resetCaches()` barrier because they have no independently releasable
1423
+ * owner state.
1424
+ *
1425
+ * Native invalidation is async: the release/reset command is awaited so it
1426
+ * has fully drained through the model thread before this promise resolves —
1427
+ * callers that `await reset()` and
1428
+ * then start a turn keep strict command-queue ordering. Because the
1429
+ * await genuinely suspends, `reset()` RESERVES the same in-flight
1430
+ * guard as the send entry points for its whole duration: any
1431
+ * concurrent `send*()`, `reset()`, `primeHistory()`, or
1432
+ * `startFromHistory*()` on this session rejects until the reset
1433
+ * settles, so a racing turn can never commit against the pre-wipe
1434
+ * history (or have its state erased mid-commit). If native invalidation
1435
+ * rejects, no JS state is wiped and the guard is released.
1436
+ */
1437
+ async reset(): Promise<void> {
1438
+ if (this.disposed) {
1439
+ throw new Error('ChatSession: session has been disposed');
1440
+ }
1441
+ if (this.inFlight) {
1442
+ throw new Error('ChatSession: cannot reset() while a send() is in flight; await the previous call first');
1443
+ }
1444
+ this.inFlight = true;
1445
+ try {
1446
+ if (this.model.hasBlockPagedCache?.() === true && this.model.releaseCacheOwner) {
1447
+ for (const ownerId of Array.from(this.nativeCacheOwnerIds)) {
1448
+ await this.model.releaseCacheOwner(ownerId);
1449
+ this.nativeCacheOwnerIds.delete(ownerId);
1450
+ }
1451
+ } else {
1452
+ await this.model.resetCaches();
1453
+ this.nativeCacheOwnerIds.clear();
1454
+ }
1455
+ this.history = [];
1456
+ this.lastImagesKey = null;
1457
+ this.lastAudioKey = null;
1458
+ this.turnCount = 0;
1459
+ this.unresolvedOkToolCallCount = null;
1460
+ this.needsFullReplay = false;
1461
+ this.activeTools = this.defaultConfig.tools;
1462
+ } finally {
1463
+ this.inFlight = false;
1464
+ }
1465
+ }
1466
+
1467
+ /**
1468
+ * Permanently dispose this JS session and release its native scheduler
1469
+ * owner. The operation is awaited and idempotent; it rejects while
1470
+ * a turn is in flight so native state cannot be torn down mid-decode.
1471
+ *
1472
+ * A caller-supplied first `cacheOwnerId` becomes this session's stable owner
1473
+ * just like the generated default. Do not share an explicit owner id between
1474
+ * independently disposed sessions: disposing either session releases that
1475
+ * owner's native scheduler state for both.
1476
+ */
1477
+ async dispose(): Promise<void> {
1478
+ if (this.disposed) return;
1479
+ if (this.inFlight) {
1480
+ throw new Error('ChatSession: cannot dispose() while a send() is in flight; await the previous call first');
1481
+ }
1482
+ this.inFlight = true;
1483
+ try {
1484
+ let firstReleaseError: unknown;
1485
+ let hadReleaseError = false;
1486
+ if (this.model.releaseCacheOwner) {
1487
+ for (const ownerId of Array.from(this.nativeCacheOwnerIds)) {
1488
+ try {
1489
+ await this.model.releaseCacheOwner(ownerId);
1490
+ this.nativeCacheOwnerIds.delete(ownerId);
1491
+ } catch (error) {
1492
+ if (!hadReleaseError) firstReleaseError = error;
1493
+ hadReleaseError = true;
1494
+ }
1495
+ }
1496
+ } else {
1497
+ this.nativeCacheOwnerIds.clear();
1498
+ }
1499
+ if (hadReleaseError) throw firstReleaseError;
1500
+ this.disposed = true;
1501
+ this.history = [];
1502
+ this.lastImagesKey = null;
1503
+ this.lastAudioKey = null;
1504
+ this.turnCount = 0;
1505
+ this.unresolvedOkToolCallCount = null;
1506
+ this.needsFullReplay = false;
1507
+ } finally {
1508
+ this.inFlight = false;
1509
+ }
1510
+ }
1511
+
1512
+ /**
1513
+ * Prime the session history without running inference.
1514
+ *
1515
+ * Used by the server-side `SessionRegistry` cold-start fallback: when
1516
+ * a request arrives with a `previous_response_id` that the cache has
1517
+ * missed, the endpoint reconstructs the full conversation from the
1518
+ * `ResponseStore` and primes a fresh session with it, then calls
1519
+ * `startFromHistory()` to replay it through the native KV cache.
1520
+ *
1521
+ * Rejects if the session is in flight or has already taken a turn.
1522
+ * Replaces the internal history with a shallow copy of `messages`.
1523
+ */
1524
+ primeHistory(messages: ChatMessage[]): void {
1525
+ if (this.inFlight) {
1526
+ throw new Error('ChatSession: cannot primeHistory() while a send() is in flight');
1527
+ }
1528
+ if (this.turnCount > 0) {
1529
+ throw new Error('ChatSession: primeHistory() can only be called on a fresh session (turn 0)');
1530
+ }
1531
+ this.history = messages.slice();
1532
+ // Derive the unresolved-tool-call guard from the trailing assistant
1533
+ // turn in the primed history so an immediately-post-prime session
1534
+ // exposes the same `pendingUnresolvedToolCallCount` state a live
1535
+ // session would have been in at that point of the conversation.
1536
+ // This lets the server endpoint layer (and any other caller)
1537
+ // pre-check the guard before starting cold replay and route around
1538
+ // unresolved turns instead of letting `startFromHistory*()` blindly
1539
+ // advance past them. The flag is reset on commit in both sync and
1540
+ // streaming start-from-history paths based on the new assistant
1541
+ // reply, which is the correct semantics for the post-replay current
1542
+ // position.
1543
+ this.unresolvedOkToolCallCount = this.computeTrailingAssistantUnresolvedToolCallCount();
1544
+ // lastImagesKey stays null until startFromHistory() / send() runs —
1545
+ // the trailing-images hydration happens at commit time.
1546
+ }
1547
+
1548
+ /**
1549
+ * Run a cold-start `chatSessionStart` using the currently primed
1550
+ * history.
1551
+ *
1552
+ * Intended pairing with {@link primeHistory}: call
1553
+ * `primeHistory(fullHistory)` first, then `startFromHistory()` to
1554
+ * replay the conversation through the native chat-session API. The
1555
+ * final history entry must be a user or tool turn — this is what the
1556
+ * native side treats as the "current input" to generate against.
1557
+ *
1558
+ * Pushes the assistant reply onto the history, advances `turnCount`
1559
+ * to 1, and computes `lastImagesKey` from the most recent user
1560
+ * message that carries images (so subsequent text-only continues
1561
+ * stay on the delta path, and subsequent image turns correctly
1562
+ * trigger restart).
1563
+ */
1564
+ async startFromHistory(config?: ChatConfig, opts: { signal?: AbortSignal } = {}): Promise<ChatResult> {
1565
+ if (this.inFlight) {
1566
+ throw new Error('ChatSession: cannot startFromHistory() while a send() is in flight');
1567
+ }
1568
+ if (this.turnCount > 0) {
1569
+ throw new Error('ChatSession: startFromHistory() can only be called on a fresh session');
1570
+ }
1571
+ if (this.history.length === 0) {
1572
+ throw new Error('ChatSession: startFromHistory() requires a primed history');
1573
+ }
1574
+ this.inFlight = true;
1575
+ try {
1576
+ const mergedConfig = this.mergeConfig(config);
1577
+ const historySnapshot = this.history.slice();
1578
+ const constrainedConfig = await this.constrainToContextCapacity(historySnapshot, mergedConfig);
1579
+ const result = await this.runNonStreamingNative(
1580
+ 'start',
1581
+ historySnapshot,
1582
+ withReplayReasoning(constrainedConfig, this.model),
1583
+ opts.signal,
1584
+ );
1585
+ this.history.push(
1586
+ buildAssistantMessage(
1587
+ result.text,
1588
+ result.toolCalls,
1589
+ result.thinking,
1590
+ result.thinkingEnabled,
1591
+ result.rawText,
1592
+ this.model.replaysAssistantRawText?.() === true,
1593
+ ),
1594
+ );
1595
+ this.turnCount++;
1596
+ this.needsFullReplay = false;
1597
+ this.lastImagesKey = this.computeTrailingImagesKey();
1598
+ this.lastAudioKey = this.computeTrailingAudioKey();
1599
+ this.commitActiveTools(constrainedConfig);
1600
+ this.recordToolCallFanout(result.toolCalls);
1601
+ return publicChatResult(result, constrainedConfig);
1602
+ } finally {
1603
+ this.inFlight = false;
1604
+ }
1605
+ }
1606
+
1607
+ /**
1608
+ * Streaming counterpart to {@link startFromHistory}.
1609
+ *
1610
+ * Iterates `model.chatStreamSessionStart(history.slice(), config)`,
1611
+ * accumulates text, and only commits history + `turnCount` +
1612
+ * `lastImagesKey` in the `finally` block when a successful terminal
1613
+ * chunk was observed (`done: true` with non-error finishReason).
1614
+ * Because history is primed (not appended to), rollback on failure
1615
+ * is a no-op: the primed state stays intact so the caller can retry.
1616
+ */
1617
+ async *startFromHistoryStream(config?: ChatConfig, signal?: AbortSignal): AsyncGenerator<ChatStreamEvent> {
1618
+ if (this.inFlight) {
1619
+ throw new Error('ChatSession: cannot startFromHistoryStream() while a send() is in flight');
1620
+ }
1621
+ if (this.turnCount > 0) {
1622
+ throw new Error('ChatSession: startFromHistoryStream() can only be called on a fresh session');
1623
+ }
1624
+ if (this.history.length === 0) {
1625
+ throw new Error('ChatSession: startFromHistoryStream() requires a primed history');
1626
+ }
1627
+ this.inFlight = true;
1628
+ try {
1629
+ const mergedConfig = this.mergeConfig(config);
1630
+ const historySnapshot = this.history.slice();
1631
+ const constrainedConfig = await this.constrainToContextCapacity(historySnapshot, mergedConfig);
1632
+ let sawFinal = false;
1633
+ let accumulated = '';
1634
+ let accumulatedVisible = '';
1635
+ let finalRaw: string | null = null;
1636
+ let finalReplayRaw: string | null = null;
1637
+ let finalTextAuthoritative: boolean | undefined;
1638
+ let finalToolCalls: readonly ToolCallResult[] | undefined;
1639
+ let finalThinking: string | null = null;
1640
+ let finalThinkingEnabled = false;
1641
+ try {
1642
+ for await (const event of this.model.chatStreamSessionStart(
1643
+ historySnapshot,
1644
+ withReplayReasoning(constrainedConfig, this.model),
1645
+ signal,
1646
+ )) {
1647
+ if (event.done) {
1648
+ if (event.finishReason !== 'error') {
1649
+ sawFinal = true;
1650
+ finalRaw = event.text;
1651
+ finalReplayRaw = event.rawText;
1652
+ finalTextAuthoritative = (event as ReplayCaptureStreamEvent).textAuthoritative;
1653
+ finalToolCalls = event.toolCalls;
1654
+ finalThinking = event.thinking;
1655
+ finalThinkingEnabled = event.thinkingEnabled;
1656
+ }
1657
+ } else {
1658
+ accumulated += event.text;
1659
+ if (event.isReasoning !== true) {
1660
+ accumulatedVisible += event.text;
1661
+ }
1662
+ }
1663
+ const publicEvent = publicStreamEvent(event, constrainedConfig);
1664
+ if (publicEvent !== null) yield publicEvent;
1665
+ }
1666
+ } finally {
1667
+ // finally runs on normal completion, mid-stream throw, caller
1668
+ // `break` (iterator.return() short-circuits the yield), and
1669
+ // error-finish chunks alike. The primed history is only
1670
+ // mutated on a successful commit — on any non-success exit,
1671
+ // the primed state is left intact so the caller can retry.
1672
+ if (sawFinal) {
1673
+ this.history.push(
1674
+ buildAssistantMessage(
1675
+ selectCommittedStreamText(finalRaw, accumulatedVisible, finalTextAuthoritative),
1676
+ finalToolCalls,
1677
+ finalThinking,
1678
+ finalThinkingEnabled,
1679
+ finalReplayRaw,
1680
+ this.model.replaysAssistantRawText?.() === true,
1681
+ ),
1682
+ );
1683
+ this.turnCount++;
1684
+ this.needsFullReplay = false;
1685
+ this.lastImagesKey = this.computeTrailingImagesKey();
1686
+ this.lastAudioKey = this.computeTrailingAudioKey();
1687
+ this.commitActiveTools(constrainedConfig);
1688
+ this.recordToolCallFanout(finalToolCalls);
1689
+ }
1690
+ }
1691
+ } finally {
1692
+ this.inFlight = false;
1693
+ }
1694
+ }
1695
+
1696
+ // -------------------------------------------------------------------
1697
+ // Internal helpers
1698
+ // -------------------------------------------------------------------
1699
+
1700
+ /**
1701
+ * Dispatch one non-streaming native turn through the public AbortSignal
1702
+ * surface (H2). The model wrapper owns the attach-race-safe translation
1703
+ * from AbortSignal to the lower-level native operation handle; ChatSession
1704
+ * never exposes that two-phase primitive to callers.
1705
+ *
1706
+ * Callers sit inside the entry points' existing try/finally blocks,
1707
+ * so a rejection here releases `inFlight` and (on the delta paths)
1708
+ * sets `needsFullReplay` exactly like any other native failure.
1709
+ */
1710
+ private async runNonStreamingNative(
1711
+ kind: 'start' | 'continue' | 'continueTool',
1712
+ messages: ChatMessage[],
1713
+ config: ChatConfig,
1714
+ signal: AbortSignal | undefined,
1715
+ ): Promise<ChatResult> {
1716
+ const model = this.model;
1717
+ if (signal?.aborted === true) throw new Error('chat session cancelled');
1718
+ if (kind === 'start') {
1719
+ return signal == null
1720
+ ? await model.chatSessionStart(messages, config)
1721
+ : await model.chatSessionStart(messages, config, signal);
1722
+ }
1723
+ if (kind === 'continue') {
1724
+ return signal == null
1725
+ ? await model.chatSessionContinue(messages, config)
1726
+ : await model.chatSessionContinue(messages, config, signal);
1727
+ }
1728
+ return signal == null
1729
+ ? await model.chatSessionContinueTool(messages, config)
1730
+ : await model.chatSessionContinueTool(messages, config, signal);
1731
+ }
1732
+
1733
+ /**
1734
+ * Gate plain-text continuation entry points (`send`, `sendStream`)
1735
+ * on the tool-call resolution invariant. Any outstanding `ok` tool
1736
+ * call from the prior assistant turn — single or multi — makes a
1737
+ * plain text continuation unsafe: the native chat-session API
1738
+ * re-opens the assistant turn on each continue, so a new user delta
1739
+ * would weave a fresh user message between the assistant's
1740
+ * `tool_call` and any response, orphaning the call. Callers must
1741
+ * resolve outstanding calls via `sendToolResult*()` (single-call
1742
+ * case) or re-enter via `reset()` + `primeHistory()` +
1743
+ * `startFromHistory()` with a resolved conversation (multi-call
1744
+ * fan-out). `reset()` clears the flag and `startFromHistory*`
1745
+ * overwrites it via `recordToolCallFanout` on the new response, so
1746
+ * legitimate recovery paths are unaffected.
1747
+ */
1748
+ private assertCanSendPlain(entryPoint: string): void {
1749
+ const n = this.unresolvedOkToolCallCount;
1750
+ if (n !== null) {
1751
+ const plural = n === 1 ? '' : 's';
1752
+ const followUp =
1753
+ n > 1
1754
+ ? `multi-call fan-outs cannot be served one result at a time — re-enter through reset() + primeHistory() + startFromHistory() with a conversation that resolves every sibling in one atomic replay`
1755
+ : `resolve the outstanding call via sendToolResult()`;
1756
+ throw new Error(
1757
+ `ChatSession.${entryPoint}: previous assistant turn has ${n} unresolved ok tool call${plural}; ` +
1758
+ `a plain text continuation would orphan the call${plural} by weaving a new user turn between the ` +
1759
+ `assistant's tool_call and any response. ${followUp}, reset() the session, or re-enter through ` +
1760
+ `primeHistory() + startFromHistory() with a resolved conversation.`,
1761
+ );
1762
+ }
1763
+ }
1764
+
1765
+ /**
1766
+ * Gate tool-result entry points (`sendToolResult`,
1767
+ * `sendToolResultStream`) on the single-tool-call-per-turn
1768
+ * invariant. Exactly one outstanding tool call is servable — that
1769
+ * is the case these methods exist for.
1770
+ *
1771
+ * Zero outstanding calls (`null`) is also unservable: without a
1772
+ * preceding assistant turn that emitted a tool call, a tool-result
1773
+ * dispatch would synthesize a `<tool_response>` delta for a call
1774
+ * that never existed, corrupting the conversation structure. The
1775
+ * native backends do not authenticate `tool_call_id` against prior
1776
+ * state — several simply append the tool-response delta verbatim —
1777
+ * so rejecting here is the only gate that prevents forged tool
1778
+ * state from reaching the model. Callers that want to start a
1779
+ * conversation on a resolved tool turn must prime an unresolved
1780
+ * single-call assistant turn via `primeHistory()` +
1781
+ * `startFromHistory()` first.
1782
+ *
1783
+ * A multi-call fan-out (`> 1`) cannot be resolved one result at a
1784
+ * time because each `sendToolResult` dispatch immediately re-opens
1785
+ * the assistant turn, so responding to the siblings would
1786
+ * interleave new assistant replies between the results.
1787
+ */
1788
+ private assertCanSendToolResult(entryPoint: string): void {
1789
+ const n = this.unresolvedOkToolCallCount;
1790
+ if (n === null) {
1791
+ throw new Error(
1792
+ `ChatSession.${entryPoint}: no outstanding ok tool call on the previous assistant turn. ` +
1793
+ `Tool-result entry points can only be called when the model has just emitted exactly one ` +
1794
+ `ok tool call that has not yet been resolved — dispatching a tool result against an empty ` +
1795
+ `or already-resolved turn would synthesize a <tool_response> delta for a call that never ` +
1796
+ `existed and corrupt the conversation structure. Call send() / sendStream() for plain user ` +
1797
+ `turns, or re-enter through primeHistory() + startFromHistory() with a conversation that ` +
1798
+ `ends on an unresolved single-call assistant turn.`,
1799
+ );
1800
+ }
1801
+ if (n > 1) {
1802
+ throw new Error(
1803
+ `ChatSession.${entryPoint}: previous assistant turn emitted ${n} ok tool calls; ` +
1804
+ `the chat-session API only supports exactly one tool call per assistant turn because each tool-result ` +
1805
+ `call immediately re-opens the assistant turn — responding to the siblings would interleave new assistant ` +
1806
+ `replies between the results. Tighten the prompt / tool spec so the model produces at most one call per ` +
1807
+ `turn, reset() the session, or re-enter through primeHistory() + startFromHistory() with a resolved ` +
1808
+ `conversation.`,
1809
+ );
1810
+ }
1811
+ }
1812
+
1813
+ /**
1814
+ * Inspect a just-committed turn's tool calls and store the count of
1815
+ * `ok` entries in `unresolvedOkToolCallCount`. Any non-zero count
1816
+ * parks the session on an unresolved tool-call turn, which gates
1817
+ * the next entry point:
1818
+ *
1819
+ * - count === 0 → flag is `null`: `send`/`sendStream` ok,
1820
+ * `sendToolResult*` throws (no outstanding call to resolve)
1821
+ * - count === 1 → `send`/`sendStream` throw; `sendToolResult*` ok
1822
+ * - count > 1 → every entry point throws (fan-out unservable)
1823
+ *
1824
+ * See `assertCanSendPlain` / `assertCanSendToolResult` for the full
1825
+ * rationale.
1826
+ */
1827
+ private recordToolCallFanout(toolCalls: readonly ToolCallResult[] | undefined): void {
1828
+ const n = countOkToolCalls(toolCalls);
1829
+ this.unresolvedOkToolCallCount = n > 0 ? n : null;
1830
+ }
1831
+
1832
+ /**
1833
+ * Persist the effective tools only when their turn commits successfully.
1834
+ * Preflights and failed/abandoned turns intentionally never call this.
1835
+ */
1836
+ private commitActiveTools(config: ChatConfig): void {
1837
+ if (config.tools !== undefined) {
1838
+ this.activeTools = config.tools;
1839
+ }
1840
+ }
1841
+
1842
+ /**
1843
+ * Merge default + per-call config and force `reuseCache: true`.
1844
+ * The session path is a session-reuse operation by construction —
1845
+ * `reuseCache: false` on the continue path would wipe the very
1846
+ * cache the delta depends on.
1847
+ *
1848
+ * Tool resolution here is side-effect free because public capacity
1849
+ * preflights use this same merge path with owner establishment disabled.
1850
+ * Successful turn commit sites call {@link commitActiveTools} after native
1851
+ * inference finishes.
1852
+ *
1853
+ * MTP auto-default: if neither `defaultConfig` nor `overlay`
1854
+ * sets `enableMtp` AND the underlying model exposes
1855
+ * `hasMtpWeights()` returning `true` AND
1856
+ * {@link ChatSession#mtpAutoDefaultAllowed} agrees, set
1857
+ * `enableMtp = true` so the speculative-decode path runs out of the
1858
+ * box on MTP-capable checkpoints. An explicit `false` from either
1859
+ * source wins (the undefined-check below preserves it). This
1860
+ * duck-typed check also covers Gemma4 with an external draft
1861
+ * attached — DSpark or Google assistant (`hasMtpWeights()` reports
1862
+ * the external draft there, not in-checkpoint MTP heads).
1863
+ */
1864
+ private mergeConfig(overlay: ChatConfig | undefined, establishCacheOwner = true): ChatConfig {
1865
+ if (this.disposed) {
1866
+ throw new Error('ChatSession: session has been disposed');
1867
+ }
1868
+ const merged: ChatConfig = {
1869
+ ...this.defaultConfig,
1870
+ ...overlay,
1871
+ reuseCache: true,
1872
+ };
1873
+ const requestedOwnerId = merged.cacheOwnerId;
1874
+ if (this.cacheOwnerId === null) {
1875
+ if (establishCacheOwner) {
1876
+ this.cacheOwnerId = requestedOwnerId === undefined || requestedOwnerId === '' ? randomUUID() : requestedOwnerId;
1877
+ merged.cacheOwnerId = this.cacheOwnerId;
1878
+ this.nativeCacheOwnerIds.add(this.cacheOwnerId);
1879
+ }
1880
+ } else if (requestedOwnerId !== undefined && requestedOwnerId !== '' && requestedOwnerId !== this.cacheOwnerId) {
1881
+ throw new Error(
1882
+ `ChatSession: cacheOwnerId cannot change after the session owner is established; create a new ChatSession for owner ${requestedOwnerId}`,
1883
+ );
1884
+ } else {
1885
+ merged.cacheOwnerId = this.cacheOwnerId;
1886
+ if (establishCacheOwner) this.nativeCacheOwnerIds.add(this.cacheOwnerId);
1887
+ }
1888
+ // Tools are part of the committed conversation state. Constructor
1889
+ // defaults seed that state, but must not overwrite a tool set committed by
1890
+ // a later successful turn. A current-call overlay is the only higher
1891
+ // precedence source; it remains provisional until a success site calls
1892
+ // commitActiveTools().
1893
+ if (overlay?.tools === undefined && this.activeTools !== undefined) {
1894
+ merged.tools = this.activeTools;
1895
+ }
1896
+ if (
1897
+ merged.enableMtp === undefined &&
1898
+ typeof this.model.hasMtpWeights === 'function' &&
1899
+ this.model.hasMtpWeights() &&
1900
+ this.mtpAutoDefaultAllowed()
1901
+ ) {
1902
+ merged.enableMtp = true;
1903
+ }
1904
+ return merged;
1905
+ }
1906
+
1907
+ /**
1908
+ * Whether an MTP-capable model wants `enableMtp` on for a caller who set
1909
+ * nothing. `mtpAutoEnabled()` is authoritative in both directions when
1910
+ * present; otherwise {@link MTP_AUTO_DEFAULT_SUPPRESSED_MODELS} is matched
1911
+ * along the prototype CHAIN rather than by a bare `constructor.name`, so it
1912
+ * still fires through the `makeStreamingModel` wrapper subclass that
1913
+ * `@mlx-node/lm` actually hands to `ChatSession`.
1914
+ */
1915
+ private mtpAutoDefaultAllowed(): boolean {
1916
+ if (typeof this.model.mtpAutoEnabled === 'function') {
1917
+ return this.model.mtpAutoEnabled();
1918
+ }
1919
+ for (
1920
+ let proto: object | null = Object.getPrototypeOf(this.model) as object | null;
1921
+ proto !== null;
1922
+ proto = Object.getPrototypeOf(proto) as object | null
1923
+ ) {
1924
+ const name = (proto as { constructor?: { name?: string } }).constructor?.name;
1925
+ if (name !== undefined && MTP_AUTO_DEFAULT_SUPPRESSED_MODELS.has(name)) {
1926
+ return false;
1927
+ }
1928
+ }
1929
+ return true;
1930
+ }
1931
+
1932
+ /**
1933
+ * Render the exact full prompt and constrain generation to the physical KV
1934
+ * window before native code allocates a block. Models that do not expose
1935
+ * both the tokenizer seam and a load-time context snapshot retain their
1936
+ * existing behavior.
1937
+ *
1938
+ * The returned config is a copy only when `maxNewTokens` needs clamping.
1939
+ * An omitted output budget stays omitted when the native default fits; it is
1940
+ * made explicit only when the remaining window is smaller than that default.
1941
+ */
1942
+ private async constrainToContextCapacity(messages: ChatMessage[], config: ChatConfig): Promise<ChatConfig> {
1943
+ if (typeof this.model.applyChatTemplate !== 'function' || typeof this.model.contextLimits !== 'function') {
1944
+ return config;
1945
+ }
1946
+
1947
+ const limits = this.model.contextLimits();
1948
+ const capacity = Math.floor(limits.effectiveWindowTokens);
1949
+ if (!Number.isSafeInteger(capacity) || capacity <= 0) {
1950
+ return config;
1951
+ }
1952
+
1953
+ const effort = config.reasoningEffort;
1954
+ const enableThinking = effort === undefined ? null : effort !== 'none';
1955
+ const tokens = await this.model.applyChatTemplate(
1956
+ messages,
1957
+ true,
1958
+ config.tools ?? null,
1959
+ enableThinking,
1960
+ effort ?? null,
1961
+ );
1962
+ const hasImages = messages.some((message) => (message.images?.length ?? 0) > 0);
1963
+ let promptTokens = tokens.length;
1964
+ if (hasImages && typeof this.model.expandedPromptTokenCount === 'function') {
1965
+ promptTokens = await this.model.expandedPromptTokenCount(tokens, messages);
1966
+ if (!Number.isSafeInteger(promptTokens) || promptTokens < tokens.length) {
1967
+ throw new Error(
1968
+ `ChatSession: expandedPromptTokenCount returned invalid length ${promptTokens} ` +
1969
+ `(rendered template length is ${tokens.length})`,
1970
+ );
1971
+ }
1972
+ }
1973
+ if (promptTokens > capacity) {
1974
+ throw new ContextCapacityError(promptTokens, capacity);
1975
+ }
1976
+
1977
+ // The final sampled token is returned without another model forward, so N
1978
+ // generated tokens consume only N-1 additional KV positions.
1979
+ const maxOutput = capacity - promptTokens + 1;
1980
+ const requested = config.maxNewTokens;
1981
+ if (requested === undefined) {
1982
+ return maxOutput >= NATIVE_DEFAULT_MAX_NEW_TOKENS ? config : { ...config, maxNewTokens: maxOutput };
1983
+ }
1984
+ const maxNewTokens = Math.min(requested, maxOutput);
1985
+ return maxNewTokens === requested ? config : { ...config, maxNewTokens };
1986
+ }
1987
+
1988
+ /** Full history that a pending user/tool turn would render, without mutation. */
1989
+ private historyWithPending(pending: ChatMessage): ChatMessage[] {
1990
+ const messages = this.history.slice();
1991
+ if (messages.length === 0 && this.system != null) {
1992
+ messages.push({ role: 'system', content: this.system });
1993
+ }
1994
+ messages.push(pending);
1995
+ return messages;
1996
+ }
1997
+
1998
+ /**
1999
+ * Shared start-path logic for `send()`. Handles both the turn-0
2000
+ * first-ever-send case and the image-change mid-session restart
2001
+ * case. The image-change restart preserves prior history so the
2002
+ * native side gets the full conversation re-rendered with the new
2003
+ * image set.
2004
+ */
2005
+ private async runStartPath(
2006
+ userMessage: string,
2007
+ images: Uint8Array[] | undefined,
2008
+ audio: Uint8Array[] | undefined,
2009
+ mediaChanged: boolean,
2010
+ isFirstTurn: boolean,
2011
+ config: ChatConfig,
2012
+ signal?: AbortSignal,
2013
+ ): Promise<ChatResult> {
2014
+ const userMsg = this.buildUserMessage(userMessage, images, audio);
2015
+ return await this.runStartPathWithMessage(userMsg, mediaChanged, isFirstTurn, config, signal);
2016
+ }
2017
+
2018
+ /**
2019
+ * Core of {@link runStartPath} that takes a PRE-BUILT pending
2020
+ * `ChatMessage` (user or tool) instead of building a user message
2021
+ * itself. The cold-restart catch in `sendToolResult()` replays the
2022
+ * conversation through this core with a pending `{ role: 'tool', ... }`
2023
+ * message so the tool-result turn is re-rendered against the full
2024
+ * history without duplicating the start-path bookkeeping.
2025
+ */
2026
+ private async runStartPathWithMessage(
2027
+ pendingMessage: ChatMessage,
2028
+ mediaChanged: boolean,
2029
+ isFirstTurn: boolean,
2030
+ config: ChatConfig,
2031
+ signal?: AbortSignal,
2032
+ ): Promise<ChatResult> {
2033
+ // Capture pre-state so the restart can be rolled back if the
2034
+ // native call fails. The media-change branch releases this owner's caches
2035
+ // BEFORE we know whether the new prefill will succeed, so on failure we
2036
+ // also have to drop turnCount + lastImagesKey/lastAudioKey to force the
2037
+ // next call to re-route through the start path (rather than a delta
2038
+ // continue against released caches).
2039
+ const wasMediaChangeRestart = mediaChanged && !isFirstTurn;
2040
+ const historyLenBefore = this.history.length;
2041
+
2042
+ // Capacity validation must happen before `prepareStartPath()` because a
2043
+ // media-change restart releases native owner state. A rejected oversized
2044
+ // prompt is a request error and must leave both JS history and native state
2045
+ // intact.
2046
+ const constrainedConfig = await this.constrainToContextCapacity(this.historyWithPending(pendingMessage), config);
2047
+
2048
+ await this.prepareStartPath(mediaChanged, isFirstTurn, constrainedConfig.cacheOwnerId);
2049
+ this.history.push(pendingMessage);
2050
+ try {
2051
+ // Pass a shallow snapshot so later pushes to `this.history`
2052
+ // (e.g. the assistant reply below) don't retroactively mutate
2053
+ // what the native side / any mock observed as its `messages`
2054
+ // argument.
2055
+ const result = await this.runNonStreamingNative(
2056
+ 'start',
2057
+ this.history.slice(),
2058
+ withReplayReasoning(constrainedConfig, this.model),
2059
+ signal,
2060
+ );
2061
+ this.history.push(
2062
+ buildAssistantMessage(
2063
+ result.text,
2064
+ result.toolCalls,
2065
+ result.thinking,
2066
+ result.thinkingEnabled,
2067
+ result.rawText,
2068
+ this.model.replaysAssistantRawText?.() === true,
2069
+ ),
2070
+ );
2071
+ this.turnCount++;
2072
+ this.needsFullReplay = false;
2073
+ // The start path always re-renders the FULL preserved history, so the
2074
+ // post-restart sticky keys are the trailing media keys of that history,
2075
+ // not the single-turn literal args. A restart driven by a change in only
2076
+ // one modality (e.g. an audio-only turn after an earlier image turn)
2077
+ // would otherwise null the untouched modality's key even though that
2078
+ // media is still live in the native cache, causing a later same-media
2079
+ // turn to be mis-detected as a change and replayed twice.
2080
+ this.lastImagesKey = this.computeTrailingImagesKey();
2081
+ this.lastAudioKey = this.computeTrailingAudioKey();
2082
+ this.commitActiveTools(constrainedConfig);
2083
+ this.recordToolCallFanout(result.toolCalls);
2084
+ return publicChatResult(result, constrainedConfig);
2085
+ } catch (err) {
2086
+ // Roll back: drop the tentative user push so history stays
2087
+ // consistent with turnCount.
2088
+ this.history.length = historyLenBefore;
2089
+ if (wasMediaChangeRestart) {
2090
+ // This owner's caches were released by prepareStartPath() but the new prefill
2091
+ // failed. Force the next call to re-route through the start
2092
+ // path with the (preserved) prior history.
2093
+ this.turnCount = 0;
2094
+ this.lastImagesKey = null;
2095
+ this.lastAudioKey = null;
2096
+ }
2097
+ throw err;
2098
+ }
2099
+ }
2100
+
2101
+ /** Streaming counterpart to {@link runStartPath}. */
2102
+ private async *runStartStreamPath(
2103
+ userMessage: string,
2104
+ images: Uint8Array[] | undefined,
2105
+ audio: Uint8Array[] | undefined,
2106
+ mediaChanged: boolean,
2107
+ isFirstTurn: boolean,
2108
+ config: ChatConfig,
2109
+ signal: AbortSignal | undefined,
2110
+ ): AsyncGenerator<ChatStreamEvent> {
2111
+ const userMsg = this.buildUserMessage(userMessage, images, audio);
2112
+ yield* this.runStartStreamPathWithMessage(userMsg, mediaChanged, isFirstTurn, config, signal);
2113
+ }
2114
+
2115
+ /**
2116
+ * Streaming counterpart to {@link runStartPathWithMessage}: replays
2117
+ * through the cold start stream from a PRE-BUILT pending
2118
+ * `ChatMessage`. The cold-restart catch in `sendToolResultStream()`
2119
+ * delegates here with a pending `{ role: 'tool', ... }` message.
2120
+ */
2121
+ private async *runStartStreamPathWithMessage(
2122
+ pendingMessage: ChatMessage,
2123
+ mediaChanged: boolean,
2124
+ isFirstTurn: boolean,
2125
+ config: ChatConfig,
2126
+ signal: AbortSignal | undefined,
2127
+ ): AsyncGenerator<ChatStreamEvent> {
2128
+ // Capture pre-state so any non-successful exit can roll back.
2129
+ // See `runStartPath` for the full rationale.
2130
+ const wasMediaChangeRestart = mediaChanged && !isFirstTurn;
2131
+ const historyLenBefore = this.history.length;
2132
+
2133
+ // See the sync start path: reject before a media restart can release native
2134
+ // owner state, and make the output budget explicit before allocating KV blocks.
2135
+ const constrainedConfig = await this.constrainToContextCapacity(this.historyWithPending(pendingMessage), config);
2136
+
2137
+ await this.prepareStartPath(mediaChanged, isFirstTurn, constrainedConfig.cacheOwnerId);
2138
+ // Stage the pending message on the pending history BEFORE the
2139
+ // stream starts — the native call reads it synchronously via
2140
+ // `model.chatStreamSessionStart(history, config)`.
2141
+ this.history.push(pendingMessage);
2142
+
2143
+ let sawFinal = false;
2144
+ let accumulated = '';
2145
+ let accumulatedVisible = '';
2146
+ let finalRaw: string | null = null;
2147
+ let finalReplayRaw: string | null = null;
2148
+ let finalTextAuthoritative: boolean | undefined;
2149
+ let finalToolCalls: readonly ToolCallResult[] | undefined;
2150
+ let finalThinking: string | null = null;
2151
+ let finalThinkingEnabled = false;
2152
+ // Snapshot the history before dispatch — see `runStartPath` for
2153
+ // the rationale.
2154
+ const historySnapshot = this.history.slice();
2155
+ try {
2156
+ for await (const event of this.model.chatStreamSessionStart(
2157
+ historySnapshot,
2158
+ withReplayReasoning(constrainedConfig, this.model),
2159
+ signal,
2160
+ )) {
2161
+ if (event.done) {
2162
+ if (event.finishReason !== 'error') {
2163
+ sawFinal = true;
2164
+ finalRaw = event.text;
2165
+ finalReplayRaw = event.rawText;
2166
+ finalTextAuthoritative = (event as ReplayCaptureStreamEvent).textAuthoritative;
2167
+ finalToolCalls = event.toolCalls;
2168
+ finalThinking = event.thinking;
2169
+ finalThinkingEnabled = event.thinkingEnabled;
2170
+ }
2171
+ } else {
2172
+ accumulated += event.text;
2173
+ if (event.isReasoning !== true) {
2174
+ accumulatedVisible += event.text;
2175
+ }
2176
+ }
2177
+ const publicEvent = publicStreamEvent(event, constrainedConfig);
2178
+ if (publicEvent !== null) yield publicEvent;
2179
+ }
2180
+ } finally {
2181
+ // finally runs in ALL termination paths: normal completion,
2182
+ // mid-stream throw, caller `break` (which calls
2183
+ // `iterator.return()` on the generator and short-circuits the
2184
+ // suspended `yield`, skipping any post-loop code), and
2185
+ // error-finish chunks. The unified commit-or-rollback below
2186
+ // makes restart fully transactional regardless of how the
2187
+ // generator was wound down. Mid-stream throws still propagate
2188
+ // naturally — finally runs first, then the error continues up.
2189
+ if (sawFinal) {
2190
+ this.history.push(
2191
+ buildAssistantMessage(
2192
+ selectCommittedStreamText(finalRaw, accumulatedVisible, finalTextAuthoritative),
2193
+ finalToolCalls,
2194
+ finalThinking,
2195
+ finalThinkingEnabled,
2196
+ finalReplayRaw,
2197
+ this.model.replaysAssistantRawText?.() === true,
2198
+ ),
2199
+ );
2200
+ this.turnCount++;
2201
+ this.needsFullReplay = false;
2202
+ // The start path always re-renders the FULL preserved history, so the
2203
+ // post-restart sticky keys are the trailing media keys of that history,
2204
+ // not the single-turn literal args. A restart driven by a change in only
2205
+ // one modality (e.g. an audio-only turn after an earlier image turn)
2206
+ // would otherwise null the untouched modality's key even though that
2207
+ // media is still live in the native cache, causing a later same-media
2208
+ // turn to be mis-detected as a change and replayed twice.
2209
+ this.lastImagesKey = this.computeTrailingImagesKey();
2210
+ this.lastAudioKey = this.computeTrailingAudioKey();
2211
+ this.commitActiveTools(constrainedConfig);
2212
+ this.recordToolCallFanout(finalToolCalls);
2213
+ } else {
2214
+ // Roll back: drop the tentative user push so history stays
2215
+ // consistent with turnCount.
2216
+ this.history.length = historyLenBefore;
2217
+ if (wasMediaChangeRestart) {
2218
+ // This owner's caches were released by prepareStartPath() but the new
2219
+ // prefill never reached a successful done:true. Force the
2220
+ // next call to re-route through the start path with the
2221
+ // preserved prior history.
2222
+ this.turnCount = 0;
2223
+ this.lastImagesKey = null;
2224
+ this.lastAudioKey = null;
2225
+ }
2226
+ }
2227
+ }
2228
+ }
2229
+
2230
+ /**
2231
+ * Shared pre-start bookkeeping for both `send()` and `sendStream()`:
2232
+ *
2233
+ * - On an image-change restart (turn >= 1), release this session's native
2234
+ * cache owner so the new image set gets a fresh prefill without wiping
2235
+ * concurrently live owners. History is
2236
+ * intentionally preserved — `chatSessionStart` receives the full
2237
+ * accumulated conversation plus the new user turn so the jinja
2238
+ * render walks every prior turn and every prior image again
2239
+ * (see plan's Turn 3 example: "full jinja on 3-turn history +
2240
+ * image B"). `lastImagesKey` will be overwritten by the
2241
+ * successful start path right after, and `turnCount` is
2242
+ * incremented by the start path the same way as for any other
2243
+ * turn.
2244
+ * - On a fresh / reset history, re-inject the system prompt.
2245
+ */
2246
+ private async prepareStartPath(
2247
+ mediaChanged: boolean,
2248
+ isFirstTurn: boolean,
2249
+ cacheOwnerId: string | undefined,
2250
+ ): Promise<void> {
2251
+ if (mediaChanged && !isFirstTurn) {
2252
+ // Await owner release so it has drained through the model thread before
2253
+ // the replacement start is enqueued. This is deliberately owner-scoped
2254
+ // on continuously batched models: a model-wide reset would invalidate
2255
+ // unrelated live ChatSessions. Third-party/exclusive models predating
2256
+ // the owner lifecycle retain the full-reset fallback.
2257
+ if (this.model.hasBlockPagedCache?.() === true && this.model.releaseCacheOwner && cacheOwnerId) {
2258
+ await this.model.releaseCacheOwner(cacheOwnerId);
2259
+ } else {
2260
+ await this.model.resetCaches();
2261
+ }
2262
+ }
2263
+ if (this.history.length === 0 && this.system != null) {
2264
+ this.history.push({ role: 'system', content: this.system });
2265
+ }
2266
+ }
2267
+
2268
+ /** Build a user `ChatMessage` with or without attached images/audio. */
2269
+ private buildUserMessage(
2270
+ userMessage: string,
2271
+ images: Uint8Array[] | undefined,
2272
+ audio: Uint8Array[] | undefined,
2273
+ ): ChatMessage {
2274
+ const msg: ChatMessage = { role: 'user', content: userMessage };
2275
+ if (images && images.length > 0) msg.images = images;
2276
+ if (audio && audio.length > 0) msg.audio = audio;
2277
+ return msg;
2278
+ }
2279
+
2280
+ /**
2281
+ * Walk the history backward to find the most recent user message
2282
+ * with images and return its SHA-256 key. Used by
2283
+ * {@link startFromHistory} and {@link startFromHistoryStream} to
2284
+ * hydrate `lastImagesKey` after a cold replay, so subsequent delta
2285
+ * continues correctly detect image changes.
2286
+ */
2287
+ private computeTrailingImagesKey(): string | null {
2288
+ for (let i = this.history.length - 1; i >= 0; i--) {
2289
+ const msg = this.history[i];
2290
+ if (msg?.role === 'user' && msg.images && msg.images.length > 0) {
2291
+ return computeByteListKey(msg.images);
2292
+ }
2293
+ }
2294
+ return null;
2295
+ }
2296
+
2297
+ /**
2298
+ * Audio counterpart of {@link computeTrailingImagesKey}: walk history
2299
+ * backward to the most recent user message carrying audio and return its
2300
+ * SHA-256 key, so a cold replay hydrates `lastAudioKey` correctly.
2301
+ */
2302
+ private computeTrailingAudioKey(): string | null {
2303
+ for (let i = this.history.length - 1; i >= 0; i--) {
2304
+ const msg = this.history[i];
2305
+ if (msg?.role === 'user' && msg.audio && msg.audio.length > 0) {
2306
+ return computeByteListKey(msg.audio);
2307
+ }
2308
+ }
2309
+ return null;
2310
+ }
2311
+
2312
+ /**
2313
+ * Derive the post-prime value of `unresolvedOkToolCallCount` from
2314
+ * the primed history. Walks backward to the most recent assistant
2315
+ * turn, then walks forward from that assistant to the end of history
2316
+ * subtracting any `tool:` message that references one of the turn's
2317
+ * `call_id`s. A fully-resolved history (every outstanding id matched
2318
+ * by a sibling `tool:` message) returns `null`; any leftover count is
2319
+ * the number of still-unresolved tool calls.
2320
+ *
2321
+ * Matches the runtime `recordToolCallFanout` semantics on the hot
2322
+ * path: zero unresolved → `null` (no pending obligation); one →
2323
+ * `1` (servable via `sendToolResult*()` only); two or more → the
2324
+ * count itself (unservable fan-out — must be resolved via cold
2325
+ * replay). The distinction between "ok" vs. other statuses only
2326
+ * exists in the live `ToolCallResult[]` emitted by the native side —
2327
+ * the persisted `ChatMessage.toolCalls` on an assistant message only
2328
+ * carries successfully parsed calls (i.e. what would have been "ok"
2329
+ * in the original live turn), so counting the array length is
2330
+ * equivalent. Tool calls whose `id` is missing or empty can't be
2331
+ * matched against subsequent `tool_call_id`s, so in that case we
2332
+ * fall back to returning the raw `calls.length` (err safe).
2333
+ */
2334
+ private computeTrailingAssistantUnresolvedToolCallCount(): number | null {
2335
+ let assistantIdx = -1;
2336
+ for (let i = this.history.length - 1; i >= 0; i--) {
2337
+ if (this.history[i]?.role === 'assistant') {
2338
+ assistantIdx = i;
2339
+ break;
2340
+ }
2341
+ }
2342
+ if (assistantIdx === -1) return null;
2343
+
2344
+ const assistant = this.history[assistantIdx]!;
2345
+ const calls = assistant.toolCalls ?? [];
2346
+ if (calls.length === 0) return null;
2347
+
2348
+ const outstanding = new Set<string>();
2349
+ let missingIdCount = 0;
2350
+ for (const tc of calls) {
2351
+ if (typeof tc.id === 'string' && tc.id.length > 0) {
2352
+ outstanding.add(tc.id);
2353
+ } else {
2354
+ missingIdCount++;
2355
+ }
2356
+ }
2357
+ // Untracked calls (no id) can't be matched against resolutions —
2358
+ // err safe by reporting the raw count.
2359
+ if (missingIdCount > 0) return calls.length;
2360
+
2361
+ for (let j = assistantIdx + 1; j < this.history.length; j++) {
2362
+ const msg = this.history[j];
2363
+ if (msg?.role === 'tool' && typeof msg.toolCallId === 'string' && msg.toolCallId.length > 0) {
2364
+ outstanding.delete(msg.toolCallId);
2365
+ }
2366
+ }
2367
+ return outstanding.size > 0 ? outstanding.size : null;
2368
+ }
2369
+ }