@msm-core/mini 0.9.0 → 0.14.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 +279 -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 +8 -1
- package/dist/brain/gemini.js +15 -2
- 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 +42 -0
- package/dist/brain/streaming.js +44 -1
- package/dist/core/hooks.d.ts +14 -2
- package/dist/core/hooks.js +20 -3
- package/dist/core/loop.js +148 -23
- package/dist/core/types.d.ts +349 -4
- package/dist/core/types.js +90 -0
- package/dist/definition/skills.d.ts +74 -0
- package/dist/definition/skills.js +119 -0
- package/dist/index.d.ts +5 -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
|
@@ -8,11 +8,15 @@
|
|
|
8
8
|
* TTL: timeoutMs + 30s safety margin
|
|
9
9
|
*/
|
|
10
10
|
import type { RedisLike } from "./redis-types.js";
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
import type { LockHandle, RunLockPort } from "../core/types.js";
|
|
12
|
+
/**
|
|
13
|
+
* Re-exported from its new home in `core/types.ts` (س٦), where it belongs with
|
|
14
|
+
* the port every lock implements rather than with one implementation of it.
|
|
15
|
+
* Every existing `import { LockHandle } from "@msm-core/mini/adapters"` keeps
|
|
16
|
+
* working, unchanged.
|
|
17
|
+
*/
|
|
18
|
+
export type { LockHandle };
|
|
19
|
+
export declare class RedisDistributedLock implements RunLockPort {
|
|
16
20
|
private readonly redis;
|
|
17
21
|
private readonly prefix;
|
|
18
22
|
constructor(redis: RedisLike, prefix?: string);
|
package/dist/brain/anthropic.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Anthropic Brain — Claude via Messages API.
|
|
3
3
|
* Peer dependency: @anthropic-ai/sdk >= 0.20.0
|
|
4
4
|
*/
|
|
5
|
+
import { respondingModel } from "../core/types.js";
|
|
5
6
|
import { computeCostUsd } from "./pricing.js";
|
|
6
7
|
import { withRetry } from "./retry.js";
|
|
7
8
|
import { foldToolResults, toolParamsToJsonSchema, toWireMessage, useToolOrchestration, } from "./tool-context.js";
|
|
@@ -63,7 +64,7 @@ export function createAnthropicBrain(opts) {
|
|
|
63
64
|
// land in `AnthropicMessage` — the fields this brain reads — so the
|
|
64
65
|
// payload is built once, from one shape.
|
|
65
66
|
const response = input.onChunk
|
|
66
|
-
? await withRetry(() => streamMessage(client, params, input.onChunk, input.signal), input.signal ? { signal: input.signal } : {})
|
|
67
|
+
? await withRetry((emitOnce) => streamMessage(client, params, emitOnce(input.onChunk), input.signal), input.signal ? { signal: input.signal } : {})
|
|
67
68
|
: await withRetry(() => client.messages.create(params, input.signal ? { signal: input.signal } : {}), input.signal ? { signal: input.signal } : {});
|
|
68
69
|
const content = response.content;
|
|
69
70
|
const inputTokens = response.usage.input_tokens;
|
|
@@ -87,6 +88,7 @@ export function createAnthropicBrain(opts) {
|
|
|
87
88
|
orchestration,
|
|
88
89
|
usage: { inputTokens, outputTokens },
|
|
89
90
|
costUsd,
|
|
91
|
+
...respondingModel(response),
|
|
90
92
|
};
|
|
91
93
|
}
|
|
92
94
|
// Text
|
|
@@ -97,6 +99,11 @@ export function createAnthropicBrain(opts) {
|
|
|
97
99
|
orchestration: { action: "respond", confidence: 0.95 },
|
|
98
100
|
usage: { inputTokens, outputTokens },
|
|
99
101
|
costUsd,
|
|
102
|
+
// Who answered, off the message itself — Claude names the model that
|
|
103
|
+
// served the request, which is not always the alias that was asked for.
|
|
104
|
+
// Streamed or not, `response` is the same shape: `message_start`
|
|
105
|
+
// carries the name and `accumulateAnthropic` keeps it.
|
|
106
|
+
...respondingModel(response),
|
|
100
107
|
};
|
|
101
108
|
},
|
|
102
109
|
};
|
package/dist/brain/gemini.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Gemini Brain — Google Gemini 2.5 Flash (primary brain for UTS).
|
|
3
3
|
* Peer dependency: @google/generative-ai >= 0.14.0
|
|
4
4
|
*/
|
|
5
|
+
import { respondingModel } from "../core/types.js";
|
|
5
6
|
import { withRetry } from "./retry.js";
|
|
6
7
|
import { foldToolResults, toWireMessage, useToolOrchestration, } from "./tool-context.js";
|
|
7
8
|
import { accumulateGemini, consumeStream, geminiDelta, } from "./streaming.js";
|
|
@@ -68,7 +69,7 @@ export function createGeminiBrain(opts) {
|
|
|
68
69
|
// land in `GeminiResponse` — the fields this brain reads — so the payload
|
|
69
70
|
// is built once, from one shape.
|
|
70
71
|
const response = input.onChunk
|
|
71
|
-
? await withRetry(() => streamContent(() => geminiModel.generateContentStream(request, input.signal ? { signal: input.signal } : {}), input.onChunk, input.signal), input.signal ? { signal: input.signal } : {})
|
|
72
|
+
? await withRetry((emitOnce) => streamContent(() => geminiModel.generateContentStream(request, input.signal ? { signal: input.signal } : {}), emitOnce(input.onChunk), input.signal), input.signal ? { signal: input.signal } : {})
|
|
72
73
|
: (await withRetry(() => geminiModel.generateContent(request, input.signal ? { signal: input.signal } : {}), input.signal ? { signal: input.signal } : {})).response;
|
|
73
74
|
const parts = response?.candidates?.[0]?.content?.parts ?? [];
|
|
74
75
|
// Token usage + cost — computed ONCE and returned on BOTH the tool-call and text
|
|
@@ -100,7 +101,12 @@ export function createGeminiBrain(opts) {
|
|
|
100
101
|
}
|
|
101
102
|
const orchestration = useToolOrchestration(calls, 0.9);
|
|
102
103
|
if (orchestration) {
|
|
103
|
-
return {
|
|
104
|
+
return {
|
|
105
|
+
orchestration,
|
|
106
|
+
costUsd,
|
|
107
|
+
...(usage ? { usage } : {}),
|
|
108
|
+
...respondingModel(response),
|
|
109
|
+
};
|
|
104
110
|
}
|
|
105
111
|
// Text response
|
|
106
112
|
const textPart = parts.find((p) => "text" in p && typeof p.text === "string");
|
|
@@ -110,6 +116,13 @@ export function createGeminiBrain(opts) {
|
|
|
110
116
|
orchestration: { action: "respond", confidence: 0.95 },
|
|
111
117
|
costUsd,
|
|
112
118
|
...(usage ? { usage } : {}),
|
|
119
|
+
// Who answered — Gemini spells it `modelVersion`, and it is the field
|
|
120
|
+
// that tells `gemini-2.5-flash` from the dated build that served it.
|
|
121
|
+
// The SDK's non-streaming path hands back the parsed response body as
|
|
122
|
+
// it arrived, so the field survives even though its own `.d.ts` stops
|
|
123
|
+
// at three properties; on the streamed path every element repeats it
|
|
124
|
+
// and `accumulateGemini` carries it through. One line, both paths.
|
|
125
|
+
...respondingModel(response),
|
|
113
126
|
};
|
|
114
127
|
},
|
|
115
128
|
};
|
package/dist/brain/ollama.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Ollama Brain — local model via Ollama REST API.
|
|
3
3
|
* No peer dependencies — plain HTTP fetch.
|
|
4
4
|
*/
|
|
5
|
+
import { respondingModel } from "../core/types.js";
|
|
5
6
|
import { withRetry } from "./retry.js";
|
|
6
7
|
import { foldToolResults, toolParamsToJsonSchema, toWireMessage, useToolOrchestration, } from "./tool-context.js";
|
|
7
8
|
import { accumulateOllama, consumeNdjson, } from "./streaming.js";
|
|
@@ -60,7 +61,7 @@ export function createOllamaBrain(opts) {
|
|
|
60
61
|
});
|
|
61
62
|
let data;
|
|
62
63
|
if (input.onChunk) {
|
|
63
|
-
data = await withRetry(() => streamChat(post, input.onChunk, input.signal), input.signal ? { signal: input.signal } : {});
|
|
64
|
+
data = await withRetry((emitOnce) => streamChat(post, emitOnce(input.onChunk), input.signal), input.signal ? { signal: input.signal } : {});
|
|
64
65
|
}
|
|
65
66
|
else {
|
|
66
67
|
// Verbatim: the fetch is retried, the status check and the body read
|
|
@@ -88,13 +89,18 @@ export function createOllamaBrain(opts) {
|
|
|
88
89
|
if (calls[0]?.name) {
|
|
89
90
|
const orchestration = useToolOrchestration(calls, 0.9);
|
|
90
91
|
if (orchestration)
|
|
91
|
-
return { orchestration, ...usageBlock };
|
|
92
|
+
return { orchestration, ...usageBlock, ...respondingModel(data) };
|
|
92
93
|
}
|
|
93
94
|
const text = data.message?.content ?? "";
|
|
94
95
|
return {
|
|
95
96
|
generation: { response_text: text },
|
|
96
97
|
orchestration: { action: "respond", confidence: 0.85 },
|
|
97
98
|
...usageBlock,
|
|
99
|
+
// Who answered — `/api/chat` echoes the served model at the top level,
|
|
100
|
+
// and on a local host that is the one place a swapped or re-tagged
|
|
101
|
+
// model shows itself. Streamed too: every NDJSON line repeats it and
|
|
102
|
+
// `accumulateOllama` keeps the last.
|
|
103
|
+
...respondingModel(data),
|
|
98
104
|
};
|
|
99
105
|
},
|
|
100
106
|
};
|
package/dist/brain/openai.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* OpenAI Brain — wraps the OpenAI Chat Completions API.
|
|
3
3
|
* Peer dependency: openai >= 4.0.0
|
|
4
4
|
*/
|
|
5
|
+
import { respondingModel } from "../core/types.js";
|
|
5
6
|
import { computeCostUsd } from "./pricing.js";
|
|
6
7
|
import { withRetry } from "./retry.js";
|
|
7
8
|
import { foldToolResults, toolParamsToJsonSchema, toWireMessage, useToolOrchestration, } from "./tool-context.js";
|
|
@@ -56,7 +57,7 @@ export function createOpenAIBrain(opts) {
|
|
|
56
57
|
// construction (`OpenAICompletion`), so there is one payload builder,
|
|
57
58
|
// not two that must be kept in step.
|
|
58
59
|
const response = input.onChunk
|
|
59
|
-
? await withRetry(() => streamCompletion(client, body, input.onChunk, input.signal), input.signal ? { signal: input.signal } : {})
|
|
60
|
+
? await withRetry((emitOnce) => streamCompletion(client, body, emitOnce(input.onChunk), input.signal), input.signal ? { signal: input.signal } : {})
|
|
60
61
|
: await withRetry(() => client.chat.completions.create(body, input.signal ? { signal: input.signal } : {}), input.signal ? { signal: input.signal } : {});
|
|
61
62
|
const choice = response.choices[0];
|
|
62
63
|
if (!choice)
|
|
@@ -93,13 +94,19 @@ export function createOpenAIBrain(opts) {
|
|
|
93
94
|
});
|
|
94
95
|
const orchestration = useToolOrchestration(calls, 0.9);
|
|
95
96
|
if (orchestration)
|
|
96
|
-
return { orchestration, ...usageBlock };
|
|
97
|
+
return { orchestration, ...usageBlock, ...respondingModel(response) };
|
|
97
98
|
}
|
|
98
99
|
const text = msg.content ?? "";
|
|
99
100
|
return {
|
|
100
101
|
generation: { response_text: text },
|
|
101
102
|
orchestration: { action: "respond", confidence: 0.95 },
|
|
102
103
|
...usageBlock,
|
|
104
|
+
// Who answered, off the completion itself — `gpt-4o-mini` asked for can
|
|
105
|
+
// come back as `gpt-4o-mini-2024-07-18`, and an override can make it
|
|
106
|
+
// something else entirely. `response` is the SAME shape either way, so
|
|
107
|
+
// this one line covers the streamed path too: the chunks carry `model`
|
|
108
|
+
// and `accumulateOpenAI` folds it in.
|
|
109
|
+
...respondingModel(response),
|
|
103
110
|
};
|
|
104
111
|
},
|
|
105
112
|
};
|
package/dist/brain/retry.d.ts
CHANGED
|
@@ -5,7 +5,96 @@
|
|
|
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
|
+
/**
|
|
87
|
+
* Wrap a side-effecting sink so that only the attempt that first delivers
|
|
88
|
+
* through it may deliver at all — unless the sink declared `acceptResets`, in
|
|
89
|
+
* which case a later attempt may deliver after telling it to clear (ص٢/٢).
|
|
90
|
+
*
|
|
91
|
+
* Handed to the retried function; ignoring it is the pre-ب١/٢ behaviour, so no
|
|
92
|
+
* caller is broken by its existence. The signature stays generic on purpose:
|
|
93
|
+
* every call site today passes a chunk sink, but narrowing the parameter to
|
|
94
|
+
* `BrainChunk` would be a surface change for no gain — the reset branch reaches
|
|
95
|
+
* the chunk type through `isResetAware`, which is a runtime fact, not a guess.
|
|
8
96
|
*/
|
|
97
|
+
export type SinkGate = <A extends unknown[]>(sink: (...args: A) => void) => (...args: A) => void;
|
|
9
98
|
export interface RetryOpts {
|
|
10
99
|
/** Total attempts including the first (default: 2 = one retry). */
|
|
11
100
|
attempts?: number;
|
|
@@ -16,5 +105,12 @@ export interface RetryOpts {
|
|
|
16
105
|
}
|
|
17
106
|
/** True for errors worth one retry: 429, 5xx, or low-level network failures. */
|
|
18
107
|
export declare function isTransientError(err: unknown): boolean;
|
|
19
|
-
/**
|
|
20
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Run `fn`, retrying once (by default) on transient provider errors.
|
|
110
|
+
*
|
|
111
|
+
* `fn` receives an `emitOnce` gate (see the header): wrap the consumer's chunk
|
|
112
|
+
* sink in it and no text is delivered twice across attempts. A zero-argument
|
|
113
|
+
* thunk — every caller written before ب١/٢ — is assignable unchanged and behaves
|
|
114
|
+
* exactly as it did.
|
|
115
|
+
*/
|
|
116
|
+
export declare function withRetry<T>(fn: (emitOnce: SinkGate) => Promise<T>, opts?: RetryOpts): Promise<T>;
|
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;
|
|
@@ -162,6 +184,8 @@ export interface AnthropicMessage {
|
|
|
162
184
|
input_tokens: number;
|
|
163
185
|
output_tokens: number;
|
|
164
186
|
};
|
|
187
|
+
/** Who answered — `Message.model`, and the `message_start` message's model. */
|
|
188
|
+
model?: string;
|
|
165
189
|
}
|
|
166
190
|
/**
|
|
167
191
|
* The text this event carries, or "" for anything else (thinking included).
|
|
@@ -215,6 +239,16 @@ export interface GeminiResponse {
|
|
|
215
239
|
promptTokenCount?: number;
|
|
216
240
|
candidatesTokenCount?: number;
|
|
217
241
|
};
|
|
242
|
+
/**
|
|
243
|
+
* Who answered. Google's spelling of the field, and the reason
|
|
244
|
+
* `respondingModel` knows two names for one thing.
|
|
245
|
+
*
|
|
246
|
+
* Undeclared by `@google/generative-ai`'s own `GenerateContentResponse` (it
|
|
247
|
+
* stops at three properties) — but the SDK hands back the parsed response
|
|
248
|
+
* body as it arrived, so the field is live on the wire and on the object.
|
|
249
|
+
* Declaring it here is what lets the brain read it without a cast.
|
|
250
|
+
*/
|
|
251
|
+
modelVersion?: string;
|
|
218
252
|
}
|
|
219
253
|
/**
|
|
220
254
|
* The text this stream item carries, across ALL of its text parts.
|
|
@@ -258,6 +292,8 @@ export interface OllamaStreamLine {
|
|
|
258
292
|
};
|
|
259
293
|
prompt_eval_count?: number;
|
|
260
294
|
eval_count?: number;
|
|
295
|
+
/** The served tag — Ollama repeats it on every line. */
|
|
296
|
+
model?: string;
|
|
261
297
|
}
|
|
262
298
|
/** What the Ollama brain reads off `/api/chat` — streamed or not. */
|
|
263
299
|
export interface OllamaChatResponse {
|
|
@@ -272,6 +308,12 @@ export interface OllamaChatResponse {
|
|
|
272
308
|
};
|
|
273
309
|
prompt_eval_count?: number;
|
|
274
310
|
eval_count?: number;
|
|
311
|
+
/**
|
|
312
|
+
* Who answered — the served tag, top level in the `/api/chat` body. On a
|
|
313
|
+
* local host this is the one place a re-pulled or re-tagged model shows
|
|
314
|
+
* itself, since nothing about the request would change.
|
|
315
|
+
*/
|
|
316
|
+
model?: string;
|
|
275
317
|
}
|
|
276
318
|
/**
|
|
277
319
|
* 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.
|
|
@@ -260,9 +292,14 @@ export function accumulateGemini(items) {
|
|
|
260
292
|
const parts = [];
|
|
261
293
|
let textSlot = -1;
|
|
262
294
|
let usage;
|
|
295
|
+
let modelVersion;
|
|
263
296
|
for (const item of items) {
|
|
264
297
|
if (item.usageMetadata)
|
|
265
298
|
usage = item.usageMetadata;
|
|
299
|
+
// Every element repeats it (each is a whole `GenerateContentResponse`);
|
|
300
|
+
// last one wins, as with usage.
|
|
301
|
+
if (item.modelVersion)
|
|
302
|
+
modelVersion = item.modelVersion;
|
|
266
303
|
for (const part of item.candidates?.[0]?.content?.parts ?? []) {
|
|
267
304
|
if (typeof part.text === "string") {
|
|
268
305
|
if (textSlot === -1) {
|
|
@@ -281,6 +318,7 @@ export function accumulateGemini(items) {
|
|
|
281
318
|
return {
|
|
282
319
|
candidates: [{ content: { parts } }],
|
|
283
320
|
...(usage ? { usageMetadata: usage } : {}),
|
|
321
|
+
...(modelVersion !== undefined ? { modelVersion } : {}),
|
|
284
322
|
};
|
|
285
323
|
}
|
|
286
324
|
/**
|
|
@@ -323,6 +361,7 @@ export function accumulateOllama(lines) {
|
|
|
323
361
|
const toolCalls = [];
|
|
324
362
|
let promptEvalCount;
|
|
325
363
|
let evalCount;
|
|
364
|
+
let model;
|
|
326
365
|
for (const line of lines) {
|
|
327
366
|
if (typeof line.message?.content === "string")
|
|
328
367
|
content += line.message.content;
|
|
@@ -333,6 +372,9 @@ export function accumulateOllama(lines) {
|
|
|
333
372
|
}
|
|
334
373
|
if (typeof line.eval_count === "number")
|
|
335
374
|
evalCount = line.eval_count;
|
|
375
|
+
// Repeated on every line; last one wins, as with the counts.
|
|
376
|
+
if (line.model)
|
|
377
|
+
model = line.model;
|
|
336
378
|
}
|
|
337
379
|
return {
|
|
338
380
|
message: {
|
|
@@ -341,6 +383,7 @@ export function accumulateOllama(lines) {
|
|
|
341
383
|
},
|
|
342
384
|
...(promptEvalCount !== undefined ? { prompt_eval_count: promptEvalCount } : {}),
|
|
343
385
|
...(evalCount !== undefined ? { eval_count: evalCount } : {}),
|
|
386
|
+
...(model !== undefined ? { model } : {}),
|
|
344
387
|
};
|
|
345
388
|
}
|
|
346
389
|
/**
|