@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.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * In-memory RunLockPort — a session mutex with no Redis.
3
+ *
4
+ * **This one has to actually lock.** A stub that always grants would make every
5
+ * test of the no-Redis loop pass while removing the guarantee the lock exists
6
+ * for: one turn per session at a time. Two turns running into one session
7
+ * interleave their history writes and their log appends, and the damage is
8
+ * silent — a log with two turns braided together still reads as a log. So the
9
+ * in-RAM lock is a real mutex, and it is guarded by a test that starts a second
10
+ * turn while the first is still inside the brain call and watches it wait.
11
+ *
12
+ * Fidelity to `RedisDistributedLock`, semantics first:
13
+ * - **held is held**: `acquire` returns `null` for a session already locked,
14
+ * rather than a second handle.
15
+ * - **TTL is honoured**: a lock past its `ttlMs` is treated as gone, exactly
16
+ * as `SET PX` lets Redis drop it. A RAM lock that never expired would
17
+ * deadlock a session that Redis would have freed — the mirror would be
18
+ * safer than the original, which is its own kind of lie.
19
+ * - **release and extend are token-checked**: only the holder's own handle
20
+ * can release or extend, which is what the `GET == token` Lua scripts buy
21
+ * on the Redis side. A late release from a previous holder whose lock has
22
+ * already expired must not unlock the current one.
23
+ * - **the retry loop is the same shape**: attempt, jittered sleep, deadline —
24
+ * and the same error message when the deadline passes, because that string
25
+ * is what surfaces from the loop when a session is genuinely stuck.
26
+ *
27
+ * Scope: ONE process. Across replicas this excludes nothing at all, and that is
28
+ * not a limitation to work around — a mutex for a single-process deploy is the
29
+ * whole of what it claims to be. Multi-replica deploys use
30
+ * `RedisDistributedLock`.
31
+ */
32
+ import type { LockHandle, RunLockPort } from "../core/types.js";
33
+ export declare class InMemoryLock implements RunLockPort {
34
+ private readonly held;
35
+ /** The live holder of this session, or `undefined` if free or expired. */
36
+ private current;
37
+ /**
38
+ * Take the lock, or return `null` if someone else holds it.
39
+ * Not required by `RunLockPort` — the loop only ever waits — but it is the
40
+ * primitive `acquireWithRetry` is built from, and `RedisDistributedLock`
41
+ * exposes it too.
42
+ */
43
+ acquire(sessionId: string, ttlMs: number): Promise<LockHandle | null>;
44
+ acquireWithRetry(sessionId: string, ttlMs: number, waitMs?: number, retryInterval?: number): Promise<LockHandle>;
45
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * In-memory RunLockPort — a session mutex with no Redis.
3
+ *
4
+ * **This one has to actually lock.** A stub that always grants would make every
5
+ * test of the no-Redis loop pass while removing the guarantee the lock exists
6
+ * for: one turn per session at a time. Two turns running into one session
7
+ * interleave their history writes and their log appends, and the damage is
8
+ * silent — a log with two turns braided together still reads as a log. So the
9
+ * in-RAM lock is a real mutex, and it is guarded by a test that starts a second
10
+ * turn while the first is still inside the brain call and watches it wait.
11
+ *
12
+ * Fidelity to `RedisDistributedLock`, semantics first:
13
+ * - **held is held**: `acquire` returns `null` for a session already locked,
14
+ * rather than a second handle.
15
+ * - **TTL is honoured**: a lock past its `ttlMs` is treated as gone, exactly
16
+ * as `SET PX` lets Redis drop it. A RAM lock that never expired would
17
+ * deadlock a session that Redis would have freed — the mirror would be
18
+ * safer than the original, which is its own kind of lie.
19
+ * - **release and extend are token-checked**: only the holder's own handle
20
+ * can release or extend, which is what the `GET == token` Lua scripts buy
21
+ * on the Redis side. A late release from a previous holder whose lock has
22
+ * already expired must not unlock the current one.
23
+ * - **the retry loop is the same shape**: attempt, jittered sleep, deadline —
24
+ * and the same error message when the deadline passes, because that string
25
+ * is what surfaces from the loop when a session is genuinely stuck.
26
+ *
27
+ * Scope: ONE process. Across replicas this excludes nothing at all, and that is
28
+ * not a limitation to work around — a mutex for a single-process deploy is the
29
+ * whole of what it claims to be. Multi-replica deploys use
30
+ * `RedisDistributedLock`.
31
+ */
32
+ import { randomBytes } from "crypto";
33
+ export class InMemoryLock {
34
+ held = new Map();
35
+ /** The live holder of this session, or `undefined` if free or expired. */
36
+ current(sessionId) {
37
+ const entry = this.held.get(sessionId);
38
+ if (!entry)
39
+ return undefined;
40
+ if (entry.expiresAt <= Date.now()) {
41
+ this.held.delete(sessionId);
42
+ return undefined;
43
+ }
44
+ return entry;
45
+ }
46
+ /**
47
+ * Take the lock, or return `null` if someone else holds it.
48
+ * Not required by `RunLockPort` — the loop only ever waits — but it is the
49
+ * primitive `acquireWithRetry` is built from, and `RedisDistributedLock`
50
+ * exposes it too.
51
+ */
52
+ async acquire(sessionId, ttlMs) {
53
+ if (this.current(sessionId))
54
+ return null;
55
+ const token = randomBytes(16).toString("hex");
56
+ this.held.set(sessionId, { token, expiresAt: Date.now() + ttlMs });
57
+ const ownsIt = () => this.current(sessionId)?.token === token;
58
+ return {
59
+ release: async () => {
60
+ // Token-checked: a handle whose lock already expired and was retaken
61
+ // must not release the new holder's lock.
62
+ if (ownsIt())
63
+ this.held.delete(sessionId);
64
+ },
65
+ extend: async (newTtlMs) => {
66
+ if (!ownsIt())
67
+ return false;
68
+ this.held.set(sessionId, {
69
+ token,
70
+ expiresAt: Date.now() + newTtlMs,
71
+ });
72
+ return true;
73
+ },
74
+ };
75
+ }
76
+ async acquireWithRetry(sessionId, ttlMs, waitMs = 5000, retryInterval = 100) {
77
+ const deadline = Date.now() + waitMs;
78
+ while (Date.now() < deadline) {
79
+ const handle = await this.acquire(sessionId, ttlMs);
80
+ if (handle)
81
+ return handle;
82
+ await sleep(retryInterval + Math.random() * 50);
83
+ }
84
+ throw new Error(`msm-mini: could not acquire lock for session ${sessionId} within ${waitMs}ms`);
85
+ }
86
+ }
87
+ function sleep(ms) {
88
+ return new Promise((resolve) => setTimeout(resolve, ms));
89
+ }
@@ -7,7 +7,8 @@
7
7
  * Checked on every loop iteration before the brain call.
8
8
  */
9
9
  import type { RedisLike } from "./redis-types.js";
10
- export declare class RedisControlBus {
10
+ import type { ControlBusPort } from "../core/types.js";
11
+ export declare class RedisControlBus implements ControlBusPort {
11
12
  private readonly redis;
12
13
  private readonly prefix;
13
14
  constructor(redis: RedisLike, prefix?: string);
@@ -8,11 +8,15 @@
8
8
  * TTL: timeoutMs + 30s safety margin
9
9
  */
10
10
  import type { RedisLike } from "./redis-types.js";
11
- export interface LockHandle {
12
- release(): Promise<void>;
13
- extend(ttlMs: number): Promise<boolean>;
14
- }
15
- export declare class RedisDistributedLock {
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);
@@ -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;
@@ -81,22 +82,46 @@ export function createAnthropicBrain(opts) {
81
82
  params: (block.input ?? {}),
82
83
  });
83
84
  }
85
+ // Text and thinking — the same rule as Gemini's (ت١/٨). EVERY `text`
86
+ // block, in the order Claude emitted them, joined with no separator;
87
+ // `thinking` blocks join separately into `thoughts` and never into the
88
+ // answer. Measured before this: `.find()` took the FIRST text block and
89
+ // dropped every later one, while thinking was already kept out by the
90
+ // type check. A `thinking` body only arrives unstreamed — the streamed
91
+ // fold carries no such field (see `AnthropicMessage`) — and this brain
92
+ // never asks for thinking, so `thoughts` is a contract, not a promise.
93
+ let text = "";
94
+ let thoughts = "";
95
+ for (const block of content) {
96
+ if (block.type === "text" && typeof block.text === "string") {
97
+ text += block.text;
98
+ }
99
+ else if (block.type === "thinking" && typeof block.thinking === "string") {
100
+ thoughts += block.thinking;
101
+ }
102
+ }
103
+ const thinking = thoughts ? { thoughts } : {};
84
104
  const orchestration = useToolOrchestration(calls, 0.9);
85
105
  if (orchestration) {
86
106
  return {
87
107
  orchestration,
88
108
  usage: { inputTokens, outputTokens },
89
109
  costUsd,
110
+ ...thinking,
111
+ ...respondingModel(response),
90
112
  };
91
113
  }
92
- // Text
93
- const textBlock = content.find((b) => b.type === "text");
94
- const text = textBlock?.text ?? "";
95
114
  return {
96
115
  generation: { response_text: text },
97
116
  orchestration: { action: "respond", confidence: 0.95 },
98
117
  usage: { inputTokens, outputTokens },
99
118
  costUsd,
119
+ ...thinking,
120
+ // Who answered, off the message itself — Claude names the model that
121
+ // served the request, which is not always the alias that was asked for.
122
+ // Streamed or not, `response` is the same shape: `message_start`
123
+ // carries the name and `accumulateAnthropic` keeps it.
124
+ ...respondingModel(response),
100
125
  };
101
126
  },
102
127
  };
@@ -13,11 +13,64 @@ export function buildBrain(def) {
13
13
  return createOpenAIBrain({ ...(model ? { model } : {}), ...(endpoint ? { baseURL: endpoint } : {}), ...(apiKey ? { apiKey } : {}) });
14
14
  case "anthropic":
15
15
  return createAnthropicBrain({ ...(model ? { model } : {}), ...(apiKey ? { apiKey } : {}) });
16
- case "gemini":
17
- return createGeminiBrain({ ...(model ? { model } : {}), ...(apiKey ? { apiKey } : {}) });
16
+ case "gemini": {
17
+ // `## Brain` `thinking` (ت١/٢): the definition's key reaches the brain
18
+ // here, and only after its shape is checked — a definition is data from
19
+ // a file or a host, and `[key: string]: unknown` lets anything through.
20
+ const thinking = brainThinkingOf(def.brain.thinking);
21
+ return createGeminiBrain({
22
+ ...(model ? { model } : {}),
23
+ ...(apiKey ? { apiKey } : {}),
24
+ ...(thinking ? { thinking } : {}),
25
+ });
26
+ }
18
27
  case "ollama":
19
28
  return createOllamaBrain({ ...(endpoint ? { endpoint } : {}), ...(model ? { model } : {}) });
20
29
  default:
21
30
  throw new Error(`msm-mini: unknown brain provider "${String(provider)}". Pass a Brain instance directly to createAgent().`);
22
31
  }
23
32
  }
33
+ /**
34
+ * `def.brain.thinking` checked into the option `createGeminiBrain` takes, or
35
+ * `undefined` when the definition has none.
36
+ *
37
+ * A NAMED error for a wrong shape, not silence: `thinking` was added so a
38
+ * definition could switch the model's reasoning on, and a value that quietly
39
+ * did nothing would be the same leak with a config line under it. An object;
40
+ * `budget` a finite number ≥ 0 when given (`0` is thinking OFF); `includeThoughts`
41
+ * a boolean when given. Each field is copied only when set, so `{}` — and a
42
+ * host that spreads `{ budget: undefined }` — reaches the brain as an empty
43
+ * option, which the brain reads as absence.
44
+ */
45
+ function brainThinkingOf(value) {
46
+ if (value === undefined)
47
+ return undefined;
48
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
49
+ throw new Error(`msm-mini: brain.thinking must be an object { budget?, includeThoughts? }, got ${describe(value)}`);
50
+ }
51
+ // `in` narrows `object` to the key it names — no cast over the definition.
52
+ const budget = "budget" in value ? value.budget : undefined;
53
+ const includeThoughts = "includeThoughts" in value ? value.includeThoughts : undefined;
54
+ if (budget !== undefined && !(typeof budget === "number" && Number.isFinite(budget) && budget >= 0)) {
55
+ throw new Error(`msm-mini: brain.thinking.budget must be a finite number >= 0, got ${describe(budget)}`);
56
+ }
57
+ if (includeThoughts !== undefined && typeof includeThoughts !== "boolean") {
58
+ throw new Error(`msm-mini: brain.thinking.includeThoughts must be a boolean, got ${describe(includeThoughts)}`);
59
+ }
60
+ return {
61
+ ...(budget !== undefined ? { budget } : {}),
62
+ ...(includeThoughts !== undefined ? { includeThoughts } : {}),
63
+ };
64
+ }
65
+ /** A value as an error message shows it: `"abc"`, `null`, `an array`. */
66
+ function describe(value) {
67
+ if (value === null)
68
+ return "null";
69
+ if (Array.isArray(value))
70
+ return "an array";
71
+ if (typeof value === "string")
72
+ return JSON.stringify(value);
73
+ if (typeof value === "object")
74
+ return "an object";
75
+ return String(value);
76
+ }
@@ -3,7 +3,23 @@
3
3
  * Peer dependency: @google/generative-ai >= 0.14.0
4
4
  */
5
5
  import type { Brain } from "./types.js";
6
- export declare function createGeminiBrain(opts: {
6
+ export interface GeminiBrainOptions {
7
7
  apiKey?: string;
8
8
  model?: string;
9
- }): Brain;
9
+ /**
10
+ * Thinking, opted into (ت١/٤). Translated to `generationConfig.thinkingConfig`
11
+ * — `budget` → `thinkingBudget`, `includeThoughts` → `includeThoughts`, each
12
+ * only when given — and ONLY when at least one field is given: absent,
13
+ * explicitly `undefined` from a JavaScript host, or an empty `{}`, the
14
+ * request carries no `generationConfig` at all and not one byte of it changes.
15
+ *
16
+ * `budget: 0` turns thinking off on models that allow it; `includeThoughts:
17
+ * true` asks for the reasoning back as parts tagged `thought: true`, which
18
+ * the brain routes to `BrainPayload.thoughts` and never into the answer.
19
+ */
20
+ thinking?: {
21
+ budget?: number;
22
+ includeThoughts?: boolean;
23
+ };
24
+ }
25
+ export declare function createGeminiBrain(opts: GeminiBrainOptions): Brain;
@@ -2,12 +2,16 @@
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";
8
9
  import { computeCostUsd } from "./pricing.js";
9
10
  export function createGeminiBrain(opts) {
10
11
  const model = opts.model ?? "gemini-2.5-flash";
12
+ // Resolved once, at composition: the request below spreads it, so a brain
13
+ // built without the option builds the request it always built.
14
+ const thinkingConfig = thinkingConfigOf(opts.thinking);
11
15
  return {
12
16
  name: "gemini",
13
17
  async run(input) {
@@ -54,13 +58,19 @@ export function createGeminiBrain(opts) {
54
58
  },
55
59
  ]
56
60
  : undefined;
57
- const request = tools
58
- ? {
59
- systemInstruction: input.system_context,
60
- contents,
61
- tools: tools,
62
- }
63
- : { systemInstruction: input.system_context, contents };
61
+ const request = {
62
+ systemInstruction: input.system_context,
63
+ contents,
64
+ ...(tools
65
+ ? {
66
+ tools: tools,
67
+ }
68
+ : {}),
69
+ // The one key ت١ adds, and only for a brain that asked for it — a
70
+ // conditional spread, so "no option" leaves no `generationConfig` key
71
+ // (not even an `undefined` one) for the SDK to serialize.
72
+ ...(thinkingConfig ? { generationConfig: { thinkingConfig } } : {}),
73
+ };
64
74
  // ── The one branch streaming adds ──────────────────────────────────
65
75
  //
66
76
  // No `onChunk`, no stream: the call below is the call this brain has
@@ -68,7 +78,7 @@ export function createGeminiBrain(opts) {
68
78
  // land in `GeminiResponse` — the fields this brain reads — so the payload
69
79
  // is built once, from one shape.
70
80
  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 } : {})
81
+ ? await withRetry((emitOnce) => streamContent(() => geminiModel.generateContentStream(request, input.signal ? { signal: input.signal } : {}), emitOnce(input.onChunk), input.signal), input.signal ? { signal: input.signal } : {})
72
82
  : (await withRetry(() => geminiModel.generateContent(request, input.signal ? { signal: input.signal } : {}), input.signal ? { signal: input.signal } : {})).response;
73
83
  const parts = response?.candidates?.[0]?.content?.parts ?? [];
74
84
  // Token usage + cost — computed ONCE and returned on BOTH the tool-call and text
@@ -98,22 +108,84 @@ export function createGeminiBrain(opts) {
98
108
  params: (fc.args ?? {}),
99
109
  });
100
110
  }
111
+ // Text and thinking — EVERY text part, in emitted order, joined with no
112
+ // separator; the parts the model tagged `thought: true` join separately
113
+ // into `thoughts` and never into the answer (ت١). `.find()` took the
114
+ // first text part and dropped the rest, and with thinking on that first
115
+ // part was the reasoning, so the user read the model's notes instead of
116
+ // its reply. Untagged prose is kept as written: what the provider did not
117
+ // mark is answer text, and guessing is the prompt's job, not the loop's.
118
+ //
119
+ // One loop for both shapes — the raw candidate `generateContent` returns
120
+ // and the folded one `accumulateGemini` builds, which keeps its thinking
121
+ // in a part tagged the same way — so the streamed and unstreamed paths
122
+ // decide text-versus-thinking on the same lines.
123
+ let text = "";
124
+ let thoughts = "";
125
+ for (const part of parts) {
126
+ if (typeof part.text !== "string")
127
+ continue;
128
+ if (part.thought === true)
129
+ thoughts += part.text;
130
+ else
131
+ text += part.text;
132
+ }
133
+ // Absent when there was no thinking — a key only when it says something.
134
+ const thinking = thoughts ? { thoughts } : {};
101
135
  const orchestration = useToolOrchestration(calls, 0.9);
102
136
  if (orchestration) {
103
- return { orchestration, costUsd, ...(usage ? { usage } : {}) };
137
+ return {
138
+ orchestration,
139
+ costUsd,
140
+ ...(usage ? { usage } : {}),
141
+ ...thinking,
142
+ ...respondingModel(response),
143
+ };
104
144
  }
105
- // Text response
106
- const textPart = parts.find((p) => "text" in p && typeof p.text === "string");
107
- const text = textPart && "text" in textPart ? textPart.text : "";
108
145
  return {
109
146
  generation: { response_text: text },
110
147
  orchestration: { action: "respond", confidence: 0.95 },
111
148
  costUsd,
112
149
  ...(usage ? { usage } : {}),
150
+ ...thinking,
151
+ // Who answered — Gemini spells it `modelVersion`, and it is the field
152
+ // that tells `gemini-2.5-flash` from the dated build that served it.
153
+ // The SDK's non-streaming path hands back the parsed response body as
154
+ // it arrived, so the field survives even though its own `.d.ts` stops
155
+ // at three properties; on the streamed path every element repeats it
156
+ // and `accumulateGemini` carries it through. One line, both paths.
157
+ ...respondingModel(response),
113
158
  };
114
159
  },
115
160
  };
116
161
  }
162
+ /**
163
+ * `createGeminiBrain({ thinking })` → the wire's `thinkingConfig`, or
164
+ * `undefined` when the option says nothing.
165
+ *
166
+ * Absence is read from the VALUE, not the key (تعميم المجلس ٦): a JavaScript
167
+ * host that spreads `thinking: undefined` gets the request it would get with
168
+ * no option at all. Each field is copied only when set, and `0` is a value —
169
+ * `budget: 0` is how thinking is switched OFF on a model that thinks by
170
+ * default, and a truthiness check would have thrown that away.
171
+ *
172
+ * **An empty object is absence too** (ت١/٢ item 5). `thinking: {}` — or
173
+ * `{ budget: undefined }` from a host that spreads what it does not have —
174
+ * names no field, so nothing goes on the wire: no `generationConfig`, and no
175
+ * `thinkingConfig: {}` that nobody asked for. The key is written only when
176
+ * it carries at least one value.
177
+ */
178
+ function thinkingConfigOf(thinking) {
179
+ if (thinking === undefined)
180
+ return undefined;
181
+ const config = {
182
+ ...(thinking.budget !== undefined ? { thinkingBudget: thinking.budget } : {}),
183
+ ...(thinking.includeThoughts !== undefined
184
+ ? { includeThoughts: thinking.includeThoughts }
185
+ : {}),
186
+ };
187
+ return Object.keys(config).length > 0 ? config : undefined;
188
+ }
117
189
  /**
118
190
  * `generateContentStream` read to the end, folded back into the response shape
119
191
  * the caller above expects.
@@ -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
  };
@@ -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
  };
@@ -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
- /** Run `fn`, retrying once (by default) on transient provider errors. */
20
- export declare function withRetry<T>(fn: () => Promise<T>, opts?: RetryOpts): Promise<T>;
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>;