@mlx-node/lm 0.0.6 → 0.0.7
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 +71 -46
- package/dist/chat-session.d.ts +455 -0
- package/dist/chat-session.d.ts.map +1 -0
- package/dist/chat-session.js +920 -0
- package/dist/index.d.ts +14 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -4
- package/dist/interfaces.d.ts +11 -1
- package/dist/interfaces.d.ts.map +1 -1
- package/dist/models/lfm2-configs.d.ts +6 -0
- package/dist/models/lfm2-configs.d.ts.map +1 -0
- package/dist/models/lfm2-configs.js +48 -0
- package/dist/models/model-loader.d.ts +25 -4
- package/dist/models/model-loader.d.ts.map +1 -1
- package/dist/models/model-loader.js +62 -7
- package/dist/stream.d.ts +154 -26
- package/dist/stream.d.ts.map +1 -1
- package/dist/stream.js +346 -37
- package/dist/tools/index.d.ts +38 -11
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +38 -11
- package/dist/tools/types.d.ts +5 -25
- package/dist/tools/types.d.ts.map +1 -1
- package/dist/tools/types.js +5 -30
- package/package.json +3 -3
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert the parsed `ToolCallResult[]` emitted by the native chat
|
|
3
|
+
* pipeline into the `ToolCall[]` shape expected by
|
|
4
|
+
* `ChatMessage.toolCalls` (and, by extension, the jinja chat
|
|
5
|
+
* templates on cold replay).
|
|
6
|
+
*
|
|
7
|
+
* Two shape differences to bridge:
|
|
8
|
+
*
|
|
9
|
+
* 1. `ToolCallResult.arguments` is `Record<string, unknown> | string`
|
|
10
|
+
* (already parsed by the native parser when status is "ok",
|
|
11
|
+
* preserved as the original string on parse failure). The
|
|
12
|
+
* `ChatMessage.toolCalls` contract is `arguments: string`, and
|
|
13
|
+
* the native tokenizer's `render_chat_template` pre-parses that
|
|
14
|
+
* string back into a `serde_json::Value` before handing it to
|
|
15
|
+
* jinja. We therefore `JSON.stringify` any non-string argument
|
|
16
|
+
* so the round-trip is lossless. Strings are passed through
|
|
17
|
+
* verbatim so a failed-to-parse payload retains its original
|
|
18
|
+
* bytes (the template then sees it as a quoted string, which is
|
|
19
|
+
* the safest available fallback).
|
|
20
|
+
* 2. Only `status === "ok"` calls carry a well-formed
|
|
21
|
+
* `(name, arguments)` pair — the other statuses (`invalid_json`,
|
|
22
|
+
* `missing_name`, `parse_error`) are informational diagnostics
|
|
23
|
+
* that the native parser emits for observability and that the
|
|
24
|
+
* downstream chat template has no way to render. Preserving them
|
|
25
|
+
* on the replay path would inject garbage tool-call tags into
|
|
26
|
+
* the jinja output. We filter to `ok` entries only — matching the
|
|
27
|
+
* filter every other consumer (server response mapper, tool-use
|
|
28
|
+
* examples, README guidance) already applies.
|
|
29
|
+
*
|
|
30
|
+
* Returns `undefined` when the input is absent or yields no `ok`
|
|
31
|
+
* entries so the assistant `ChatMessage` stays minimal (no empty
|
|
32
|
+
* `toolCalls: []` field polluting the history).
|
|
33
|
+
*/
|
|
34
|
+
function toAssistantToolCalls(toolCalls) {
|
|
35
|
+
if (!toolCalls || toolCalls.length === 0)
|
|
36
|
+
return undefined;
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const tc of toolCalls) {
|
|
39
|
+
if (tc.status !== 'ok')
|
|
40
|
+
continue;
|
|
41
|
+
const argsStr = typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments);
|
|
42
|
+
out.push({ id: tc.id, name: tc.name, arguments: argsStr });
|
|
43
|
+
}
|
|
44
|
+
return out.length > 0 ? out : undefined;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Build an assistant `ChatMessage` from a just-completed turn's
|
|
48
|
+
* decoded text + tool-call list. The assistant entry is appended to
|
|
49
|
+
* `this.history` after every successful turn and is later read back
|
|
50
|
+
* by the native `chatSessionStart` cold-replay path (image-change
|
|
51
|
+
* mid-session restart, `startFromHistory*`, server-side
|
|
52
|
+
* `SessionRegistry` cache-miss rebuild). Dropping the `toolCalls`
|
|
53
|
+
* field here would orphan any subsequent `{role: 'tool', ...}`
|
|
54
|
+
* entries on replay — the jinja template would render a
|
|
55
|
+
* `<tool_response>` for a call that was never declared on the
|
|
56
|
+
* preceding assistant turn, corrupting the conversation structure
|
|
57
|
+
* and changing model behavior after a restart.
|
|
58
|
+
*/
|
|
59
|
+
function buildAssistantMessage(text, toolCalls) {
|
|
60
|
+
const calls = toAssistantToolCalls(toolCalls);
|
|
61
|
+
if (calls) {
|
|
62
|
+
return { role: 'assistant', content: text, toolCalls: calls };
|
|
63
|
+
}
|
|
64
|
+
return { role: 'assistant', content: text };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Count the `ok`-status tool calls in a `ChatResult.toolCalls` /
|
|
68
|
+
* terminal stream chunk. Used to detect the unsupported multi-call
|
|
69
|
+
* fan-out pattern — the chat-session API only serves one tool call
|
|
70
|
+
* per assistant turn because each `sendToolResult` dispatch
|
|
71
|
+
* immediately re-opens the assistant turn, so a second result would
|
|
72
|
+
* land after a new assistant reply and corrupt the conversation
|
|
73
|
+
* structure. Non-`ok` entries (`parse_error`, `invalid_json`, etc.)
|
|
74
|
+
* are ignored because the caller cannot respond to them anyway.
|
|
75
|
+
*/
|
|
76
|
+
function countOkToolCalls(toolCalls) {
|
|
77
|
+
if (!toolCalls || toolCalls.length === 0)
|
|
78
|
+
return 0;
|
|
79
|
+
let n = 0;
|
|
80
|
+
for (const c of toolCalls) {
|
|
81
|
+
if (c.status === 'ok')
|
|
82
|
+
n++;
|
|
83
|
+
}
|
|
84
|
+
return n;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Compute a stable hex-encoded identity key for a list of image
|
|
88
|
+
* byte buffers.
|
|
89
|
+
*
|
|
90
|
+
* Returns `null` when no images are provided so `send()` can
|
|
91
|
+
* distinguish "no-images" from "image set changed". The key is
|
|
92
|
+
* order-sensitive: `[A, B]` and `[B, A]` produce different keys,
|
|
93
|
+
* matching the positional semantics of the underlying VLM chat
|
|
94
|
+
* template.
|
|
95
|
+
*
|
|
96
|
+
* This is a byte-identity check — callers use the key solely to
|
|
97
|
+
* decide whether to restart the server-side session, so a
|
|
98
|
+
* non-cryptographic hash is sufficient. We use FNV-1a 64-bit with a
|
|
99
|
+
* length-prefixed framing so different image counts and different
|
|
100
|
+
* byte lengths cannot collide by accident.
|
|
101
|
+
*
|
|
102
|
+
* Implementation note: kept fully sync + self-contained so
|
|
103
|
+
* `send()` can stay synchronous in its routing decision and so the
|
|
104
|
+
* module has no external runtime dependencies beyond `@mlx-node/core`
|
|
105
|
+
* and the existing stream bridge.
|
|
106
|
+
*/
|
|
107
|
+
function computeImagesKey(images) {
|
|
108
|
+
if (!images || images.length === 0)
|
|
109
|
+
return null;
|
|
110
|
+
// FNV-1a 64-bit. Split into two 32-bit halves because JavaScript
|
|
111
|
+
// doesn't have a native 64-bit integer type and BigInt ops are
|
|
112
|
+
// slow on large byte streams. This emulates 64-bit FNV-1a using
|
|
113
|
+
// paired 32-bit lo/hi halves — the standard JS idiom.
|
|
114
|
+
const FNV_OFFSET_LO = 0x84222325 >>> 0;
|
|
115
|
+
const FNV_OFFSET_HI = 0xcbf29ce4 >>> 0;
|
|
116
|
+
const FNV_PRIME_LO = 0x000001b3 >>> 0;
|
|
117
|
+
const FNV_PRIME_HI = 0x00000100 >>> 0;
|
|
118
|
+
let lo = FNV_OFFSET_LO;
|
|
119
|
+
let hi = FNV_OFFSET_HI;
|
|
120
|
+
function mix(byte) {
|
|
121
|
+
lo = (lo ^ byte) >>> 0;
|
|
122
|
+
// Multiply (hi:lo) by (FNV_PRIME_HI:FNV_PRIME_LO) mod 2^64.
|
|
123
|
+
// Break 32-bit halves into 16-bit quarters to keep intermediate
|
|
124
|
+
// products inside the safe-integer range.
|
|
125
|
+
const loLo = lo & 0xffff;
|
|
126
|
+
const loHi = lo >>> 16;
|
|
127
|
+
const hiLo = hi & 0xffff;
|
|
128
|
+
const hiHi = hi >>> 16;
|
|
129
|
+
const pLo = FNV_PRIME_LO & 0xffff;
|
|
130
|
+
const pLoH = FNV_PRIME_LO >>> 16;
|
|
131
|
+
const pHi = FNV_PRIME_HI & 0xffff;
|
|
132
|
+
const pHiH = FNV_PRIME_HI >>> 16;
|
|
133
|
+
const r0 = loLo * pLo;
|
|
134
|
+
const r1 = loLo * pLoH + loHi * pLo;
|
|
135
|
+
const r2 = loLo * pHi + loHi * pLoH + hiLo * pLo;
|
|
136
|
+
const r3 = loLo * pHiH + loHi * pHi + hiLo * pLoH + hiHi * pLo;
|
|
137
|
+
const newLo0 = r0 & 0xffff;
|
|
138
|
+
const carry1 = r0 >>> 16;
|
|
139
|
+
const sum1 = r1 + carry1;
|
|
140
|
+
const newLo1 = sum1 & 0xffff;
|
|
141
|
+
const carry2 = Math.floor(sum1 / 0x10000);
|
|
142
|
+
const sum2 = r2 + carry2;
|
|
143
|
+
const newHi0 = sum2 & 0xffff;
|
|
144
|
+
const carry3 = Math.floor(sum2 / 0x10000);
|
|
145
|
+
const sum3 = r3 + carry3;
|
|
146
|
+
const newHi1 = sum3 & 0xffff;
|
|
147
|
+
lo = ((newLo1 << 16) | newLo0) >>> 0;
|
|
148
|
+
hi = ((newHi1 << 16) | newHi0) >>> 0;
|
|
149
|
+
}
|
|
150
|
+
// Frame each image with a 4-byte little-endian length prefix so
|
|
151
|
+
// `[ab, c]` and `[a, bc]` hash to distinct values.
|
|
152
|
+
mix(images.length & 0xff);
|
|
153
|
+
mix((images.length >>> 8) & 0xff);
|
|
154
|
+
mix((images.length >>> 16) & 0xff);
|
|
155
|
+
mix((images.length >>> 24) & 0xff);
|
|
156
|
+
for (const img of images) {
|
|
157
|
+
mix(img.byteLength & 0xff);
|
|
158
|
+
mix((img.byteLength >>> 8) & 0xff);
|
|
159
|
+
mix((img.byteLength >>> 16) & 0xff);
|
|
160
|
+
mix((img.byteLength >>> 24) & 0xff);
|
|
161
|
+
for (let i = 0; i < img.byteLength; i++) {
|
|
162
|
+
mix(img[i]);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return hi.toString(16).padStart(8, '0') + lo.toString(16).padStart(8, '0');
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Cross-model chat session. See module docstring for design notes.
|
|
169
|
+
*
|
|
170
|
+
* The generic parameter `M` statically captures the concrete model
|
|
171
|
+
* type so the structural interface stays as expressive as the
|
|
172
|
+
* concrete one. Internally the class only uses the
|
|
173
|
+
* `SessionCapableModel` surface.
|
|
174
|
+
*/
|
|
175
|
+
export class ChatSession {
|
|
176
|
+
model;
|
|
177
|
+
system;
|
|
178
|
+
defaultConfig;
|
|
179
|
+
/**
|
|
180
|
+
* Full conversation history tracked on the TS side. Appended to on
|
|
181
|
+
* every successful turn. Only read back when the image-change path
|
|
182
|
+
* triggers a restart — normal text continues use the server-side
|
|
183
|
+
* cache, not this array.
|
|
184
|
+
*/
|
|
185
|
+
history = [];
|
|
186
|
+
/**
|
|
187
|
+
* Hex-encoded byte-identity key of the image set currently bound
|
|
188
|
+
* to the server's KV cache (FNV-1a 64-bit; see `computeImagesKey`).
|
|
189
|
+
* `null` when no images are cached. A `send()` whose new key
|
|
190
|
+
* differs triggers a full `chatSessionStart` restart.
|
|
191
|
+
*/
|
|
192
|
+
lastImagesKey = null;
|
|
193
|
+
turnCount = 0;
|
|
194
|
+
inFlight = false;
|
|
195
|
+
/**
|
|
196
|
+
* Count of `ok` tool calls emitted by the prior assistant turn, or
|
|
197
|
+
* `null` when the prior turn produced none. Gates every continuation
|
|
198
|
+
* entry point on the tool-call resolution invariant because each
|
|
199
|
+
* native `chat_session_continue*` dispatch re-opens the assistant
|
|
200
|
+
* turn:
|
|
201
|
+
*
|
|
202
|
+
* - A plain text `send` / `sendStream` after ANY outstanding tool
|
|
203
|
+
* call would orphan the call(s) by weaving a fresh user turn
|
|
204
|
+
* between the assistant's `tool_call` and any response.
|
|
205
|
+
* - A `sendToolResult` / `sendToolResultStream` is only servable
|
|
206
|
+
* when exactly one tool call is outstanding. A multi-call
|
|
207
|
+
* fan-out (`> 1`) cannot be resolved one result at a time — the
|
|
208
|
+
* siblings would be separated by fresh assistant replies — so
|
|
209
|
+
* those entry points also reject.
|
|
210
|
+
*
|
|
211
|
+
* Cleared on every successful commit whose new turn emits zero `ok`
|
|
212
|
+
* tool calls, and on `reset()`. See `assertCanSendPlain` /
|
|
213
|
+
* `assertCanSendToolResult` for the per-entry-point gate logic.
|
|
214
|
+
*/
|
|
215
|
+
unresolvedOkToolCallCount = null;
|
|
216
|
+
constructor(model, options = {}) {
|
|
217
|
+
this.model = model;
|
|
218
|
+
this.system = options.system;
|
|
219
|
+
this.defaultConfig = options.defaultConfig ?? {};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Number of completed turns. Increments only after a successful
|
|
223
|
+
* round-trip — in-flight or failed calls leave this untouched.
|
|
224
|
+
*/
|
|
225
|
+
get turns() {
|
|
226
|
+
return this.turnCount;
|
|
227
|
+
}
|
|
228
|
+
/** Whether the session currently has images bound to its cache. */
|
|
229
|
+
get hasImages() {
|
|
230
|
+
return this.lastImagesKey !== null;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Count of `ok` tool calls from the most recent assistant turn, or
|
|
234
|
+
* `null` when the trailing turn produced none. Non-null means the
|
|
235
|
+
* session is parked on an unresolved tool-call turn and the only
|
|
236
|
+
* forward-progress move is `sendToolResult*()` against one of the
|
|
237
|
+
* outstanding ids — and only when the count is exactly 1. A
|
|
238
|
+
* multi-call fan-out (`> 1`) cannot be served by the chat-session
|
|
239
|
+
* API at all; server endpoints should pre-check this getter and
|
|
240
|
+
* route around a fan-out via `reset()` + `primeHistory()` +
|
|
241
|
+
* `startFromHistory()` cold replay that resolves every sibling in
|
|
242
|
+
* one atomic jinja render.
|
|
243
|
+
*
|
|
244
|
+
* The flag updates after every successful `send` / `sendStream` /
|
|
245
|
+
* `sendToolResult` / `sendToolResultStream` / `startFromHistory*`
|
|
246
|
+
* commit, and after `primeHistory()` (from the trailing assistant
|
|
247
|
+
* message's `toolCalls.length`). `reset()` clears it.
|
|
248
|
+
*/
|
|
249
|
+
get pendingUnresolvedToolCallCount() {
|
|
250
|
+
return this.unresolvedOkToolCallCount;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Send a user message and resolve with the assistant reply.
|
|
254
|
+
*
|
|
255
|
+
* Turn 0 and any turn whose image set has changed dispatch through
|
|
256
|
+
* `chatSessionStart` with the full history. All other turns use
|
|
257
|
+
* the cheap `chatSessionContinue` delta path.
|
|
258
|
+
*/
|
|
259
|
+
async send(userMessage, opts = {}) {
|
|
260
|
+
if (this.inFlight) {
|
|
261
|
+
throw new Error('ChatSession: concurrent send() not allowed; await the previous call first');
|
|
262
|
+
}
|
|
263
|
+
this.assertCanSendPlain('send');
|
|
264
|
+
this.inFlight = true;
|
|
265
|
+
try {
|
|
266
|
+
const mergedConfig = this.mergeConfig(opts.config);
|
|
267
|
+
const newImagesKey = computeImagesKey(opts.images);
|
|
268
|
+
// Only an explicit NEW image set can trigger a restart. Omitting
|
|
269
|
+
// `images` (newImagesKey === null) is interpreted as "keep the
|
|
270
|
+
// current image cache state" — the server-side cache already
|
|
271
|
+
// holds any prior image context, so a text-only follow-up like
|
|
272
|
+
// "what about the top-right?" can stay on the cheap delta path
|
|
273
|
+
// even after an image turn.
|
|
274
|
+
const imageChanged = newImagesKey !== null && newImagesKey !== this.lastImagesKey;
|
|
275
|
+
const isFirstTurn = this.turnCount === 0;
|
|
276
|
+
if (isFirstTurn || imageChanged) {
|
|
277
|
+
return await this.runStartPath(userMessage, opts.images, newImagesKey, imageChanged, isFirstTurn, mergedConfig);
|
|
278
|
+
}
|
|
279
|
+
// Delta continue: text-only, images always null. The server
|
|
280
|
+
// cache already holds all prior turns (including any images
|
|
281
|
+
// from an earlier restart), so we only need to ship the new
|
|
282
|
+
// user string.
|
|
283
|
+
const result = await this.model.chatSessionContinue(userMessage, null, mergedConfig);
|
|
284
|
+
this.history.push({ role: 'user', content: userMessage });
|
|
285
|
+
this.history.push(buildAssistantMessage(result.text, result.toolCalls));
|
|
286
|
+
this.turnCount++;
|
|
287
|
+
this.recordToolCallFanout(result.toolCalls);
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
this.inFlight = false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Streaming variant of {@link ChatSession#send}.
|
|
296
|
+
*
|
|
297
|
+
* Routing matches `send()`. The assistant reply is accumulated
|
|
298
|
+
* from stream deltas and pushed to `history` only after a
|
|
299
|
+
* successful terminal chunk (`done: true` with non-error
|
|
300
|
+
* `finishReason`). Caller break, mid-stream exceptions, and error
|
|
301
|
+
* finishes all leave `turnCount` untouched and the history
|
|
302
|
+
* un-appended for the turn so the next call re-routes through the
|
|
303
|
+
* start path.
|
|
304
|
+
*/
|
|
305
|
+
async *sendStream(userMessage, opts = {}) {
|
|
306
|
+
if (this.inFlight) {
|
|
307
|
+
throw new Error('ChatSession: concurrent send() not allowed; await the previous call first');
|
|
308
|
+
}
|
|
309
|
+
this.assertCanSendPlain('sendStream');
|
|
310
|
+
this.inFlight = true;
|
|
311
|
+
try {
|
|
312
|
+
const mergedConfig = this.mergeConfig(opts.config);
|
|
313
|
+
const newImagesKey = computeImagesKey(opts.images);
|
|
314
|
+
// Only an explicit NEW image set can trigger a restart. Omitting
|
|
315
|
+
// `images` (newImagesKey === null) is interpreted as "keep the
|
|
316
|
+
// current image cache state" — the server-side cache already
|
|
317
|
+
// holds any prior image context, so a text-only follow-up like
|
|
318
|
+
// "what about the top-right?" can stay on the cheap delta path
|
|
319
|
+
// even after an image turn.
|
|
320
|
+
const imageChanged = newImagesKey !== null && newImagesKey !== this.lastImagesKey;
|
|
321
|
+
const isFirstTurn = this.turnCount === 0;
|
|
322
|
+
if (isFirstTurn || imageChanged) {
|
|
323
|
+
yield* this.runStartStreamPath(userMessage, opts.images, newImagesKey, imageChanged, isFirstTurn, mergedConfig, opts.signal);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// Delta continue stream: text-only.
|
|
327
|
+
let sawFinal = false;
|
|
328
|
+
let accumulated = '';
|
|
329
|
+
let finalRaw = null;
|
|
330
|
+
let finalToolCalls;
|
|
331
|
+
try {
|
|
332
|
+
for await (const event of this.model.chatStreamSessionContinue(userMessage, null, mergedConfig, opts.signal)) {
|
|
333
|
+
if (event.done) {
|
|
334
|
+
if (event.finishReason !== 'error') {
|
|
335
|
+
sawFinal = true;
|
|
336
|
+
finalRaw = event.text;
|
|
337
|
+
finalToolCalls = event.toolCalls;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
accumulated += event.text;
|
|
342
|
+
}
|
|
343
|
+
yield event;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
// finally runs for normal completion, mid-stream throw,
|
|
348
|
+
// caller `break` (which calls `iterator.return()` and
|
|
349
|
+
// short-circuits the suspended yield), and error-finish
|
|
350
|
+
// chunks alike. The delta path doesn't push to history until
|
|
351
|
+
// commit, so the rollback branch is a no-op: nothing to
|
|
352
|
+
// undo, and the native cache state is managed by the Rust
|
|
353
|
+
// save_cache_state path on its own.
|
|
354
|
+
if (sawFinal) {
|
|
355
|
+
this.history.push({ role: 'user', content: userMessage });
|
|
356
|
+
this.history.push(buildAssistantMessage(finalRaw ?? accumulated, finalToolCalls));
|
|
357
|
+
this.turnCount++;
|
|
358
|
+
this.recordToolCallFanout(finalToolCalls);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
this.inFlight = false;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Send a tool-result turn. Always dispatches
|
|
368
|
+
* `chatSessionContinueTool` — tool turns never change image state,
|
|
369
|
+
* so there is no restart path here.
|
|
370
|
+
*
|
|
371
|
+
* Rejects if the prior assistant turn emitted more than one `ok`
|
|
372
|
+
* tool call: the chat-session API only supports exactly one tool
|
|
373
|
+
* call per assistant turn because each `sendToolResult` dispatch
|
|
374
|
+
* immediately re-opens the assistant turn, so responding to the
|
|
375
|
+
* remaining calls would interleave new assistant replies between
|
|
376
|
+
* the results and corrupt the conversation structure. Callers that
|
|
377
|
+
* hit this must tighten the prompt / tool spec or reset the
|
|
378
|
+
* session.
|
|
379
|
+
*
|
|
380
|
+
* Appends a `{ role: 'tool', ... }` message to history on success.
|
|
381
|
+
*/
|
|
382
|
+
async sendToolResult(toolCallId, content, opts = {}) {
|
|
383
|
+
if (this.inFlight) {
|
|
384
|
+
throw new Error('ChatSession: concurrent send() not allowed; await the previous call first');
|
|
385
|
+
}
|
|
386
|
+
this.assertCanSendToolResult('sendToolResult');
|
|
387
|
+
this.inFlight = true;
|
|
388
|
+
try {
|
|
389
|
+
const mergedConfig = this.mergeConfig(opts.config);
|
|
390
|
+
const result = await this.model.chatSessionContinueTool(toolCallId, content, mergedConfig);
|
|
391
|
+
this.history.push({ role: 'tool', content, toolCallId });
|
|
392
|
+
this.history.push(buildAssistantMessage(result.text, result.toolCalls));
|
|
393
|
+
this.turnCount++;
|
|
394
|
+
this.recordToolCallFanout(result.toolCalls);
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
this.inFlight = false;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/** Streaming variant of {@link ChatSession#sendToolResult}. */
|
|
402
|
+
async *sendToolResultStream(toolCallId, content, opts = {}) {
|
|
403
|
+
if (this.inFlight) {
|
|
404
|
+
throw new Error('ChatSession: concurrent send() not allowed; await the previous call first');
|
|
405
|
+
}
|
|
406
|
+
this.assertCanSendToolResult('sendToolResultStream');
|
|
407
|
+
this.inFlight = true;
|
|
408
|
+
try {
|
|
409
|
+
const mergedConfig = this.mergeConfig(opts.config);
|
|
410
|
+
let sawFinal = false;
|
|
411
|
+
let accumulated = '';
|
|
412
|
+
let finalRaw = null;
|
|
413
|
+
let finalToolCalls;
|
|
414
|
+
try {
|
|
415
|
+
for await (const event of this.model.chatStreamSessionContinueTool(toolCallId, content, mergedConfig, opts.signal)) {
|
|
416
|
+
if (event.done) {
|
|
417
|
+
if (event.finishReason !== 'error') {
|
|
418
|
+
sawFinal = true;
|
|
419
|
+
finalRaw = event.text;
|
|
420
|
+
finalToolCalls = event.toolCalls;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
accumulated += event.text;
|
|
425
|
+
}
|
|
426
|
+
yield event;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
finally {
|
|
430
|
+
// finally runs for normal completion, mid-stream throw,
|
|
431
|
+
// caller `break` (iterator.return() short-circuits the yield),
|
|
432
|
+
// and error-finish chunks alike. Tool turns never touch
|
|
433
|
+
// history until commit, so the rollback branch is a no-op.
|
|
434
|
+
if (sawFinal) {
|
|
435
|
+
this.history.push({ role: 'tool', content, toolCallId });
|
|
436
|
+
this.history.push(buildAssistantMessage(finalRaw ?? accumulated, finalToolCalls));
|
|
437
|
+
this.turnCount++;
|
|
438
|
+
this.recordToolCallFanout(finalToolCalls);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
finally {
|
|
443
|
+
this.inFlight = false;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Reset the session state.
|
|
448
|
+
*
|
|
449
|
+
* Clears the underlying model's KV caches and wipes local history,
|
|
450
|
+
* image key, and turn counter so the next `send()` goes through
|
|
451
|
+
* `chatSessionStart` again.
|
|
452
|
+
*
|
|
453
|
+
* Returns `Promise<void>` for an async-friendly signature even
|
|
454
|
+
* though `resetCaches()` is currently synchronous.
|
|
455
|
+
*/
|
|
456
|
+
async reset() {
|
|
457
|
+
if (this.inFlight) {
|
|
458
|
+
throw new Error('ChatSession: cannot reset() while a send() is in flight; await the previous call first');
|
|
459
|
+
}
|
|
460
|
+
this.model.resetCaches();
|
|
461
|
+
this.history = [];
|
|
462
|
+
this.lastImagesKey = null;
|
|
463
|
+
this.turnCount = 0;
|
|
464
|
+
this.unresolvedOkToolCallCount = null;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Prime the session history without running inference.
|
|
468
|
+
*
|
|
469
|
+
* Used by the server-side `SessionRegistry` cold-start fallback: when
|
|
470
|
+
* a request arrives with a `previous_response_id` that the cache has
|
|
471
|
+
* missed, the endpoint reconstructs the full conversation from the
|
|
472
|
+
* `ResponseStore` and primes a fresh session with it, then calls
|
|
473
|
+
* `startFromHistory()` to replay it through the native KV cache.
|
|
474
|
+
*
|
|
475
|
+
* Rejects if the session is in flight or has already taken a turn.
|
|
476
|
+
* Replaces the internal history with a shallow copy of `messages`.
|
|
477
|
+
*/
|
|
478
|
+
primeHistory(messages) {
|
|
479
|
+
if (this.inFlight) {
|
|
480
|
+
throw new Error('ChatSession: cannot primeHistory() while a send() is in flight');
|
|
481
|
+
}
|
|
482
|
+
if (this.turnCount > 0) {
|
|
483
|
+
throw new Error('ChatSession: primeHistory() can only be called on a fresh session (turn 0)');
|
|
484
|
+
}
|
|
485
|
+
this.history = messages.slice();
|
|
486
|
+
// Derive the unresolved-tool-call guard from the trailing assistant
|
|
487
|
+
// turn in the primed history so an immediately-post-prime session
|
|
488
|
+
// exposes the same `pendingUnresolvedToolCallCount` state a live
|
|
489
|
+
// session would have been in at that point of the conversation.
|
|
490
|
+
// This lets the server endpoint layer (and any other caller)
|
|
491
|
+
// pre-check the guard before starting cold replay and route around
|
|
492
|
+
// unresolved turns instead of letting `startFromHistory*()` blindly
|
|
493
|
+
// advance past them. The flag is reset on commit in both sync and
|
|
494
|
+
// streaming start-from-history paths based on the new assistant
|
|
495
|
+
// reply, which is the correct semantics for the post-replay current
|
|
496
|
+
// position.
|
|
497
|
+
this.unresolvedOkToolCallCount = this.computeTrailingAssistantUnresolvedToolCallCount();
|
|
498
|
+
// lastImagesKey stays null until startFromHistory() / send() runs —
|
|
499
|
+
// the trailing-images hydration happens at commit time.
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Run a cold-start `chatSessionStart` using the currently primed
|
|
503
|
+
* history.
|
|
504
|
+
*
|
|
505
|
+
* Intended pairing with {@link primeHistory}: call
|
|
506
|
+
* `primeHistory(fullHistory)` first, then `startFromHistory()` to
|
|
507
|
+
* replay the conversation through the native chat-session API. The
|
|
508
|
+
* final history entry must be a user or tool turn — this is what the
|
|
509
|
+
* native side treats as the "current input" to generate against.
|
|
510
|
+
*
|
|
511
|
+
* Pushes the assistant reply onto the history, advances `turnCount`
|
|
512
|
+
* to 1, and computes `lastImagesKey` from the most recent user
|
|
513
|
+
* message that carries images (so subsequent text-only continues
|
|
514
|
+
* stay on the delta path, and subsequent image turns correctly
|
|
515
|
+
* trigger restart).
|
|
516
|
+
*/
|
|
517
|
+
async startFromHistory(config) {
|
|
518
|
+
if (this.inFlight) {
|
|
519
|
+
throw new Error('ChatSession: cannot startFromHistory() while a send() is in flight');
|
|
520
|
+
}
|
|
521
|
+
if (this.turnCount > 0) {
|
|
522
|
+
throw new Error('ChatSession: startFromHistory() can only be called on a fresh session');
|
|
523
|
+
}
|
|
524
|
+
if (this.history.length === 0) {
|
|
525
|
+
throw new Error('ChatSession: startFromHistory() requires a primed history');
|
|
526
|
+
}
|
|
527
|
+
this.inFlight = true;
|
|
528
|
+
try {
|
|
529
|
+
const mergedConfig = this.mergeConfig(config);
|
|
530
|
+
const result = await this.model.chatSessionStart(this.history.slice(), mergedConfig);
|
|
531
|
+
this.history.push(buildAssistantMessage(result.text, result.toolCalls));
|
|
532
|
+
this.turnCount++;
|
|
533
|
+
this.lastImagesKey = this.computeTrailingImagesKey();
|
|
534
|
+
this.recordToolCallFanout(result.toolCalls);
|
|
535
|
+
return result;
|
|
536
|
+
}
|
|
537
|
+
finally {
|
|
538
|
+
this.inFlight = false;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Streaming counterpart to {@link startFromHistory}.
|
|
543
|
+
*
|
|
544
|
+
* Iterates `model.chatStreamSessionStart(history.slice(), config)`,
|
|
545
|
+
* accumulates text, and only commits history + `turnCount` +
|
|
546
|
+
* `lastImagesKey` in the `finally` block when a successful terminal
|
|
547
|
+
* chunk was observed (`done: true` with non-error finishReason).
|
|
548
|
+
* Because history is primed (not appended to), rollback on failure
|
|
549
|
+
* is a no-op: the primed state stays intact so the caller can retry.
|
|
550
|
+
*/
|
|
551
|
+
async *startFromHistoryStream(config, signal) {
|
|
552
|
+
if (this.inFlight) {
|
|
553
|
+
throw new Error('ChatSession: cannot startFromHistoryStream() while a send() is in flight');
|
|
554
|
+
}
|
|
555
|
+
if (this.turnCount > 0) {
|
|
556
|
+
throw new Error('ChatSession: startFromHistoryStream() can only be called on a fresh session');
|
|
557
|
+
}
|
|
558
|
+
if (this.history.length === 0) {
|
|
559
|
+
throw new Error('ChatSession: startFromHistoryStream() requires a primed history');
|
|
560
|
+
}
|
|
561
|
+
this.inFlight = true;
|
|
562
|
+
try {
|
|
563
|
+
const mergedConfig = this.mergeConfig(config);
|
|
564
|
+
const historySnapshot = this.history.slice();
|
|
565
|
+
let sawFinal = false;
|
|
566
|
+
let accumulated = '';
|
|
567
|
+
let finalRaw = null;
|
|
568
|
+
let finalToolCalls;
|
|
569
|
+
try {
|
|
570
|
+
for await (const event of this.model.chatStreamSessionStart(historySnapshot, mergedConfig, signal)) {
|
|
571
|
+
if (event.done) {
|
|
572
|
+
if (event.finishReason !== 'error') {
|
|
573
|
+
sawFinal = true;
|
|
574
|
+
finalRaw = event.text;
|
|
575
|
+
finalToolCalls = event.toolCalls;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
accumulated += event.text;
|
|
580
|
+
}
|
|
581
|
+
yield event;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
finally {
|
|
585
|
+
// finally runs on normal completion, mid-stream throw, caller
|
|
586
|
+
// `break` (iterator.return() short-circuits the yield), and
|
|
587
|
+
// error-finish chunks alike. The primed history is only
|
|
588
|
+
// mutated on a successful commit — on any non-success exit,
|
|
589
|
+
// the primed state is left intact so the caller can retry.
|
|
590
|
+
if (sawFinal) {
|
|
591
|
+
this.history.push(buildAssistantMessage(finalRaw ?? accumulated, finalToolCalls));
|
|
592
|
+
this.turnCount++;
|
|
593
|
+
this.lastImagesKey = this.computeTrailingImagesKey();
|
|
594
|
+
this.recordToolCallFanout(finalToolCalls);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
finally {
|
|
599
|
+
this.inFlight = false;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// -------------------------------------------------------------------
|
|
603
|
+
// Internal helpers
|
|
604
|
+
// -------------------------------------------------------------------
|
|
605
|
+
/**
|
|
606
|
+
* Gate plain-text continuation entry points (`send`, `sendStream`)
|
|
607
|
+
* on the tool-call resolution invariant. Any outstanding `ok` tool
|
|
608
|
+
* call from the prior assistant turn — single or multi — makes a
|
|
609
|
+
* plain text continuation unsafe: the native chat-session API
|
|
610
|
+
* re-opens the assistant turn on each continue, so a new user delta
|
|
611
|
+
* would weave a fresh user message between the assistant's
|
|
612
|
+
* `tool_call` and any response, orphaning the call. Callers must
|
|
613
|
+
* resolve outstanding calls via `sendToolResult*()` (single-call
|
|
614
|
+
* case) or re-enter via `reset()` + `primeHistory()` +
|
|
615
|
+
* `startFromHistory()` with a resolved conversation (multi-call
|
|
616
|
+
* fan-out). `reset()` clears the flag and `startFromHistory*`
|
|
617
|
+
* overwrites it via `recordToolCallFanout` on the new response, so
|
|
618
|
+
* legitimate recovery paths are unaffected.
|
|
619
|
+
*/
|
|
620
|
+
assertCanSendPlain(entryPoint) {
|
|
621
|
+
const n = this.unresolvedOkToolCallCount;
|
|
622
|
+
if (n !== null) {
|
|
623
|
+
const plural = n === 1 ? '' : 's';
|
|
624
|
+
const followUp = n > 1
|
|
625
|
+
? `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`
|
|
626
|
+
: `resolve the outstanding call via sendToolResult()`;
|
|
627
|
+
throw new Error(`ChatSession.${entryPoint}: previous assistant turn has ${n} unresolved ok tool call${plural}; ` +
|
|
628
|
+
`a plain text continuation would orphan the call${plural} by weaving a new user turn between the ` +
|
|
629
|
+
`assistant's tool_call and any response. ${followUp}, reset() the session, or re-enter through ` +
|
|
630
|
+
`primeHistory() + startFromHistory() with a resolved conversation.`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Gate tool-result entry points (`sendToolResult`,
|
|
635
|
+
* `sendToolResultStream`) on the single-tool-call-per-turn
|
|
636
|
+
* invariant. Exactly one outstanding tool call is servable — that
|
|
637
|
+
* is the case these methods exist for.
|
|
638
|
+
*
|
|
639
|
+
* Zero outstanding calls (`null`) is also unservable: without a
|
|
640
|
+
* preceding assistant turn that emitted a tool call, a tool-result
|
|
641
|
+
* dispatch would synthesize a `<tool_response>` delta for a call
|
|
642
|
+
* that never existed, corrupting the conversation structure. The
|
|
643
|
+
* native backends do not authenticate `tool_call_id` against prior
|
|
644
|
+
* state — several simply append the tool-response delta verbatim —
|
|
645
|
+
* so rejecting here is the only gate that prevents forged tool
|
|
646
|
+
* state from reaching the model. Callers that want to start a
|
|
647
|
+
* conversation on a resolved tool turn must prime an unresolved
|
|
648
|
+
* single-call assistant turn via `primeHistory()` +
|
|
649
|
+
* `startFromHistory()` first.
|
|
650
|
+
*
|
|
651
|
+
* A multi-call fan-out (`> 1`) cannot be resolved one result at a
|
|
652
|
+
* time because each `sendToolResult` dispatch immediately re-opens
|
|
653
|
+
* the assistant turn, so responding to the siblings would
|
|
654
|
+
* interleave new assistant replies between the results.
|
|
655
|
+
*/
|
|
656
|
+
assertCanSendToolResult(entryPoint) {
|
|
657
|
+
const n = this.unresolvedOkToolCallCount;
|
|
658
|
+
if (n === null) {
|
|
659
|
+
throw new Error(`ChatSession.${entryPoint}: no outstanding ok tool call on the previous assistant turn. ` +
|
|
660
|
+
`Tool-result entry points can only be called when the model has just emitted exactly one ` +
|
|
661
|
+
`ok tool call that has not yet been resolved — dispatching a tool result against an empty ` +
|
|
662
|
+
`or already-resolved turn would synthesize a <tool_response> delta for a call that never ` +
|
|
663
|
+
`existed and corrupt the conversation structure. Call send() / sendStream() for plain user ` +
|
|
664
|
+
`turns, or re-enter through primeHistory() + startFromHistory() with a conversation that ` +
|
|
665
|
+
`ends on an unresolved single-call assistant turn.`);
|
|
666
|
+
}
|
|
667
|
+
if (n > 1) {
|
|
668
|
+
throw new Error(`ChatSession.${entryPoint}: previous assistant turn emitted ${n} ok tool calls; ` +
|
|
669
|
+
`the chat-session API only supports exactly one tool call per assistant turn because each tool-result ` +
|
|
670
|
+
`call immediately re-opens the assistant turn — responding to the siblings would interleave new assistant ` +
|
|
671
|
+
`replies between the results. Tighten the prompt / tool spec so the model produces at most one call per ` +
|
|
672
|
+
`turn, reset() the session, or re-enter through primeHistory() + startFromHistory() with a resolved ` +
|
|
673
|
+
`conversation.`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Inspect a just-committed turn's tool calls and store the count of
|
|
678
|
+
* `ok` entries in `unresolvedOkToolCallCount`. Any non-zero count
|
|
679
|
+
* parks the session on an unresolved tool-call turn, which gates
|
|
680
|
+
* the next entry point:
|
|
681
|
+
*
|
|
682
|
+
* - count === 0 → flag is `null`: `send`/`sendStream` ok,
|
|
683
|
+
* `sendToolResult*` throws (no outstanding call to resolve)
|
|
684
|
+
* - count === 1 → `send`/`sendStream` throw; `sendToolResult*` ok
|
|
685
|
+
* - count > 1 → every entry point throws (fan-out unservable)
|
|
686
|
+
*
|
|
687
|
+
* See `assertCanSendPlain` / `assertCanSendToolResult` for the full
|
|
688
|
+
* rationale.
|
|
689
|
+
*/
|
|
690
|
+
recordToolCallFanout(toolCalls) {
|
|
691
|
+
const n = countOkToolCalls(toolCalls);
|
|
692
|
+
this.unresolvedOkToolCallCount = n > 0 ? n : null;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Merge default + per-call config and force `reuseCache: true`.
|
|
696
|
+
* The session path is a session-reuse operation by construction —
|
|
697
|
+
* `reuseCache: false` on the continue path would wipe the very
|
|
698
|
+
* cache the delta depends on.
|
|
699
|
+
*/
|
|
700
|
+
mergeConfig(overlay) {
|
|
701
|
+
return {
|
|
702
|
+
...this.defaultConfig,
|
|
703
|
+
...overlay,
|
|
704
|
+
reuseCache: true,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Shared start-path logic for `send()`. Handles both the turn-0
|
|
709
|
+
* first-ever-send case and the image-change mid-session restart
|
|
710
|
+
* case. The image-change restart preserves prior history so the
|
|
711
|
+
* native side gets the full conversation re-rendered with the new
|
|
712
|
+
* image set.
|
|
713
|
+
*/
|
|
714
|
+
async runStartPath(userMessage, images, newImagesKey, imageChanged, isFirstTurn, config) {
|
|
715
|
+
// Capture pre-state so the restart can be rolled back if the
|
|
716
|
+
// native call fails. The image-change branch resets caches BEFORE
|
|
717
|
+
// we know whether the new prefill will succeed, so on failure we
|
|
718
|
+
// also have to drop turnCount + lastImagesKey to force the next
|
|
719
|
+
// call to re-route through the start path (rather than a delta
|
|
720
|
+
// continue against wiped caches).
|
|
721
|
+
const wasImageChangeRestart = imageChanged && !isFirstTurn;
|
|
722
|
+
const historyLenBefore = this.history.length;
|
|
723
|
+
this.prepareStartPath(imageChanged, isFirstTurn);
|
|
724
|
+
const userMsg = this.buildUserMessage(userMessage, images);
|
|
725
|
+
this.history.push(userMsg);
|
|
726
|
+
try {
|
|
727
|
+
// Pass a shallow snapshot so later pushes to `this.history`
|
|
728
|
+
// (e.g. the assistant reply below) don't retroactively mutate
|
|
729
|
+
// what the native side / any mock observed as its `messages`
|
|
730
|
+
// argument.
|
|
731
|
+
const result = await this.model.chatSessionStart(this.history.slice(), config);
|
|
732
|
+
this.history.push(buildAssistantMessage(result.text, result.toolCalls));
|
|
733
|
+
this.turnCount++;
|
|
734
|
+
this.lastImagesKey = newImagesKey;
|
|
735
|
+
this.recordToolCallFanout(result.toolCalls);
|
|
736
|
+
return result;
|
|
737
|
+
}
|
|
738
|
+
catch (err) {
|
|
739
|
+
// Roll back: drop the tentative user push so history stays
|
|
740
|
+
// consistent with turnCount.
|
|
741
|
+
this.history.length = historyLenBefore;
|
|
742
|
+
if (wasImageChangeRestart) {
|
|
743
|
+
// Caches were wiped by prepareStartPath() but the new prefill
|
|
744
|
+
// failed. Force the next call to re-route through the start
|
|
745
|
+
// path with the (preserved) prior history.
|
|
746
|
+
this.turnCount = 0;
|
|
747
|
+
this.lastImagesKey = null;
|
|
748
|
+
}
|
|
749
|
+
throw err;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/** Streaming counterpart to {@link runStartPath}. */
|
|
753
|
+
async *runStartStreamPath(userMessage, images, newImagesKey, imageChanged, isFirstTurn, config, signal) {
|
|
754
|
+
// Capture pre-state so any non-successful exit can roll back.
|
|
755
|
+
// See `runStartPath` for the full rationale.
|
|
756
|
+
const wasImageChangeRestart = imageChanged && !isFirstTurn;
|
|
757
|
+
const historyLenBefore = this.history.length;
|
|
758
|
+
this.prepareStartPath(imageChanged, isFirstTurn);
|
|
759
|
+
const userMsg = this.buildUserMessage(userMessage, images);
|
|
760
|
+
// Stage the user message on the pending history BEFORE the
|
|
761
|
+
// stream starts — the native call reads it synchronously via
|
|
762
|
+
// `model.chatStreamSessionStart(history, config)`.
|
|
763
|
+
this.history.push(userMsg);
|
|
764
|
+
let sawFinal = false;
|
|
765
|
+
let accumulated = '';
|
|
766
|
+
let finalRaw = null;
|
|
767
|
+
let finalToolCalls;
|
|
768
|
+
// Snapshot the history before dispatch — see `runStartPath` for
|
|
769
|
+
// the rationale.
|
|
770
|
+
const historySnapshot = this.history.slice();
|
|
771
|
+
try {
|
|
772
|
+
for await (const event of this.model.chatStreamSessionStart(historySnapshot, config, signal)) {
|
|
773
|
+
if (event.done) {
|
|
774
|
+
if (event.finishReason !== 'error') {
|
|
775
|
+
sawFinal = true;
|
|
776
|
+
finalRaw = event.text;
|
|
777
|
+
finalToolCalls = event.toolCalls;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
accumulated += event.text;
|
|
782
|
+
}
|
|
783
|
+
yield event;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
finally {
|
|
787
|
+
// finally runs in ALL termination paths: normal completion,
|
|
788
|
+
// mid-stream throw, caller `break` (which calls
|
|
789
|
+
// `iterator.return()` on the generator and short-circuits the
|
|
790
|
+
// suspended `yield`, skipping any post-loop code), and
|
|
791
|
+
// error-finish chunks. The unified commit-or-rollback below
|
|
792
|
+
// makes restart fully transactional regardless of how the
|
|
793
|
+
// generator was wound down. Mid-stream throws still propagate
|
|
794
|
+
// naturally — finally runs first, then the error continues up.
|
|
795
|
+
if (sawFinal) {
|
|
796
|
+
this.history.push(buildAssistantMessage(finalRaw ?? accumulated, finalToolCalls));
|
|
797
|
+
this.turnCount++;
|
|
798
|
+
this.lastImagesKey = newImagesKey;
|
|
799
|
+
this.recordToolCallFanout(finalToolCalls);
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
// Roll back: drop the tentative user push so history stays
|
|
803
|
+
// consistent with turnCount.
|
|
804
|
+
this.history.length = historyLenBefore;
|
|
805
|
+
if (wasImageChangeRestart) {
|
|
806
|
+
// Caches were wiped by prepareStartPath() but the new
|
|
807
|
+
// prefill never reached a successful done:true. Force the
|
|
808
|
+
// next call to re-route through the start path with the
|
|
809
|
+
// preserved prior history.
|
|
810
|
+
this.turnCount = 0;
|
|
811
|
+
this.lastImagesKey = null;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Shared pre-start bookkeeping for both `send()` and `sendStream()`:
|
|
818
|
+
*
|
|
819
|
+
* - On an image-change restart (turn >= 1), reset the native KV
|
|
820
|
+
* caches so the new image set gets a fresh prefill. History is
|
|
821
|
+
* intentionally preserved — `chatSessionStart` receives the full
|
|
822
|
+
* accumulated conversation plus the new user turn so the jinja
|
|
823
|
+
* render walks every prior turn and every prior image again
|
|
824
|
+
* (see plan's Turn 3 example: "full jinja on 3-turn history +
|
|
825
|
+
* image B"). `lastImagesKey` will be overwritten by the
|
|
826
|
+
* successful start path right after, and `turnCount` is
|
|
827
|
+
* incremented by the start path the same way as for any other
|
|
828
|
+
* turn.
|
|
829
|
+
* - On a fresh / reset history, re-inject the system prompt.
|
|
830
|
+
*/
|
|
831
|
+
prepareStartPath(imageChanged, isFirstTurn) {
|
|
832
|
+
if (imageChanged && !isFirstTurn) {
|
|
833
|
+
this.model.resetCaches();
|
|
834
|
+
}
|
|
835
|
+
if (this.history.length === 0 && this.system != null) {
|
|
836
|
+
this.history.push({ role: 'system', content: this.system });
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
/** Build a user `ChatMessage` with or without attached images. */
|
|
840
|
+
buildUserMessage(userMessage, images) {
|
|
841
|
+
if (images && images.length > 0) {
|
|
842
|
+
return { role: 'user', content: userMessage, images };
|
|
843
|
+
}
|
|
844
|
+
return { role: 'user', content: userMessage };
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Walk the history backward to find the most recent user message
|
|
848
|
+
* with images and return its FNV-1a key. Used by
|
|
849
|
+
* {@link startFromHistory} and {@link startFromHistoryStream} to
|
|
850
|
+
* hydrate `lastImagesKey` after a cold replay, so subsequent delta
|
|
851
|
+
* continues correctly detect image changes.
|
|
852
|
+
*/
|
|
853
|
+
computeTrailingImagesKey() {
|
|
854
|
+
for (let i = this.history.length - 1; i >= 0; i--) {
|
|
855
|
+
const msg = this.history[i];
|
|
856
|
+
if (msg?.role === 'user' && msg.images && msg.images.length > 0) {
|
|
857
|
+
return computeImagesKey(msg.images);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Derive the post-prime value of `unresolvedOkToolCallCount` from
|
|
864
|
+
* the primed history. Walks backward to the most recent assistant
|
|
865
|
+
* turn, then walks forward from that assistant to the end of history
|
|
866
|
+
* subtracting any `tool:` message that references one of the turn's
|
|
867
|
+
* `call_id`s. A fully-resolved history (every outstanding id matched
|
|
868
|
+
* by a sibling `tool:` message) returns `null`; any leftover count is
|
|
869
|
+
* the number of still-unresolved tool calls.
|
|
870
|
+
*
|
|
871
|
+
* Matches the runtime `recordToolCallFanout` semantics on the hot
|
|
872
|
+
* path: zero unresolved → `null` (no pending obligation); one →
|
|
873
|
+
* `1` (servable via `sendToolResult*()` only); two or more → the
|
|
874
|
+
* count itself (unservable fan-out — must be resolved via cold
|
|
875
|
+
* replay). The distinction between "ok" vs. other statuses only
|
|
876
|
+
* exists in the live `ToolCallResult[]` emitted by the native side —
|
|
877
|
+
* the persisted `ChatMessage.toolCalls` on an assistant message only
|
|
878
|
+
* carries successfully parsed calls (i.e. what would have been "ok"
|
|
879
|
+
* in the original live turn), so counting the array length is
|
|
880
|
+
* equivalent. Tool calls whose `id` is missing or empty can't be
|
|
881
|
+
* matched against subsequent `tool_call_id`s, so in that case we
|
|
882
|
+
* fall back to returning the raw `calls.length` (err safe).
|
|
883
|
+
*/
|
|
884
|
+
computeTrailingAssistantUnresolvedToolCallCount() {
|
|
885
|
+
let assistantIdx = -1;
|
|
886
|
+
for (let i = this.history.length - 1; i >= 0; i--) {
|
|
887
|
+
if (this.history[i]?.role === 'assistant') {
|
|
888
|
+
assistantIdx = i;
|
|
889
|
+
break;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
if (assistantIdx === -1)
|
|
893
|
+
return null;
|
|
894
|
+
const assistant = this.history[assistantIdx];
|
|
895
|
+
const calls = assistant.toolCalls ?? [];
|
|
896
|
+
if (calls.length === 0)
|
|
897
|
+
return null;
|
|
898
|
+
const outstanding = new Set();
|
|
899
|
+
let missingIdCount = 0;
|
|
900
|
+
for (const tc of calls) {
|
|
901
|
+
if (typeof tc.id === 'string' && tc.id.length > 0) {
|
|
902
|
+
outstanding.add(tc.id);
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
missingIdCount++;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// Untracked calls (no id) can't be matched against resolutions —
|
|
909
|
+
// err safe by reporting the raw count.
|
|
910
|
+
if (missingIdCount > 0)
|
|
911
|
+
return calls.length;
|
|
912
|
+
for (let j = assistantIdx + 1; j < this.history.length; j++) {
|
|
913
|
+
const msg = this.history[j];
|
|
914
|
+
if (msg?.role === 'tool' && typeof msg.toolCallId === 'string' && msg.toolCallId.length > 0) {
|
|
915
|
+
outstanding.delete(msg.toolCallId);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
return outstanding.size > 0 ? outstanding.size : null;
|
|
919
|
+
}
|
|
920
|
+
}
|