@mlx-node/lm 0.0.8 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -27
- package/dist/chat-session.d.ts +37 -14
- package/dist/chat-session.d.ts.map +1 -1
- package/dist/chat-session.js +236 -79
- package/dist/models/model-loader.d.ts +2 -0
- package/dist/models/model-loader.d.ts.map +1 -1
- package/dist/models/paged-config-override.d.ts +13 -1
- package/dist/models/paged-config-override.d.ts.map +1 -1
- package/dist/models/paged-config-override.js +47 -10
- package/dist/stream.d.ts +43 -7
- package/dist/stream.d.ts.map +1 -1
- package/dist/stream.js +50 -20
- package/package.json +2 -2
package/dist/chat-session.js
CHANGED
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
* Design notes:
|
|
12
12
|
*
|
|
13
13
|
* - The session tracks its own `ChatMessage[]` history on the
|
|
14
|
-
* TypeScript side
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* the
|
|
18
|
-
*
|
|
19
|
-
* 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
20
|
*
|
|
21
21
|
* - An image hash (`lastImagesKey`) tracks the images bound to the
|
|
22
22
|
* current cache. A `send()` call whose image set has changed
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
* restart: `resetCaches()` → push the new user message (with
|
|
25
25
|
* images) to history → `chatSessionStart(history)`.
|
|
26
26
|
*
|
|
27
|
-
* - Text-only `send()` on turn >= 1
|
|
27
|
+
* - Text-only `send()` on turn >= 1 still gets incremental prefill
|
|
28
|
+
* on a token-prefix hit. Prompt structure is never reconstructed
|
|
29
|
+
* from Rust string literals.
|
|
28
30
|
*
|
|
29
31
|
* - `sendToolResult` always dispatches `chatSessionContinueTool`,
|
|
30
32
|
* since tool turns never change image state. The session enforces
|
|
@@ -48,9 +50,7 @@
|
|
|
48
50
|
* `sendToolResult*()` throw. The only valid recovery is
|
|
49
51
|
* `reset()` or `primeHistory()` + `startFromHistory*()` with
|
|
50
52
|
* a fully-resolved conversation — there is no "advance past
|
|
51
|
-
* the broken turn" path.
|
|
52
|
-
* format which would otherwise silently corrupt multi-call
|
|
53
|
-
* conversations.
|
|
53
|
+
* the broken turn" path.
|
|
54
54
|
*
|
|
55
55
|
* - `sawFinal` gates `turnCount` advance on the streaming path, so
|
|
56
56
|
* the session refuses to advance when the stream throws
|
|
@@ -85,21 +85,17 @@
|
|
|
85
85
|
*/
|
|
86
86
|
import { createHash } from 'node:crypto';
|
|
87
87
|
/**
|
|
88
|
-
* Typed prefix
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* to advance the cheap delta on top of media KV, so the session layer
|
|
92
|
-
* recognizes this exact prefix and transparently replays the whole
|
|
93
|
-
* conversation through the cold start path instead of surfacing the
|
|
94
|
-
* raw error to the caller.
|
|
88
|
+
* Typed prefix native media guards use when a history cannot be continued
|
|
89
|
+
* from the held image/audio state. The session layer recognizes this exact
|
|
90
|
+
* prefix and transparently replays the complete structured conversation.
|
|
95
91
|
*
|
|
96
92
|
* MUST stay byte-for-byte identical to the Rust constant
|
|
97
93
|
* `IMAGE_CHANGE_RESTART_PREFIX` in
|
|
98
94
|
* `crates/mlx-core/src/engine/cache.rs` — it is not exported across the
|
|
99
95
|
* NAPI boundary, so the two literals are kept in sync by hand. The
|
|
100
96
|
* native message starts with this prefix and is delivered as the
|
|
101
|
-
* `Error.message`: on the sync
|
|
102
|
-
* the streaming
|
|
97
|
+
* `Error.message`: on the sync path as a rejected promise, and on
|
|
98
|
+
* the streaming path as a thrown error on the generator's first
|
|
103
99
|
* iteration (the native worker-thread sink error is re-thrown by the
|
|
104
100
|
* `packages/lm/src/stream.ts` bridge before any chunk is yielded).
|
|
105
101
|
*/
|
|
@@ -192,23 +188,40 @@ function toAssistantToolCalls(toolCalls) {
|
|
|
192
188
|
}
|
|
193
189
|
/**
|
|
194
190
|
* Build an assistant `ChatMessage` from a just-completed turn's
|
|
195
|
-
* decoded text
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
191
|
+
* decoded text, exact raw text, tool-call list, reasoning body, and resolved
|
|
192
|
+
* thinking mode. LFM2 replays the exact raw content because its checkpoint
|
|
193
|
+
* template does not consume structured reasoning; Qwen/Gemma retain their
|
|
194
|
+
* structured fields. The assistant entry is appended to `this.history` after every
|
|
195
|
+
* successful turn and is later read back by the native
|
|
196
|
+
* `chatSessionStart` cold-replay path (image-change mid-session
|
|
197
|
+
* restart, `startFromHistory*`, server-side `SessionRegistry`
|
|
198
|
+
* cache-miss rebuild). Dropping `toolCalls`, `reasoningContent`, or
|
|
199
|
+
* `thinkingEnabled` changes the rendered assistant bytes on replay:
|
|
200
|
+
* tool responses lose their declaring call, reasoning disappears, or
|
|
201
|
+
* an empty disabled-thinking channel is reinterpreted under the
|
|
202
|
+
* current turn's mode.
|
|
205
203
|
*/
|
|
206
|
-
function buildAssistantMessage(text, toolCalls) {
|
|
204
|
+
function buildAssistantMessage(text, toolCalls, thinking, thinkingEnabled, rawText, replayRawText) {
|
|
205
|
+
if (replayRawText) {
|
|
206
|
+
return {
|
|
207
|
+
role: 'assistant',
|
|
208
|
+
content: rawText ?? text,
|
|
209
|
+
thinkingEnabled,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
207
212
|
const calls = toAssistantToolCalls(toolCalls);
|
|
213
|
+
const message = {
|
|
214
|
+
role: 'assistant',
|
|
215
|
+
content: text,
|
|
216
|
+
thinkingEnabled,
|
|
217
|
+
};
|
|
208
218
|
if (calls) {
|
|
209
|
-
|
|
219
|
+
message.toolCalls = calls;
|
|
220
|
+
}
|
|
221
|
+
if (thinking != null) {
|
|
222
|
+
message.reasoningContent = thinking;
|
|
210
223
|
}
|
|
211
|
-
return
|
|
224
|
+
return message;
|
|
212
225
|
}
|
|
213
226
|
/**
|
|
214
227
|
* Count the `ok`-status tool calls in a `ChatResult.toolCalls` /
|
|
@@ -230,6 +243,63 @@ function countOkToolCalls(toolCalls) {
|
|
|
230
243
|
}
|
|
231
244
|
return n;
|
|
232
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Select the assistant text committed after a successful stream.
|
|
248
|
+
*
|
|
249
|
+
* Default emitters expose parsed text on the terminal event, so an empty
|
|
250
|
+
* value is authoritative for a tool-only turn and must not fall back to raw
|
|
251
|
+
* streamed tool markup. Gemma's channel-aware emitter instead sends visible
|
|
252
|
+
* text as deltas and an empty terminal value for ordinary no-tool replies, so
|
|
253
|
+
* retain the accumulated visible text in that distinct shape.
|
|
254
|
+
*/
|
|
255
|
+
function selectCommittedStreamText(finalText, accumulatedVisible, terminalTextAuthoritative) {
|
|
256
|
+
if (finalText == null)
|
|
257
|
+
return accumulatedVisible;
|
|
258
|
+
if (terminalTextAuthoritative === true)
|
|
259
|
+
return finalText;
|
|
260
|
+
if (terminalTextAuthoritative === false)
|
|
261
|
+
return accumulatedVisible;
|
|
262
|
+
if (finalText !== '')
|
|
263
|
+
return finalText;
|
|
264
|
+
return accumulatedVisible;
|
|
265
|
+
}
|
|
266
|
+
/** Mirror the native default used by `resolve_include_reasoning`. */
|
|
267
|
+
function includesReasoning(config) {
|
|
268
|
+
return config.includeReasoning ?? config.reasoningEffort !== 'none';
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Session history must retain reasoning even when the caller hides it. Ask
|
|
272
|
+
* native finalization for the full parsed turn without mutating the committed
|
|
273
|
+
* request config; the public view is redacted again below.
|
|
274
|
+
*/
|
|
275
|
+
function withReplayReasoning(config, model) {
|
|
276
|
+
if (includesReasoning(config))
|
|
277
|
+
return config;
|
|
278
|
+
if (model.supportsReplayReasoningCapture?.() !== true)
|
|
279
|
+
return config;
|
|
280
|
+
// A zero-budget "none" turn cannot produce a reasoning body worth
|
|
281
|
+
// replaying. Preserve the caller's native suppression flag for this common
|
|
282
|
+
// short-generation path (for example title generation).
|
|
283
|
+
if (config.reasoningEffort === 'none' && (config.thinkingTokenBudget ?? 0) <= 0) {
|
|
284
|
+
return config;
|
|
285
|
+
}
|
|
286
|
+
return { ...config, includeReasoning: true };
|
|
287
|
+
}
|
|
288
|
+
function publicChatResult(result, config) {
|
|
289
|
+
if (includesReasoning(config))
|
|
290
|
+
return result;
|
|
291
|
+
const safeRaw = result.publicRawText;
|
|
292
|
+
return { ...result, thinking: undefined, rawText: safeRaw ?? result.text };
|
|
293
|
+
}
|
|
294
|
+
function publicStreamEvent(event, config) {
|
|
295
|
+
if (includesReasoning(config))
|
|
296
|
+
return event;
|
|
297
|
+
if (!event.done) {
|
|
298
|
+
return event.isReasoning === true ? null : event;
|
|
299
|
+
}
|
|
300
|
+
const safeRaw = event.publicRawText;
|
|
301
|
+
return { ...event, thinking: null, rawText: safeRaw ?? event.text };
|
|
302
|
+
}
|
|
233
303
|
/**
|
|
234
304
|
* Compute a stable hex-encoded identity key for a list of image
|
|
235
305
|
* byte buffers.
|
|
@@ -306,11 +376,12 @@ export class ChatSession {
|
|
|
306
376
|
model;
|
|
307
377
|
system;
|
|
308
378
|
defaultConfig;
|
|
379
|
+
/** Tool definitions are conversation state for deterministic template replay. */
|
|
380
|
+
activeTools;
|
|
309
381
|
/**
|
|
310
382
|
* Full conversation history tracked on the TS side. Appended to on
|
|
311
|
-
* every successful turn
|
|
312
|
-
*
|
|
313
|
-
* cache, not this array.
|
|
383
|
+
* every successful turn and sent on every role-aware native turn so
|
|
384
|
+
* the model-provided template remains the sole prompt authority.
|
|
314
385
|
*/
|
|
315
386
|
history = [];
|
|
316
387
|
/**
|
|
@@ -329,7 +400,7 @@ export class ChatSession {
|
|
|
329
400
|
lastAudioKey = null;
|
|
330
401
|
turnCount = 0;
|
|
331
402
|
inFlight = false;
|
|
332
|
-
/** A failed/abandoned native
|
|
403
|
+
/** A failed/abandoned native turn must be followed by a full replay. */
|
|
333
404
|
needsFullReplay = false;
|
|
334
405
|
/**
|
|
335
406
|
* Count of `ok` tool calls emitted by the prior assistant turn, or
|
|
@@ -356,6 +427,7 @@ export class ChatSession {
|
|
|
356
427
|
this.model = model;
|
|
357
428
|
this.system = options.system;
|
|
358
429
|
this.defaultConfig = options.defaultConfig ?? {};
|
|
430
|
+
this.activeTools = this.defaultConfig.tools;
|
|
359
431
|
}
|
|
360
432
|
/**
|
|
361
433
|
* Number of completed turns. Increments only after a successful
|
|
@@ -440,9 +512,10 @@ export class ChatSession {
|
|
|
440
512
|
/**
|
|
441
513
|
* Send a user message and resolve with the assistant reply.
|
|
442
514
|
*
|
|
443
|
-
* Turn 0 and any turn whose image set
|
|
444
|
-
* `chatSessionStart
|
|
445
|
-
*
|
|
515
|
+
* Turn 0 and any turn whose image set changed dispatch through
|
|
516
|
+
* `chatSessionStart`. Later turns pass the same complete structured
|
|
517
|
+
* history through `chatSessionContinue`; native code renders the
|
|
518
|
+
* model template and reuses KV on an exact token-prefix match.
|
|
446
519
|
*/
|
|
447
520
|
async send(userMessage, opts = {}) {
|
|
448
521
|
if (this.inFlight) {
|
|
@@ -454,11 +527,11 @@ export class ChatSession {
|
|
|
454
527
|
const mergedConfig = this.mergeConfig(opts.config);
|
|
455
528
|
const newImagesKey = computeImagesKey(opts.images);
|
|
456
529
|
const newAudioKey = computeAudioKey(opts.audio);
|
|
457
|
-
// Only an explicit NEW image/audio set can trigger a restart. Omitting
|
|
530
|
+
// Only an explicit NEW image/audio set can trigger a forced restart. Omitting
|
|
458
531
|
// `images`/`audio` (key === null) is interpreted as "keep the current
|
|
459
532
|
// media cache state" — the server-side cache already holds any prior
|
|
460
533
|
// media context, so a text-only follow-up like "what about the
|
|
461
|
-
// top-right?" can
|
|
534
|
+
// top-right?" can ask native code to verify/reuse the templated history.
|
|
462
535
|
const imageChanged = newImagesKey !== null && newImagesKey !== this.lastImagesKey;
|
|
463
536
|
const audioChanged = newAudioKey !== null && newAudioKey !== this.lastAudioKey;
|
|
464
537
|
const isFirstTurn = this.turnCount === 0;
|
|
@@ -466,14 +539,15 @@ export class ChatSession {
|
|
|
466
539
|
if (isFirstTurn || imageChanged || audioChanged || replayRequired) {
|
|
467
540
|
return await this.runStartPath(userMessage, opts.images, opts.audio, imageChanged || audioChanged || replayRequired, isFirstTurn, mergedConfig);
|
|
468
541
|
}
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
542
|
+
// Role-aware continuation: pass the complete structured transcript.
|
|
543
|
+
// Native code renders it with the checkpoint template and only reuses
|
|
544
|
+
// the live cache when the resulting tokens exactly extend that cache.
|
|
472
545
|
const pendingUser = { role: 'user', content: userMessage };
|
|
473
|
-
const
|
|
546
|
+
const pendingHistory = this.historyWithPending(pendingUser);
|
|
547
|
+
const constrainedConfig = await this.constrainToContextCapacity(pendingHistory, mergedConfig);
|
|
474
548
|
let result;
|
|
475
549
|
try {
|
|
476
|
-
result = await this.model.chatSessionContinue(
|
|
550
|
+
result = await this.model.chatSessionContinue(pendingHistory, withReplayReasoning(constrainedConfig, this.model));
|
|
477
551
|
}
|
|
478
552
|
catch (err) {
|
|
479
553
|
if (!isMediaHeldRestartError(err)) {
|
|
@@ -481,19 +555,20 @@ export class ChatSession {
|
|
|
481
555
|
throw err;
|
|
482
556
|
}
|
|
483
557
|
// The native session holds media KV (gemma4 after an image/audio
|
|
484
|
-
// turn) and refused the
|
|
558
|
+
// turn) and refused the continuation. Transparently replay the full
|
|
485
559
|
// conversation through the cold start path. The earlier media turn
|
|
486
560
|
// already lives in `this.history`, so the start path re-renders it;
|
|
487
561
|
// the trailing-media keys keep `lastImagesKey`/`lastAudioKey`
|
|
488
|
-
// consistent across the replay. The
|
|
562
|
+
// consistent across the replay. The continuation path has NOT pushed
|
|
489
563
|
// `userMessage` yet, so `runStartPath` pushing it adds no duplicate.
|
|
490
564
|
return await this.runStartPath(userMessage, undefined, undefined, true, false, constrainedConfig);
|
|
491
565
|
}
|
|
492
566
|
this.history.push(pendingUser);
|
|
493
|
-
this.history.push(buildAssistantMessage(result.text, result.toolCalls));
|
|
567
|
+
this.history.push(buildAssistantMessage(result.text, result.toolCalls, result.thinking, result.thinkingEnabled, result.rawText, this.model.replaysAssistantRawText?.() === true));
|
|
494
568
|
this.turnCount++;
|
|
569
|
+
this.commitActiveTools(constrainedConfig);
|
|
495
570
|
this.recordToolCallFanout(result.toolCalls);
|
|
496
|
-
return result;
|
|
571
|
+
return publicChatResult(result, constrainedConfig);
|
|
497
572
|
}
|
|
498
573
|
finally {
|
|
499
574
|
this.inFlight = false;
|
|
@@ -535,12 +610,17 @@ export class ChatSession {
|
|
|
535
610
|
}
|
|
536
611
|
// Delta continue stream: text-only.
|
|
537
612
|
const pendingUser = { role: 'user', content: userMessage };
|
|
538
|
-
const
|
|
613
|
+
const pendingHistory = this.historyWithPending(pendingUser);
|
|
614
|
+
const constrainedConfig = await this.constrainToContextCapacity(pendingHistory, mergedConfig);
|
|
539
615
|
let sawFinal = false;
|
|
540
616
|
let accumulated = '';
|
|
541
617
|
let accumulatedVisible = '';
|
|
542
618
|
let finalRaw = null;
|
|
619
|
+
let finalReplayRaw = null;
|
|
620
|
+
let finalTextAuthoritative;
|
|
543
621
|
let finalToolCalls;
|
|
622
|
+
let finalThinking = null;
|
|
623
|
+
let finalThinkingEnabled = false;
|
|
544
624
|
// Set when the media-held rejection re-routes this turn through the
|
|
545
625
|
// cold start stream. The replay path owns the history push, turnCount
|
|
546
626
|
// increment, and media-key rehydration, so the commit `finally` below
|
|
@@ -548,12 +628,16 @@ export class ChatSession {
|
|
|
548
628
|
let delegated = false;
|
|
549
629
|
try {
|
|
550
630
|
try {
|
|
551
|
-
for await (const event of this.model.chatStreamSessionContinue(
|
|
631
|
+
for await (const event of this.model.chatStreamSessionContinue(pendingHistory, withReplayReasoning(constrainedConfig, this.model), opts.signal)) {
|
|
552
632
|
if (event.done) {
|
|
553
633
|
if (event.finishReason !== 'error') {
|
|
554
634
|
sawFinal = true;
|
|
555
635
|
finalRaw = event.text;
|
|
636
|
+
finalReplayRaw = event.rawText;
|
|
637
|
+
finalTextAuthoritative = event.textAuthoritative;
|
|
556
638
|
finalToolCalls = event.toolCalls;
|
|
639
|
+
finalThinking = event.thinking;
|
|
640
|
+
finalThinkingEnabled = event.thinkingEnabled;
|
|
557
641
|
}
|
|
558
642
|
}
|
|
559
643
|
else {
|
|
@@ -562,7 +646,9 @@ export class ChatSession {
|
|
|
562
646
|
accumulatedVisible += event.text;
|
|
563
647
|
}
|
|
564
648
|
}
|
|
565
|
-
|
|
649
|
+
const publicEvent = publicStreamEvent(event, constrainedConfig);
|
|
650
|
+
if (publicEvent !== null)
|
|
651
|
+
yield publicEvent;
|
|
566
652
|
}
|
|
567
653
|
}
|
|
568
654
|
catch (err) {
|
|
@@ -594,8 +680,9 @@ export class ChatSession {
|
|
|
594
680
|
// committed (or rolled back) — so this commit must stay off.
|
|
595
681
|
if (sawFinal && !delegated) {
|
|
596
682
|
this.history.push(pendingUser);
|
|
597
|
-
this.history.push(buildAssistantMessage(finalRaw
|
|
683
|
+
this.history.push(buildAssistantMessage(selectCommittedStreamText(finalRaw, accumulatedVisible, finalTextAuthoritative), finalToolCalls, finalThinking, finalThinkingEnabled, finalReplayRaw, this.model.replaysAssistantRawText?.() === true));
|
|
598
684
|
this.turnCount++;
|
|
685
|
+
this.commitActiveTools(constrainedConfig);
|
|
599
686
|
this.recordToolCallFanout(finalToolCalls);
|
|
600
687
|
}
|
|
601
688
|
else if (!delegated) {
|
|
@@ -611,9 +698,9 @@ export class ChatSession {
|
|
|
611
698
|
}
|
|
612
699
|
}
|
|
613
700
|
/**
|
|
614
|
-
* Send a tool-result turn.
|
|
615
|
-
*
|
|
616
|
-
*
|
|
701
|
+
* Send a tool-result turn. The declaring assistant tool call and the
|
|
702
|
+
* pending result are both included in the full history passed to
|
|
703
|
+
* `chatSessionContinueTool`.
|
|
617
704
|
*
|
|
618
705
|
* Rejects if the prior assistant turn emitted more than one `ok`
|
|
619
706
|
* tool call: the chat-session API only supports exactly one tool
|
|
@@ -647,8 +734,14 @@ export class ChatSession {
|
|
|
647
734
|
try {
|
|
648
735
|
const { isError, config } = opts;
|
|
649
736
|
const mergedConfig = this.mergeConfig(config);
|
|
650
|
-
const toolMsg = {
|
|
651
|
-
|
|
737
|
+
const toolMsg = {
|
|
738
|
+
role: 'tool',
|
|
739
|
+
content,
|
|
740
|
+
toolCallId,
|
|
741
|
+
isError,
|
|
742
|
+
};
|
|
743
|
+
const pendingHistory = this.historyWithPending(toolMsg);
|
|
744
|
+
const constrainedConfig = await this.constrainToContextCapacity(pendingHistory, mergedConfig);
|
|
652
745
|
// A cold native session (turnCount===0) has no live KV to delta
|
|
653
746
|
// against — the typical cause is an interrupted media-held replay
|
|
654
747
|
// whose rollback wiped the cache and reset the counter while
|
|
@@ -663,12 +756,13 @@ export class ChatSession {
|
|
|
663
756
|
return await this.replayToolResultThroughStartPath(toolMsg, constrainedConfig);
|
|
664
757
|
}
|
|
665
758
|
try {
|
|
666
|
-
const result = await this.model.chatSessionContinueTool(
|
|
759
|
+
const result = await this.model.chatSessionContinueTool(pendingHistory, withReplayReasoning(constrainedConfig, this.model));
|
|
667
760
|
this.history.push({ role: 'tool', content, toolCallId, isError });
|
|
668
|
-
this.history.push(buildAssistantMessage(result.text, result.toolCalls));
|
|
761
|
+
this.history.push(buildAssistantMessage(result.text, result.toolCalls, result.thinking, result.thinkingEnabled, result.rawText, this.model.replaysAssistantRawText?.() === true));
|
|
669
762
|
this.turnCount++;
|
|
763
|
+
this.commitActiveTools(constrainedConfig);
|
|
670
764
|
this.recordToolCallFanout(result.toolCalls);
|
|
671
|
-
return result;
|
|
765
|
+
return publicChatResult(result, constrainedConfig);
|
|
672
766
|
}
|
|
673
767
|
catch (err) {
|
|
674
768
|
if (!isMediaHeldRestartError(err)) {
|
|
@@ -725,8 +819,14 @@ export class ChatSession {
|
|
|
725
819
|
try {
|
|
726
820
|
const { isError, config, signal } = opts;
|
|
727
821
|
const mergedConfig = this.mergeConfig(config);
|
|
728
|
-
const toolMsg = {
|
|
729
|
-
|
|
822
|
+
const toolMsg = {
|
|
823
|
+
role: 'tool',
|
|
824
|
+
content,
|
|
825
|
+
toolCallId,
|
|
826
|
+
isError,
|
|
827
|
+
};
|
|
828
|
+
const pendingHistory = this.historyWithPending(toolMsg);
|
|
829
|
+
const constrainedConfig = await this.constrainToContextCapacity(pendingHistory, mergedConfig);
|
|
730
830
|
// A cold native session (turnCount===0) has no live KV to delta
|
|
731
831
|
// against — typically the residue of an interrupted media-held
|
|
732
832
|
// replay whose rollback wiped the cache and reset the counter
|
|
@@ -745,7 +845,11 @@ export class ChatSession {
|
|
|
745
845
|
let accumulated = '';
|
|
746
846
|
let accumulatedVisible = '';
|
|
747
847
|
let finalRaw = null;
|
|
848
|
+
let finalReplayRaw = null;
|
|
849
|
+
let finalTextAuthoritative;
|
|
748
850
|
let finalToolCalls;
|
|
851
|
+
let finalThinking = null;
|
|
852
|
+
let finalThinkingEnabled = false;
|
|
749
853
|
// Set when the media-held rejection re-routes this tool turn
|
|
750
854
|
// through the cold start stream. The replay path owns the history
|
|
751
855
|
// push, turnCount increment, and media-key rehydration, so the
|
|
@@ -753,12 +857,16 @@ export class ChatSession {
|
|
|
753
857
|
let delegated = false;
|
|
754
858
|
try {
|
|
755
859
|
try {
|
|
756
|
-
for await (const event of this.model.chatStreamSessionContinueTool(
|
|
860
|
+
for await (const event of this.model.chatStreamSessionContinueTool(pendingHistory, withReplayReasoning(constrainedConfig, this.model), signal)) {
|
|
757
861
|
if (event.done) {
|
|
758
862
|
if (event.finishReason !== 'error') {
|
|
759
863
|
sawFinal = true;
|
|
760
864
|
finalRaw = event.text;
|
|
865
|
+
finalReplayRaw = event.rawText;
|
|
866
|
+
finalTextAuthoritative = event.textAuthoritative;
|
|
761
867
|
finalToolCalls = event.toolCalls;
|
|
868
|
+
finalThinking = event.thinking;
|
|
869
|
+
finalThinkingEnabled = event.thinkingEnabled;
|
|
762
870
|
}
|
|
763
871
|
}
|
|
764
872
|
else {
|
|
@@ -767,7 +875,9 @@ export class ChatSession {
|
|
|
767
875
|
accumulatedVisible += event.text;
|
|
768
876
|
}
|
|
769
877
|
}
|
|
770
|
-
|
|
878
|
+
const publicEvent = publicStreamEvent(event, constrainedConfig);
|
|
879
|
+
if (publicEvent !== null)
|
|
880
|
+
yield publicEvent;
|
|
771
881
|
}
|
|
772
882
|
}
|
|
773
883
|
catch (err) {
|
|
@@ -799,8 +909,9 @@ export class ChatSession {
|
|
|
799
909
|
// off.
|
|
800
910
|
if (sawFinal && !delegated) {
|
|
801
911
|
this.history.push({ role: 'tool', content, toolCallId, isError });
|
|
802
|
-
this.history.push(buildAssistantMessage(finalRaw
|
|
912
|
+
this.history.push(buildAssistantMessage(selectCommittedStreamText(finalRaw, accumulatedVisible, finalTextAuthoritative), finalToolCalls, finalThinking, finalThinkingEnabled, finalReplayRaw, this.model.replaysAssistantRawText?.() === true));
|
|
803
913
|
this.turnCount++;
|
|
914
|
+
this.commitActiveTools(constrainedConfig);
|
|
804
915
|
this.recordToolCallFanout(finalToolCalls);
|
|
805
916
|
}
|
|
806
917
|
else if (!delegated) {
|
|
@@ -862,6 +973,7 @@ export class ChatSession {
|
|
|
862
973
|
this.turnCount = 0;
|
|
863
974
|
this.unresolvedOkToolCallCount = null;
|
|
864
975
|
this.needsFullReplay = false;
|
|
976
|
+
this.activeTools = this.defaultConfig.tools;
|
|
865
977
|
}
|
|
866
978
|
/**
|
|
867
979
|
* Prime the session history without running inference.
|
|
@@ -929,14 +1041,15 @@ export class ChatSession {
|
|
|
929
1041
|
const mergedConfig = this.mergeConfig(config);
|
|
930
1042
|
const historySnapshot = this.history.slice();
|
|
931
1043
|
const constrainedConfig = await this.constrainToContextCapacity(historySnapshot, mergedConfig);
|
|
932
|
-
const result = await this.model.chatSessionStart(historySnapshot, constrainedConfig);
|
|
933
|
-
this.history.push(buildAssistantMessage(result.text, result.toolCalls));
|
|
1044
|
+
const result = await this.model.chatSessionStart(historySnapshot, withReplayReasoning(constrainedConfig, this.model));
|
|
1045
|
+
this.history.push(buildAssistantMessage(result.text, result.toolCalls, result.thinking, result.thinkingEnabled, result.rawText, this.model.replaysAssistantRawText?.() === true));
|
|
934
1046
|
this.turnCount++;
|
|
935
1047
|
this.needsFullReplay = false;
|
|
936
1048
|
this.lastImagesKey = this.computeTrailingImagesKey();
|
|
937
1049
|
this.lastAudioKey = this.computeTrailingAudioKey();
|
|
1050
|
+
this.commitActiveTools(constrainedConfig);
|
|
938
1051
|
this.recordToolCallFanout(result.toolCalls);
|
|
939
|
-
return result;
|
|
1052
|
+
return publicChatResult(result, constrainedConfig);
|
|
940
1053
|
}
|
|
941
1054
|
finally {
|
|
942
1055
|
this.inFlight = false;
|
|
@@ -971,14 +1084,22 @@ export class ChatSession {
|
|
|
971
1084
|
let accumulated = '';
|
|
972
1085
|
let accumulatedVisible = '';
|
|
973
1086
|
let finalRaw = null;
|
|
1087
|
+
let finalReplayRaw = null;
|
|
1088
|
+
let finalTextAuthoritative;
|
|
974
1089
|
let finalToolCalls;
|
|
1090
|
+
let finalThinking = null;
|
|
1091
|
+
let finalThinkingEnabled = false;
|
|
975
1092
|
try {
|
|
976
|
-
for await (const event of this.model.chatStreamSessionStart(historySnapshot, constrainedConfig, signal)) {
|
|
1093
|
+
for await (const event of this.model.chatStreamSessionStart(historySnapshot, withReplayReasoning(constrainedConfig, this.model), signal)) {
|
|
977
1094
|
if (event.done) {
|
|
978
1095
|
if (event.finishReason !== 'error') {
|
|
979
1096
|
sawFinal = true;
|
|
980
1097
|
finalRaw = event.text;
|
|
1098
|
+
finalReplayRaw = event.rawText;
|
|
1099
|
+
finalTextAuthoritative = event.textAuthoritative;
|
|
981
1100
|
finalToolCalls = event.toolCalls;
|
|
1101
|
+
finalThinking = event.thinking;
|
|
1102
|
+
finalThinkingEnabled = event.thinkingEnabled;
|
|
982
1103
|
}
|
|
983
1104
|
}
|
|
984
1105
|
else {
|
|
@@ -987,7 +1108,9 @@ export class ChatSession {
|
|
|
987
1108
|
accumulatedVisible += event.text;
|
|
988
1109
|
}
|
|
989
1110
|
}
|
|
990
|
-
|
|
1111
|
+
const publicEvent = publicStreamEvent(event, constrainedConfig);
|
|
1112
|
+
if (publicEvent !== null)
|
|
1113
|
+
yield publicEvent;
|
|
991
1114
|
}
|
|
992
1115
|
}
|
|
993
1116
|
finally {
|
|
@@ -997,11 +1120,12 @@ export class ChatSession {
|
|
|
997
1120
|
// mutated on a successful commit — on any non-success exit,
|
|
998
1121
|
// the primed state is left intact so the caller can retry.
|
|
999
1122
|
if (sawFinal) {
|
|
1000
|
-
this.history.push(buildAssistantMessage(finalRaw
|
|
1123
|
+
this.history.push(buildAssistantMessage(selectCommittedStreamText(finalRaw, accumulatedVisible, finalTextAuthoritative), finalToolCalls, finalThinking, finalThinkingEnabled, finalReplayRaw, this.model.replaysAssistantRawText?.() === true));
|
|
1001
1124
|
this.turnCount++;
|
|
1002
1125
|
this.needsFullReplay = false;
|
|
1003
1126
|
this.lastImagesKey = this.computeTrailingImagesKey();
|
|
1004
1127
|
this.lastAudioKey = this.computeTrailingAudioKey();
|
|
1128
|
+
this.commitActiveTools(constrainedConfig);
|
|
1005
1129
|
this.recordToolCallFanout(finalToolCalls);
|
|
1006
1130
|
}
|
|
1007
1131
|
}
|
|
@@ -1102,12 +1226,25 @@ export class ChatSession {
|
|
|
1102
1226
|
const n = countOkToolCalls(toolCalls);
|
|
1103
1227
|
this.unresolvedOkToolCallCount = n > 0 ? n : null;
|
|
1104
1228
|
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Persist the effective tools only when their turn commits successfully.
|
|
1231
|
+
* Preflights and failed/abandoned turns intentionally never call this.
|
|
1232
|
+
*/
|
|
1233
|
+
commitActiveTools(config) {
|
|
1234
|
+
if (config.tools !== undefined) {
|
|
1235
|
+
this.activeTools = config.tools;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1105
1238
|
/**
|
|
1106
1239
|
* Merge default + per-call config and force `reuseCache: true`.
|
|
1107
1240
|
* The session path is a session-reuse operation by construction —
|
|
1108
1241
|
* `reuseCache: false` on the continue path would wipe the very
|
|
1109
1242
|
* cache the delta depends on.
|
|
1110
1243
|
*
|
|
1244
|
+
* Tool resolution here is side-effect free because public capacity
|
|
1245
|
+
* preflights use this same merge path. Successful turn commit sites call
|
|
1246
|
+
* {@link commitActiveTools} after native inference finishes.
|
|
1247
|
+
*
|
|
1111
1248
|
* MTP auto-default: if neither `defaultConfig` nor `overlay`
|
|
1112
1249
|
* sets `enableMtp` AND the underlying model exposes
|
|
1113
1250
|
* `hasMtpWeights()` returning `true`, set `enableMtp = true` so the
|
|
@@ -1124,6 +1261,14 @@ export class ChatSession {
|
|
|
1124
1261
|
...overlay,
|
|
1125
1262
|
reuseCache: true,
|
|
1126
1263
|
};
|
|
1264
|
+
// Tools are part of the committed conversation state. Constructor
|
|
1265
|
+
// defaults seed that state, but must not overwrite a tool set committed by
|
|
1266
|
+
// a later successful turn. A current-call overlay is the only higher
|
|
1267
|
+
// precedence source; it remains provisional until a success site calls
|
|
1268
|
+
// commitActiveTools().
|
|
1269
|
+
if (overlay?.tools === undefined && this.activeTools !== undefined) {
|
|
1270
|
+
merged.tools = this.activeTools;
|
|
1271
|
+
}
|
|
1127
1272
|
if (merged.enableMtp === undefined &&
|
|
1128
1273
|
typeof this.model.hasMtpWeights === 'function' &&
|
|
1129
1274
|
this.model.hasMtpWeights()) {
|
|
@@ -1223,8 +1368,8 @@ export class ChatSession {
|
|
|
1223
1368
|
// (e.g. the assistant reply below) don't retroactively mutate
|
|
1224
1369
|
// what the native side / any mock observed as its `messages`
|
|
1225
1370
|
// argument.
|
|
1226
|
-
const result = await this.model.chatSessionStart(this.history.slice(), constrainedConfig);
|
|
1227
|
-
this.history.push(buildAssistantMessage(result.text, result.toolCalls));
|
|
1371
|
+
const result = await this.model.chatSessionStart(this.history.slice(), withReplayReasoning(constrainedConfig, this.model));
|
|
1372
|
+
this.history.push(buildAssistantMessage(result.text, result.toolCalls, result.thinking, result.thinkingEnabled, result.rawText, this.model.replaysAssistantRawText?.() === true));
|
|
1228
1373
|
this.turnCount++;
|
|
1229
1374
|
this.needsFullReplay = false;
|
|
1230
1375
|
// The start path always re-renders the FULL preserved history, so the
|
|
@@ -1236,8 +1381,9 @@ export class ChatSession {
|
|
|
1236
1381
|
// turn to be mis-detected as a change and replayed twice.
|
|
1237
1382
|
this.lastImagesKey = this.computeTrailingImagesKey();
|
|
1238
1383
|
this.lastAudioKey = this.computeTrailingAudioKey();
|
|
1384
|
+
this.commitActiveTools(constrainedConfig);
|
|
1239
1385
|
this.recordToolCallFanout(result.toolCalls);
|
|
1240
|
-
return result;
|
|
1386
|
+
return publicChatResult(result, constrainedConfig);
|
|
1241
1387
|
}
|
|
1242
1388
|
catch (err) {
|
|
1243
1389
|
// Roll back: drop the tentative user push so history stays
|
|
@@ -1282,17 +1428,25 @@ export class ChatSession {
|
|
|
1282
1428
|
let accumulated = '';
|
|
1283
1429
|
let accumulatedVisible = '';
|
|
1284
1430
|
let finalRaw = null;
|
|
1431
|
+
let finalReplayRaw = null;
|
|
1432
|
+
let finalTextAuthoritative;
|
|
1285
1433
|
let finalToolCalls;
|
|
1434
|
+
let finalThinking = null;
|
|
1435
|
+
let finalThinkingEnabled = false;
|
|
1286
1436
|
// Snapshot the history before dispatch — see `runStartPath` for
|
|
1287
1437
|
// the rationale.
|
|
1288
1438
|
const historySnapshot = this.history.slice();
|
|
1289
1439
|
try {
|
|
1290
|
-
for await (const event of this.model.chatStreamSessionStart(historySnapshot, constrainedConfig, signal)) {
|
|
1440
|
+
for await (const event of this.model.chatStreamSessionStart(historySnapshot, withReplayReasoning(constrainedConfig, this.model), signal)) {
|
|
1291
1441
|
if (event.done) {
|
|
1292
1442
|
if (event.finishReason !== 'error') {
|
|
1293
1443
|
sawFinal = true;
|
|
1294
1444
|
finalRaw = event.text;
|
|
1445
|
+
finalReplayRaw = event.rawText;
|
|
1446
|
+
finalTextAuthoritative = event.textAuthoritative;
|
|
1295
1447
|
finalToolCalls = event.toolCalls;
|
|
1448
|
+
finalThinking = event.thinking;
|
|
1449
|
+
finalThinkingEnabled = event.thinkingEnabled;
|
|
1296
1450
|
}
|
|
1297
1451
|
}
|
|
1298
1452
|
else {
|
|
@@ -1301,7 +1455,9 @@ export class ChatSession {
|
|
|
1301
1455
|
accumulatedVisible += event.text;
|
|
1302
1456
|
}
|
|
1303
1457
|
}
|
|
1304
|
-
|
|
1458
|
+
const publicEvent = publicStreamEvent(event, constrainedConfig);
|
|
1459
|
+
if (publicEvent !== null)
|
|
1460
|
+
yield publicEvent;
|
|
1305
1461
|
}
|
|
1306
1462
|
}
|
|
1307
1463
|
finally {
|
|
@@ -1314,7 +1470,7 @@ export class ChatSession {
|
|
|
1314
1470
|
// generator was wound down. Mid-stream throws still propagate
|
|
1315
1471
|
// naturally — finally runs first, then the error continues up.
|
|
1316
1472
|
if (sawFinal) {
|
|
1317
|
-
this.history.push(buildAssistantMessage(finalRaw
|
|
1473
|
+
this.history.push(buildAssistantMessage(selectCommittedStreamText(finalRaw, accumulatedVisible, finalTextAuthoritative), finalToolCalls, finalThinking, finalThinkingEnabled, finalReplayRaw, this.model.replaysAssistantRawText?.() === true));
|
|
1318
1474
|
this.turnCount++;
|
|
1319
1475
|
this.needsFullReplay = false;
|
|
1320
1476
|
// The start path always re-renders the FULL preserved history, so the
|
|
@@ -1326,6 +1482,7 @@ export class ChatSession {
|
|
|
1326
1482
|
// turn to be mis-detected as a change and replayed twice.
|
|
1327
1483
|
this.lastImagesKey = this.computeTrailingImagesKey();
|
|
1328
1484
|
this.lastAudioKey = this.computeTrailingAudioKey();
|
|
1485
|
+
this.commitActiveTools(constrainedConfig);
|
|
1329
1486
|
this.recordToolCallFanout(finalToolCalls);
|
|
1330
1487
|
}
|
|
1331
1488
|
else {
|