@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.
- package/CHANGELOG.md +364 -0
- package/README.md +54 -0
- package/dist/adapters/index.d.ts +12 -2
- package/dist/adapters/index.js +10 -0
- package/dist/adapters/memory-control.d.ts +43 -0
- package/dist/adapters/memory-control.js +56 -0
- package/dist/adapters/memory-dedup.d.ts +36 -0
- package/dist/adapters/memory-dedup.js +59 -0
- package/dist/adapters/memory-lock.d.ts +45 -0
- package/dist/adapters/memory-lock.js +89 -0
- package/dist/adapters/redis-control.d.ts +2 -1
- package/dist/adapters/redis-lock.d.ts +9 -5
- package/dist/brain/anthropic.js +29 -4
- package/dist/brain/factory.js +55 -2
- package/dist/brain/gemini.d.ts +18 -2
- package/dist/brain/gemini.js +84 -12
- package/dist/brain/ollama.js +8 -2
- package/dist/brain/openai.js +9 -2
- package/dist/brain/retry.d.ts +98 -2
- package/dist/brain/retry.js +132 -2
- package/dist/brain/streaming.d.ts +80 -5
- package/dist/brain/streaming.js +81 -13
- package/dist/core/hooks.d.ts +14 -2
- package/dist/core/hooks.js +20 -3
- package/dist/core/loop.js +189 -25
- package/dist/core/types.d.ts +395 -4
- package/dist/core/types.js +90 -0
- package/dist/definition/parser.d.ts +3 -0
- package/dist/definition/parser.js +52 -1
- package/dist/definition/skills.d.ts +74 -0
- package/dist/definition/skills.js +119 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +28 -0
- package/dist/tools/dedup.d.ts +22 -1
- package/dist/tools/dedup.js +28 -0
- package/dist/tools/executor.d.ts +49 -5
- package/dist/tools/executor.js +38 -5
- package/package.json +4 -3
package/dist/brain/retry.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
235
|
-
* `parts.find(p => "text" in p)`, so a response left as fifty
|
|
236
|
-
*
|
|
237
|
-
*
|
|
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
|
|
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.
|
package/dist/brain/streaming.js
CHANGED
|
@@ -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 {
|
|
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
|
|
247
|
-
* `parts.find(p => "text" in p)`, so a response left as fifty
|
|
248
|
-
*
|
|
249
|
-
*
|
|
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
|
|
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 (
|
|
269
|
-
|
|
270
|
-
|
|
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
|
/**
|
package/dist/core/hooks.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/hooks.js
CHANGED
|
@@ -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({
|
|
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)
|