@msm-core/mini 0.9.0 → 0.15.0

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.
@@ -5,7 +5,105 @@
5
5
  * run with an error outcome. Every built-in brain now wraps its provider call
6
6
  * in withRetry(): a single bounded retry on transient failures, abort-aware
7
7
  * (never retries a cancelled call — that's the loop's time budget firing).
8
+ *
9
+ * ── Retrying a STREAM is not retrying a call (ب١/٢) ──────────────────────────
10
+ *
11
+ * A retried non-streaming call is invisible: the first attempt's response is
12
+ * thrown away and only the second one is ever seen. A retried STREAMING call is
13
+ * not, because the first attempt already spoke. It pushed "The contract sta"
14
+ * into the consumer's `onChunk` and then died on a 429; the second attempt
15
+ * opens a fresh stream and says the whole sentence again, and what the human
16
+ * watching reads is "The contract staThe contract states…". The payload was
17
+ * always right — only the display lied — which is exactly why it survived ب١.
18
+ *
19
+ * The cure has to live at the retry boundary, because that is the only place
20
+ * that knows an earlier attempt existed. `emitOnce` below is that boundary made
21
+ * available to the caller: wrap a sink in it and the run's chunks are delivered
22
+ * by AT MOST ONE attempt.
23
+ *
24
+ * **The invariant this buys.** What reaches the consumer is always a PREFIX of
25
+ * the payload the call finally returns. Never a different string, never a
26
+ * longer one. That is the whole property: a display may lag the truth, it may
27
+ * stop short of it, but it may never contradict it — and "The contract staThe
28
+ * contract states…" contradicts it, because no payload ever said that.
29
+ *
30
+ * **Gated on delivery, not on attempt number.** "Mute every attempt after the
31
+ * first" is the obvious rule and it is wrong: a first attempt that dies while
32
+ * opening the socket has shown the reader nothing, and muting the retry would
33
+ * turn a recoverable stumble into a run that streams not one character. So the
34
+ * gate closes only once a chunk has actually been DELIVERED, and a silent
35
+ * failure costs the reader nothing.
36
+ *
37
+ * **The decision is taken per attempt, before the call.** An attempt either
38
+ * speaks or it does not; a gate re-read mid-flight could fall silent halfway
39
+ * through a sentence, which is a worse artifact than the duplicate it replaced.
40
+ *
41
+ * **What this costs, stated plainly.** When the first attempt dies mid-sentence
42
+ * the reader's display stops there: the retry's text is suppressed, and the rest
43
+ * of the answer arrives with the payload rather than character by character. It
44
+ * is a truncated display, not a wrong one — and `onChunk` is display, not truth
45
+ * (ب١), so the returned payload is complete either way.
46
+ *
47
+ * **The alternative that was rejected.** Suppressing only the already-delivered
48
+ * PREFIX and streaming the remainder would keep the display live — but only if
49
+ * the retry repeats itself word for word, and a re-issued model call is under no
50
+ * obligation to. When it diverges, prefix-skipping splices the head of one
51
+ * answer onto the tail of another and hands the reader a sentence neither
52
+ * attempt produced. Stopping is honest; splicing is not. Delivering the retry
53
+ * losslessly needs a "discard what I showed you" signal on `BrainChunk`, which
54
+ * is a consumer-visible contract change and not this file's to make.
55
+ *
56
+ * ── The signal, and the covenant it must not break (ص٢/٢) ───────────────────
57
+ *
58
+ * That signal now exists: `BrainChunk.reset`. So the gate has TWO patterns, and
59
+ * which one a run gets is decided by the consumer, never by this file.
60
+ *
61
+ * • **prefix** — the default, and the ص١ path unchanged. A retried attempt is
62
+ * muted; the display stops where the dead attempt stopped; what reached the
63
+ * reader is a prefix of the payload.
64
+ *
65
+ * • **reset** — only for a sink that went through `acceptResets`. The retried
66
+ * attempt is NOT muted. Its first delivered chunk carries `reset: true`,
67
+ * meaning "throw away what I sent, start here", and the rest stream
68
+ * normally. The reader ends up holding the whole payload: nothing lost,
69
+ * nothing doubled.
70
+ *
71
+ * **A consumer that did not declare is not affected by any of this.** It is not
72
+ * a softer default — it is the same branch, the same `SILENT`, the same bytes,
73
+ * and the reset branch is unreachable without the declaration. That matters
74
+ * because delivering `reset` to a consumer that appends and ignores it would
75
+ * reproduce ب١/٢'s duplicate display exactly.
76
+ *
77
+ * **The reset rides on a real chunk rather than arriving as its own empty one.**
78
+ * Two reasons. It cannot be split — a reader that acts on one message and drops
79
+ * the next can never clear without also receiving the text that replaces it.
80
+ * And every chunk still corresponds to model text, so "concatenate, clearing at
81
+ * a reset" yields the payload with no zero-length special case. It is also LAZY:
82
+ * built when the retried attempt actually delivers, so an attempt that dies
83
+ * silently a second time clears nothing and leaves the display where ص١ would
84
+ * have left it.
85
+ */
86
+ import { isResetAware } from "../core/types.js";
87
+ /** What a muted attempt streams into. */
88
+ const SILENT = () => { };
89
+ /**
90
+ * The reset pattern's sink for one retried attempt: the first thing it delivers
91
+ * says "start over", everything after it is ordinary.
92
+ *
93
+ * `cleared` is per-attempt, which is what makes a third attempt clear again —
94
+ * each retry replaces the display, it does not append to the one before it.
8
95
  */
96
+ function clearThenStream(sink) {
97
+ let cleared = false;
98
+ return (chunk) => {
99
+ if (cleared) {
100
+ sink(chunk);
101
+ return;
102
+ }
103
+ cleared = true;
104
+ sink({ ...chunk, reset: true });
105
+ };
106
+ }
9
107
  const TRANSIENT_CODES = new Set([
10
108
  "ECONNRESET",
11
109
  "ECONNREFUSED",
@@ -38,14 +136,46 @@ export function isTransientError(err) {
38
136
  function sleep(ms) {
39
137
  return new Promise((r) => setTimeout(r, ms));
40
138
  }
41
- /** Run `fn`, retrying once (by default) on transient provider errors. */
139
+ /**
140
+ * Run `fn`, retrying once (by default) on transient provider errors.
141
+ *
142
+ * `fn` receives an `emitOnce` gate (see the header): wrap the consumer's chunk
143
+ * sink in it and no text is delivered twice across attempts. A zero-argument
144
+ * thunk — every caller written before ب١/٢ — is assignable unchanged and behaves
145
+ * exactly as it did.
146
+ */
42
147
  export async function withRetry(fn, opts = {}) {
43
148
  const attempts = Math.max(1, opts.attempts ?? 2);
44
149
  const baseDelayMs = opts.baseDelayMs ?? 500;
150
+ /** Has any attempt so far actually pushed something through a gated sink? */
151
+ let delivered = false;
45
152
  let lastErr;
46
153
  for (let attempt = 1; attempt <= attempts; attempt++) {
154
+ // Read once, here, and not again inside the attempt — see the header.
155
+ const muted = delivered;
156
+ const emitOnce = muted
157
+ ? (sink) => {
158
+ // PREFIX pattern — the ص١ path, and the only one a sink that did not
159
+ // declare can reach.
160
+ if (!isResetAware(sink))
161
+ return SILENT;
162
+ // RESET pattern. The predicate is the evidence: `acceptResets` is the
163
+ // only producer of the mark and it accepts nothing but a chunk sink,
164
+ // so this narrowed value really does take a `BrainChunk`. The gate's
165
+ // own signature stays `(...args: A) => void` for its caller; the args
166
+ // are ignored because the sink is called with the chunk it is given.
167
+ const declared = sink;
168
+ const stream = clearThenStream(declared);
169
+ return (...args) => {
170
+ stream(args[0]);
171
+ };
172
+ }
173
+ : (sink) => (...args) => {
174
+ delivered = true;
175
+ sink(...args);
176
+ };
47
177
  try {
48
- return await fn();
178
+ return await fn(emitOnce);
49
179
  }
50
180
  catch (err) {
51
181
  lastErr = err;
@@ -23,6 +23,22 @@
23
23
  * a stream cannot reconstruct and no brain here consumes, so promising them
24
24
  * would be a lie the compiler would happily keep.
25
25
  *
26
+ * **The responder's name joined that set in س٦/٤**, by the same rule: the
27
+ * brains now read it (`respondingModel`) to record WHO answered in the session
28
+ * log, so it is a field this file has to carry or the answer is lost. And it is
29
+ * the one field here a stream CAN reconstruct — every provider stamps it on the
30
+ * wire, measured: OpenAI on each `ChatCompletionChunk`, Anthropic on the
31
+ * `message_start` message, Gemini as `modelVersion` on every stream element,
32
+ * Ollama on every NDJSON line. Leaving it out was not a design decision but the
33
+ * shape of the file before anyone needed it, and the cost was precise: nisus
34
+ * sets `hooks.onChunk` on every agent it builds, so EVERY turn it runs is a
35
+ * streamed turn, and every one of them would have logged no responder at all.
36
+ *
37
+ * **Absent stays absent.** Every carry below is a conditional spread: a stream
38
+ * that never names a model folds to the object it folded to yesterday, key for
39
+ * key, and the ب١ fixtures that assert `fold(chunks) === nonStreamed` keep
40
+ * passing untouched.
41
+ *
26
42
  * **Display, not truth (the governing limit of ب١).** Nothing in this file
27
43
  * writes a log event, touches a fingerprint, or decides anything. Chunks are
28
44
  * emitted for a human to look at; the payload the brain returns is the record.
@@ -67,6 +83,8 @@ export interface OpenAIStreamChunk {
67
83
  prompt_tokens?: number;
68
84
  completion_tokens?: number;
69
85
  } | null;
86
+ /** The serving model — OpenAI stamps it on every chunk (`ChatCompletionChunk.model`). */
87
+ model?: string;
70
88
  }
71
89
  /**
72
90
  * What the OpenAI brain reads off a completion — streamed or not.
@@ -89,6 +107,8 @@ export interface OpenAICompletion {
89
107
  prompt_tokens?: number;
90
108
  completion_tokens?: number;
91
109
  } | null;
110
+ /** Who answered — `ChatCompletion.model`, and the streamed chunks' `model`. */
111
+ model?: string;
92
112
  }
93
113
  /**
94
114
  * The text this chunk carries, or "" for a tool-call or usage-only chunk.
@@ -139,6 +159,8 @@ export interface AnthropicStreamEvent {
139
159
  input_tokens?: number;
140
160
  output_tokens?: number;
141
161
  };
162
+ /** The serving model — carried on the `message_start` event's message. */
163
+ model?: string;
142
164
  };
143
165
  content_block?: {
144
166
  type?: string;
@@ -157,11 +179,23 @@ export interface AnthropicMessage {
157
179
  text?: string;
158
180
  name?: string;
159
181
  input?: unknown;
182
+ /**
183
+ * The body of a `thinking` block, as the unstreamed API spells it (ت١/٨).
184
+ * The brain joins these into `BrainPayload.thoughts` and never into the
185
+ * text. The streamed fold below does NOT carry it — `thinking_delta` is
186
+ * neither text nor JSON to `accumulateAnthropic`, so a streamed thinking
187
+ * block folds to `{ type: "thinking", text: "" }` as it always has, and
188
+ * `thoughts` is simply absent on that path. Said here so nobody reads the
189
+ * asymmetry as an accident: this brain does not request thinking at all.
190
+ */
191
+ thinking?: string;
160
192
  }>;
161
193
  usage: {
162
194
  input_tokens: number;
163
195
  output_tokens: number;
164
196
  };
197
+ /** Who answered — `Message.model`, and the `message_start` message's model. */
198
+ model?: string;
165
199
  }
166
200
  /**
167
201
  * The text this event carries, or "" for anything else (thinking included).
@@ -203,6 +237,18 @@ export interface GeminiPart {
203
237
  name: string;
204
238
  args?: unknown;
205
239
  };
240
+ /**
241
+ * `true` on a text part that is the model's THINKING, not its answer (ت١).
242
+ *
243
+ * Gemini 2.5+ marks the parts it emits under `thinkingConfig.includeThoughts`
244
+ * with exactly this flag, on the wire, and `@google/generative-ai` 0.24.1
245
+ * hands the parsed body through without declaring the field — so it is
246
+ * declared here, where the brain reads it, and nowhere else. A part without
247
+ * the flag (or with it `false`) is answer text; ONLY `=== true` is thinking.
248
+ * The brain drops what the provider tagged and keeps what it did not — prose
249
+ * that merely *reads* like reasoning is the prompt's business, not the loop's.
250
+ */
251
+ thought?: boolean;
206
252
  }
207
253
  /** What the Gemini brain reads off a response — streamed or not. */
208
254
  export interface GeminiResponse {
@@ -215,6 +261,16 @@ export interface GeminiResponse {
215
261
  promptTokenCount?: number;
216
262
  candidatesTokenCount?: number;
217
263
  };
264
+ /**
265
+ * Who answered. Google's spelling of the field, and the reason
266
+ * `respondingModel` knows two names for one thing.
267
+ *
268
+ * Undeclared by `@google/generative-ai`'s own `GenerateContentResponse` (it
269
+ * stops at three properties) — but the SDK hands back the parsed response
270
+ * body as it arrived, so the field is live on the wire and on the object.
271
+ * Declaring it here is what lets the brain read it without a cast.
272
+ */
273
+ modelVersion?: string;
218
274
  }
219
275
  /**
220
276
  * The text this stream item carries, across ALL of its text parts.
@@ -231,17 +287,28 @@ export declare function geminiDelta(item: GeminiResponse): string;
231
287
  * Fold Gemini stream items into the response they describe.
232
288
  *
233
289
  * **The text parts MERGE into one.** This is the whole reason this function is
234
- * not a flat concatenation of `parts` arrays: the brain finds its answer with
235
- * `parts.find(p => "text" in p)`, so a response left as fifty little text parts
236
- * would return the first syllable of the answer and drop the rest. One part in,
237
- * one part out the same shape `generateContent` returns.
290
+ * not a flat concatenation of `parts` arrays: until ت١ the brain found its
291
+ * answer with `parts.find(p => "text" in p)`, so a response left as fifty
292
+ * little text parts returned the first syllable of the answer and dropped the
293
+ * rest. The brain now JOINS every text part, so the merge is no longer what
294
+ * saves the answer — but one part in, one part out is still the shape
295
+ * `generateContent` returns, and a fold that equals the unstreamed object is
296
+ * the claim the ب١ fixtures check.
238
297
  *
239
298
  * The merged text keeps the POSITION of the first text part it saw, so a
240
299
  * response that opened with a function call still reads in emitted order.
241
300
  * (Order between text and calls does not reach the payload — the brain collects
242
- * calls by filter and text by find — but a shape that reorders parts for no
301
+ * calls by filter and text by join — but a shape that reorders parts for no
243
302
  * reason is a shape that will eventually be believed.)
244
303
  *
304
+ * **Two slots, not one (ت١).** A part the model tagged `thought: true` merges
305
+ * into its OWN part, tagged the same way, and never into the answer's. The
306
+ * folded shape therefore still carries both kinds, tagged as they arrived, and
307
+ * the brain decides text-versus-thinking on that shape with the same lines it
308
+ * uses on an unstreamed candidate — one place for the decision, two paths
309
+ * through it. A stream with no thinking part folds to the object it folded to
310
+ * before the second slot existed, key for key.
311
+ *
245
312
  * Usage is cumulative in Gemini's stream: the last item that carries it wins.
246
313
  */
247
314
  export declare function accumulateGemini(items: readonly GeminiResponse[]): GeminiResponse;
@@ -258,6 +325,8 @@ export interface OllamaStreamLine {
258
325
  };
259
326
  prompt_eval_count?: number;
260
327
  eval_count?: number;
328
+ /** The served tag — Ollama repeats it on every line. */
329
+ model?: string;
261
330
  }
262
331
  /** What the Ollama brain reads off `/api/chat` — streamed or not. */
263
332
  export interface OllamaChatResponse {
@@ -272,6 +341,12 @@ export interface OllamaChatResponse {
272
341
  };
273
342
  prompt_eval_count?: number;
274
343
  eval_count?: number;
344
+ /**
345
+ * Who answered — the served tag, top level in the `/api/chat` body. On a
346
+ * local host this is the one place a re-pulled or re-tagged model shows
347
+ * itself, since nothing about the request would change.
348
+ */
349
+ model?: string;
275
350
  }
276
351
  /**
277
352
  * The text this line carries.
@@ -23,6 +23,22 @@
23
23
  * a stream cannot reconstruct and no brain here consumes, so promising them
24
24
  * would be a lie the compiler would happily keep.
25
25
  *
26
+ * **The responder's name joined that set in س٦/٤**, by the same rule: the
27
+ * brains now read it (`respondingModel`) to record WHO answered in the session
28
+ * log, so it is a field this file has to carry or the answer is lost. And it is
29
+ * the one field here a stream CAN reconstruct — every provider stamps it on the
30
+ * wire, measured: OpenAI on each `ChatCompletionChunk`, Anthropic on the
31
+ * `message_start` message, Gemini as `modelVersion` on every stream element,
32
+ * Ollama on every NDJSON line. Leaving it out was not a design decision but the
33
+ * shape of the file before anyone needed it, and the cost was precise: nisus
34
+ * sets `hooks.onChunk` on every agent it builds, so EVERY turn it runs is a
35
+ * streamed turn, and every one of them would have logged no responder at all.
36
+ *
37
+ * **Absent stays absent.** Every carry below is a conditional spread: a stream
38
+ * that never names a model folds to the object it folded to yesterday, key for
39
+ * key, and the ب١ fixtures that assert `fold(chunks) === nonStreamed` keep
40
+ * passing untouched.
41
+ *
26
42
  * **Display, not truth (the governing limit of ب١).** Nothing in this file
27
43
  * writes a log event, touches a fingerprint, or decides anything. Chunks are
28
44
  * emitted for a human to look at; the payload the brain returns is the record.
@@ -94,10 +110,16 @@ export function accumulateOpenAI(chunks) {
94
110
  let content = "";
95
111
  let sawText = false;
96
112
  let usage;
113
+ let model;
97
114
  const fragments = new Map();
98
115
  for (const chunk of chunks) {
99
116
  if (chunk.usage)
100
117
  usage = chunk.usage;
118
+ // Every chunk repeats the serving model; the last one wins, exactly as
119
+ // `usage` does. They should all agree — and if a gateway ever re-routed
120
+ // mid-response, the name that answered LAST is the honest one to record.
121
+ if (chunk.model)
122
+ model = chunk.model;
101
123
  const delta = chunk.choices?.[0]?.delta;
102
124
  if (!delta)
103
125
  continue;
@@ -132,6 +154,7 @@ export function accumulateOpenAI(chunks) {
132
154
  },
133
155
  ],
134
156
  ...(usage ? { usage } : {}),
157
+ ...(model !== undefined ? { model } : {}),
135
158
  };
136
159
  }
137
160
  function blockDelta(delta) {
@@ -173,6 +196,7 @@ export function accumulateAnthropic(events) {
173
196
  const blocks = new Map();
174
197
  let inputTokens = 0;
175
198
  let outputTokens = 0;
199
+ let model;
176
200
  for (const event of events) {
177
201
  if (event.type === "message_start") {
178
202
  const usage = event.message?.usage;
@@ -180,6 +204,10 @@ export function accumulateAnthropic(events) {
180
204
  inputTokens = usage.input_tokens;
181
205
  if (typeof usage?.output_tokens === "number")
182
206
  outputTokens = usage.output_tokens;
207
+ // Claude names the serving model once, in the opening event's message —
208
+ // the same object the non-streamed call returns whole.
209
+ if (typeof event.message?.model === "string")
210
+ model = event.message.model;
183
211
  continue;
184
212
  }
185
213
  if (event.type === "message_delta") {
@@ -218,7 +246,11 @@ export function accumulateAnthropic(events) {
218
246
  .map(([, slot]) => slot.type === "tool_use"
219
247
  ? { type: "tool_use", name: slot.name, input: parseJsonObject(slot.json) }
220
248
  : { type: slot.type, text: slot.text });
221
- return { content, usage: { input_tokens: inputTokens, output_tokens: outputTokens } };
249
+ return {
250
+ content,
251
+ usage: { input_tokens: inputTokens, output_tokens: outputTokens },
252
+ ...(model !== undefined ? { model } : {}),
253
+ };
222
254
  }
223
255
  /**
224
256
  * The text this stream item carries, across ALL of its text parts.
@@ -234,6 +266,11 @@ export function geminiDelta(item) {
234
266
  const parts = item.candidates?.[0]?.content?.parts ?? [];
235
267
  let text = "";
236
268
  for (const part of parts) {
269
+ // A thinking part is not the answer, and what reaches the watcher must
270
+ // stay a prefix of the answer the payload finally carries (ب١) — so it is
271
+ // skipped here for the same reason the brain keeps it out of the text.
272
+ if (part.thought === true)
273
+ continue;
237
274
  if (typeof part.text === "string")
238
275
  text += part.text;
239
276
  }
@@ -243,35 +280,60 @@ export function geminiDelta(item) {
243
280
  * Fold Gemini stream items into the response they describe.
244
281
  *
245
282
  * **The text parts MERGE into one.** This is the whole reason this function is
246
- * not a flat concatenation of `parts` arrays: the brain finds its answer with
247
- * `parts.find(p => "text" in p)`, so a response left as fifty little text parts
248
- * would return the first syllable of the answer and drop the rest. One part in,
249
- * one part out the same shape `generateContent` returns.
283
+ * not a flat concatenation of `parts` arrays: until ت١ the brain found its
284
+ * answer with `parts.find(p => "text" in p)`, so a response left as fifty
285
+ * little text parts returned the first syllable of the answer and dropped the
286
+ * rest. The brain now JOINS every text part, so the merge is no longer what
287
+ * saves the answer — but one part in, one part out is still the shape
288
+ * `generateContent` returns, and a fold that equals the unstreamed object is
289
+ * the claim the ب١ fixtures check.
250
290
  *
251
291
  * The merged text keeps the POSITION of the first text part it saw, so a
252
292
  * response that opened with a function call still reads in emitted order.
253
293
  * (Order between text and calls does not reach the payload — the brain collects
254
- * calls by filter and text by find — but a shape that reorders parts for no
294
+ * calls by filter and text by join — but a shape that reorders parts for no
255
295
  * reason is a shape that will eventually be believed.)
256
296
  *
297
+ * **Two slots, not one (ت١).** A part the model tagged `thought: true` merges
298
+ * into its OWN part, tagged the same way, and never into the answer's. The
299
+ * folded shape therefore still carries both kinds, tagged as they arrived, and
300
+ * the brain decides text-versus-thinking on that shape with the same lines it
301
+ * uses on an unstreamed candidate — one place for the decision, two paths
302
+ * through it. A stream with no thinking part folds to the object it folded to
303
+ * before the second slot existed, key for key.
304
+ *
257
305
  * Usage is cumulative in Gemini's stream: the last item that carries it wins.
258
306
  */
259
307
  export function accumulateGemini(items) {
260
308
  const parts = [];
261
309
  let textSlot = -1;
310
+ let thoughtSlot = -1;
262
311
  let usage;
312
+ let modelVersion;
313
+ // Append `text` to the part at `slot`, opening it at the end if there is none
314
+ // yet; returns the slot. `thought` rides along so the merged part stays tagged.
315
+ const merge = (slot, text, thought) => {
316
+ if (slot === -1) {
317
+ parts.push(thought ? { text, thought: true } : { text });
318
+ return parts.length - 1;
319
+ }
320
+ const joined = (parts[slot]?.text ?? "") + text;
321
+ parts[slot] = thought ? { text: joined, thought: true } : { text: joined };
322
+ return slot;
323
+ };
263
324
  for (const item of items) {
264
325
  if (item.usageMetadata)
265
326
  usage = item.usageMetadata;
327
+ // Every element repeats it (each is a whole `GenerateContentResponse`);
328
+ // last one wins, as with usage.
329
+ if (item.modelVersion)
330
+ modelVersion = item.modelVersion;
266
331
  for (const part of item.candidates?.[0]?.content?.parts ?? []) {
267
332
  if (typeof part.text === "string") {
268
- if (textSlot === -1) {
269
- textSlot = parts.length;
270
- parts.push({ text: part.text });
271
- }
272
- else {
273
- parts[textSlot] = { text: (parts[textSlot]?.text ?? "") + part.text };
274
- }
333
+ if (part.thought === true)
334
+ thoughtSlot = merge(thoughtSlot, part.text, true);
335
+ else
336
+ textSlot = merge(textSlot, part.text, false);
275
337
  }
276
338
  else if (part.functionCall) {
277
339
  parts.push({ functionCall: part.functionCall });
@@ -281,6 +343,7 @@ export function accumulateGemini(items) {
281
343
  return {
282
344
  candidates: [{ content: { parts } }],
283
345
  ...(usage ? { usageMetadata: usage } : {}),
346
+ ...(modelVersion !== undefined ? { modelVersion } : {}),
284
347
  };
285
348
  }
286
349
  /**
@@ -323,6 +386,7 @@ export function accumulateOllama(lines) {
323
386
  const toolCalls = [];
324
387
  let promptEvalCount;
325
388
  let evalCount;
389
+ let model;
326
390
  for (const line of lines) {
327
391
  if (typeof line.message?.content === "string")
328
392
  content += line.message.content;
@@ -333,6 +397,9 @@ export function accumulateOllama(lines) {
333
397
  }
334
398
  if (typeof line.eval_count === "number")
335
399
  evalCount = line.eval_count;
400
+ // Repeated on every line; last one wins, as with the counts.
401
+ if (line.model)
402
+ model = line.model;
336
403
  }
337
404
  return {
338
405
  message: {
@@ -341,6 +408,7 @@ export function accumulateOllama(lines) {
341
408
  },
342
409
  ...(promptEvalCount !== undefined ? { prompt_eval_count: promptEvalCount } : {}),
343
410
  ...(evalCount !== undefined ? { eval_count: evalCount } : {}),
411
+ ...(model !== undefined ? { model } : {}),
344
412
  };
345
413
  }
346
414
  /**
@@ -10,13 +10,25 @@ export declare function fireSectionComplete(hooks: AgentHooks | undefined, sessi
10
10
  export declare function fireToolCall(hooks: AgentHooks | undefined, sessionId: string, result: ToolResult, iteration: number, cached: boolean, durationMs: number): void;
11
11
  export declare function fireGuard(hooks: AgentHooks | undefined, sessionId: string, signal: GuardSignal, iteration: number): void;
12
12
  /**
13
- * One streamed piece of model text (ب١).
13
+ * One streamed piece of model text (ب١), carrying `reset` when there is one
14
+ * and a consumer who asked for it (ص٣/٢).
14
15
  *
15
16
  * Same shape as its siblings, and the same contract: `safely` swallows a
16
17
  * throwing hook. That matters more here than anywhere else — this one fires
17
18
  * dozens of times per step, in the middle of reading a provider stream, and a
18
19
  * consumer whose SSE socket closed mid-answer must not take the run down with
19
20
  * it. A failed display is a failed display, not a failed run.
21
+ *
22
+ * **`reset` is spread, not assigned.** `{ sessionId, iteration, text }` is what
23
+ * an undeclared consumer received before this parameter existed and it is
24
+ * exactly what it receives now — the key is ABSENT, not present-and-undefined,
25
+ * so `Object.keys`, `toEqual`, `JSON.stringify` and a `for…in` all see what
26
+ * they saw yesterday. That equality is the mandatory control of ص٣/٢, and
27
+ * writing `reset` unconditionally would have broken it while every existing
28
+ * assertion stayed green.
29
+ *
30
+ * The parameter is optional and last, so the pre-ص٣ call shape still compiles
31
+ * and still means what it meant.
20
32
  */
21
- export declare function fireChunk(hooks: AgentHooks | undefined, sessionId: string, iteration: number, text: string): void;
33
+ export declare function fireChunk(hooks: AgentHooks | undefined, sessionId: string, iteration: number, text: string, reset?: true): void;
22
34
  export declare function fireFatalError(hooks: AgentHooks | undefined, error: Error, sessionId: string): void;
@@ -54,18 +54,35 @@ export function fireGuard(hooks, sessionId, signal, iteration) {
54
54
  safely(() => hooks.onGuard({ sessionId, signal, iteration }));
55
55
  }
56
56
  /**
57
- * One streamed piece of model text (ب١).
57
+ * One streamed piece of model text (ب١), carrying `reset` when there is one
58
+ * and a consumer who asked for it (ص٣/٢).
58
59
  *
59
60
  * Same shape as its siblings, and the same contract: `safely` swallows a
60
61
  * throwing hook. That matters more here than anywhere else — this one fires
61
62
  * dozens of times per step, in the middle of reading a provider stream, and a
62
63
  * consumer whose SSE socket closed mid-answer must not take the run down with
63
64
  * it. A failed display is a failed display, not a failed run.
65
+ *
66
+ * **`reset` is spread, not assigned.** `{ sessionId, iteration, text }` is what
67
+ * an undeclared consumer received before this parameter existed and it is
68
+ * exactly what it receives now — the key is ABSENT, not present-and-undefined,
69
+ * so `Object.keys`, `toEqual`, `JSON.stringify` and a `for…in` all see what
70
+ * they saw yesterday. That equality is the mandatory control of ص٣/٢, and
71
+ * writing `reset` unconditionally would have broken it while every existing
72
+ * assertion stayed green.
73
+ *
74
+ * The parameter is optional and last, so the pre-ص٣ call shape still compiles
75
+ * and still means what it meant.
64
76
  */
65
- export function fireChunk(hooks, sessionId, iteration, text) {
77
+ export function fireChunk(hooks, sessionId, iteration, text, reset) {
66
78
  if (!hooks?.onChunk)
67
79
  return;
68
- safely(() => hooks.onChunk({ sessionId, iteration, text }));
80
+ safely(() => hooks.onChunk({
81
+ sessionId,
82
+ iteration,
83
+ text,
84
+ ...(reset === true ? { reset } : {}),
85
+ }));
69
86
  }
70
87
  export function fireFatalError(hooks, error, sessionId) {
71
88
  if (!hooks?.onFatalError)