@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.
- package/dist/chat-session.d.ts +1 -1
- package/dist/chat-session.d.ts.map +1 -1
- package/dist/chat-session.js +2 -2
- package/dist/draft-companion.d.ts +16 -0
- package/dist/draft-companion.d.ts.map +1 -0
- package/dist/draft-companion.js +76 -0
- package/dist/family-data.d.ts +2 -0
- package/dist/family-data.d.ts.map +1 -1
- package/dist/family-data.js +2 -0
- package/dist/gguf-metadata.d.ts +2 -0
- package/dist/gguf-metadata.d.ts.map +1 -0
- package/dist/gguf-metadata.js +128 -0
- package/dist/model-detection.d.ts +6 -0
- package/dist/model-detection.d.ts.map +1 -0
- package/dist/model-detection.js +38 -0
- package/dist/model-discovery.d.ts +24 -0
- package/dist/model-discovery.d.ts.map +1 -0
- package/dist/model-discovery.js +274 -0
- package/dist/models/model-loader.d.ts +6 -0
- package/dist/models/model-loader.d.ts.map +1 -1
- package/dist/models/model-loader.js +10 -32
- package/dist/models/paged-config-override.d.ts.map +1 -1
- package/dist/models/paged-config-override.js +21 -1
- package/dist/stream.d.ts.map +1 -1
- package/dist/stream.js +5 -5
- package/package.json +21 -3
- package/src/chat-session.ts +2369 -0
- package/src/draft-companion.ts +74 -0
- package/src/family-data.ts +542 -0
- package/src/gguf-metadata.ts +117 -0
- package/src/index.ts +151 -0
- package/src/model-detection.ts +46 -0
- package/src/model-discovery.ts +329 -0
- package/src/models/lfm2-configs.ts +110 -0
- package/src/models/model-loader.ts +256 -0
- package/src/models/paged-config-override.ts +387 -0
- package/src/models/qwen3-configs.ts +113 -0
- package/src/models/qwen3_5-configs.ts +60 -0
- package/src/profiling.ts +69 -0
- package/src/stream.ts +960 -0
- package/src/tools/index.ts +58 -0
- 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
|
+
}
|