@msm-core/mini 0.5.1 → 0.8.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 CHANGED
@@ -5,6 +5,31 @@ Follows [Semantic Versioning](https://semver.org/).
5
5
 
6
6
  ---
7
7
 
8
+ ## [0.5.2] — 2026-07-01
9
+
10
+ ### Fixed
11
+
12
+ - **The Gemini brain now reports token usage + cost on the tool-call path.** It
13
+ previously returned a bare `{ orchestration }` there (only the text path carried
14
+ usage/cost), so the loop's `costCapPerTask` accrued $0 on exactly the iterations
15
+ that dominate an agentic run — the cost cap never fired on the primary provider.
16
+ Both paths now compute usage + cost once, via the shared pricing table
17
+ (`computeCostUsd`) instead of hardcoded Flash rates, so non-Flash models bill
18
+ correctly. Locked with `tests/gemini-usage.test.ts`.
19
+
20
+ ### Added
21
+
22
+ - Pricing table entry for `gemini-2.5-pro` (Pro agents previously had no cost tracking).
23
+
24
+ ## [0.5.1] — 2026-06-30
25
+
26
+ ### Added
27
+
28
+ - **Array tool-parameters.** `ToolParameter.items` is now forwarded to every
29
+ provider (Gemini included), so an array-typed tool parameter (e.g. a list of
30
+ template variables) is described to the model instead of being dropped — which
31
+ had caused arrays to 400 on Gemini.
32
+
8
33
  ## [0.5.0] — 2026-06-30
9
34
 
10
35
  Brain-parity + guard-integrity release from the 2026-06 SDK audit (H1, H2, H10, M1).
@@ -8,5 +8,9 @@ export { RedisDistributedLock } from "./redis-lock.js";
8
8
  export type { LockHandle } from "./redis-lock.js";
9
9
  /** Correct single-node RedisLike for local / no-Redis / sovereign deploys + tests. */
10
10
  export { InMemoryRedis, createInMemoryRedis } from "./memory-redis.js";
11
+ /** In-RAM SessionStore — the memory port with no Redis and no network. */
12
+ export { InMemorySessionStore } from "./memory-store.js";
11
13
  /** The client shape RedisConfig.client expects — for wiring a custom client. */
12
14
  export type { RedisLike } from "./redis-types.js";
15
+ /** The port `AgentConfig.memory` accepts — for writing a custom session store. */
16
+ export type { SessionStore } from "../core/types.js";
@@ -6,3 +6,5 @@ export { RedisControlBus } from "./redis-control.js";
6
6
  export { RedisDistributedLock } from "./redis-lock.js";
7
7
  /** Correct single-node RedisLike for local / no-Redis / sovereign deploys + tests. */
8
8
  export { InMemoryRedis, createInMemoryRedis } from "./memory-redis.js";
9
+ /** In-RAM SessionStore — the memory port with no Redis and no network. */
10
+ export { InMemorySessionStore } from "./memory-store.js";
@@ -0,0 +1,38 @@
1
+ /**
2
+ * In-memory SessionStore — the memory port with no Redis and no network.
3
+ *
4
+ * The bundled counterpart to `RedisMemory`: a complete implementation of the
5
+ * six `SessionStore` functions, held in process memory. It exists because until
6
+ * now the loop built its store by hand from a Redis config, so a test — or a
7
+ * local / air-gapped deploy — could not run the loop without a live Redis for
8
+ * session memory.
9
+ *
10
+ * Fidelity to `RedisMemory` is deliberate, so swapping one for the other does
11
+ * not change loop behaviour:
12
+ * - `getHistory` returns the LAST `limit` entries, oldest-first (`lrange -limit -1`).
13
+ * - history is capped at `maxHistoryEntries` (default 500), oldest dropped
14
+ * first — the same silent head-loss the Redis adapter has via `ltrim`.
15
+ * - reads and writes are deep-copied, mirroring the JSON round-trip a real
16
+ * store performs, so a caller holding a returned object can never mutate
17
+ * what is stored (the loop does mutate the DocumentState it reads back).
18
+ *
19
+ * Scope: ONE process, no expiry. TTLs are a persistence concern; nothing here
20
+ * outlives the process, so nothing needs evicting. For multi-replica deploys
21
+ * and for durability across restarts, use `RedisMemory`.
22
+ */
23
+ import type { DocumentState, Message, SessionMetadata, SessionStore } from "../core/types.js";
24
+ export declare class InMemorySessionStore implements SessionStore {
25
+ private readonly sessions;
26
+ private readonly maxHistoryEntries;
27
+ constructor(opts?: {
28
+ /** Hard cap on stored history entries per session (default: 500). */
29
+ maxHistoryEntries?: number;
30
+ });
31
+ private slot;
32
+ appendHistory(sessionId: string, entry: Message): Promise<void>;
33
+ getHistory(sessionId: string, limit?: number): Promise<Message[]>;
34
+ getMetadata(sessionId: string): Promise<SessionMetadata | null>;
35
+ setMetadata(sessionId: string, meta: SessionMetadata): Promise<void>;
36
+ getDocumentState(sessionId: string): Promise<DocumentState | null>;
37
+ setDocumentState(sessionId: string, state: DocumentState): Promise<void>;
38
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * In-memory SessionStore — the memory port with no Redis and no network.
3
+ *
4
+ * The bundled counterpart to `RedisMemory`: a complete implementation of the
5
+ * six `SessionStore` functions, held in process memory. It exists because until
6
+ * now the loop built its store by hand from a Redis config, so a test — or a
7
+ * local / air-gapped deploy — could not run the loop without a live Redis for
8
+ * session memory.
9
+ *
10
+ * Fidelity to `RedisMemory` is deliberate, so swapping one for the other does
11
+ * not change loop behaviour:
12
+ * - `getHistory` returns the LAST `limit` entries, oldest-first (`lrange -limit -1`).
13
+ * - history is capped at `maxHistoryEntries` (default 500), oldest dropped
14
+ * first — the same silent head-loss the Redis adapter has via `ltrim`.
15
+ * - reads and writes are deep-copied, mirroring the JSON round-trip a real
16
+ * store performs, so a caller holding a returned object can never mutate
17
+ * what is stored (the loop does mutate the DocumentState it reads back).
18
+ *
19
+ * Scope: ONE process, no expiry. TTLs are a persistence concern; nothing here
20
+ * outlives the process, so nothing needs evicting. For multi-replica deploys
21
+ * and for durability across restarts, use `RedisMemory`.
22
+ */
23
+ /** Deep copy through JSON — the same serialization boundary a real store has. */
24
+ function clone(value) {
25
+ return JSON.parse(JSON.stringify(value));
26
+ }
27
+ export class InMemorySessionStore {
28
+ sessions = new Map();
29
+ maxHistoryEntries;
30
+ constructor(opts = {}) {
31
+ this.maxHistoryEntries = opts.maxHistoryEntries ?? 500;
32
+ }
33
+ slot(sessionId) {
34
+ let slot = this.sessions.get(sessionId);
35
+ if (!slot) {
36
+ slot = { history: [], metadata: null, document: null };
37
+ this.sessions.set(sessionId, slot);
38
+ }
39
+ return slot;
40
+ }
41
+ // ─── History ──────────────────────────────────────────────
42
+ async appendHistory(sessionId, entry) {
43
+ const slot = this.slot(sessionId);
44
+ slot.history.push(clone(entry));
45
+ // Cap storage growth for chatty sessions, exactly as RedisMemory's ltrim does.
46
+ if (slot.history.length > this.maxHistoryEntries) {
47
+ slot.history.splice(0, slot.history.length - this.maxHistoryEntries);
48
+ }
49
+ }
50
+ async getHistory(sessionId, limit = 50) {
51
+ const history = this.sessions.get(sessionId)?.history ?? [];
52
+ // `lrange(key, -limit, -1)`: the tail of `limit` entries. A limit of 0 (or
53
+ // below) means "no tail window" and returns everything, as Redis does.
54
+ const start = limit > 0 ? Math.max(history.length - limit, 0) : 0;
55
+ return history.slice(start).map(clone);
56
+ }
57
+ // ─── Metadata ─────────────────────────────────────────────
58
+ async getMetadata(sessionId) {
59
+ const meta = this.sessions.get(sessionId)?.metadata;
60
+ return meta ? clone(meta) : null;
61
+ }
62
+ async setMetadata(sessionId, meta) {
63
+ this.slot(sessionId).metadata = clone(meta);
64
+ }
65
+ // ─── Document State (section tracker) ────────────────────
66
+ async getDocumentState(sessionId) {
67
+ const doc = this.sessions.get(sessionId)?.document;
68
+ return doc ? clone(doc) : null;
69
+ }
70
+ async setDocumentState(sessionId, state) {
71
+ this.slot(sessionId).document = clone(state);
72
+ }
73
+ }
@@ -9,15 +9,20 @@
9
9
  * {prefix}:session:{id}:metadata JSON object, TTL 24h
10
10
  * {prefix}:session:{id}:document JSON object, TTL 4h
11
11
  */
12
- import type { Message, DocumentState } from "../core/types.js";
12
+ import type { Message, DocumentState, SessionMetadata, SessionStore } from "../core/types.js";
13
13
  import type { RedisLike } from "./redis-types.js";
14
- export interface SessionMetadata {
15
- iterationCount: number;
16
- startedAt: number;
17
- totalCostUsd: number;
18
- status: "running" | "completed" | "failed" | "killed";
19
- }
20
- export declare class RedisMemory {
14
+ /**
15
+ * `SessionMetadata` now lives in core/types.ts with the `SessionStore` port it
16
+ * belongs to. Re-exported here so `import { SessionMetadata } from
17
+ * "@msm-core/mini/adapters"` — and every existing deep import — keeps working.
18
+ */
19
+ export type { SessionMetadata };
20
+ /**
21
+ * The Redis implementation of the `SessionStore` port. The `implements` clause
22
+ * is load-bearing: it makes the compiler break on any future drift between this
23
+ * adapter and the port the loop programs against.
24
+ */
25
+ export declare class RedisMemory implements SessionStore {
21
26
  private readonly redis;
22
27
  private readonly prefix;
23
28
  private readonly historyTtl;
@@ -9,6 +9,11 @@
9
9
  * {prefix}:session:{id}:metadata JSON object, TTL 24h
10
10
  * {prefix}:session:{id}:document JSON object, TTL 4h
11
11
  */
12
+ /**
13
+ * The Redis implementation of the `SessionStore` port. The `implements` clause
14
+ * is load-bearing: it makes the compiler break on any future drift between this
15
+ * adapter and the port the loop programs against.
16
+ */
12
17
  export class RedisMemory {
13
18
  redis;
14
19
  prefix;
@@ -4,7 +4,8 @@
4
4
  */
5
5
  import { computeCostUsd } from "./pricing.js";
6
6
  import { withRetry } from "./retry.js";
7
- import { foldToolResults, toolParamsToJsonSchema } from "./tool-context.js";
7
+ import { foldToolResults, toolParamsToJsonSchema, toWireMessage, useToolOrchestration, } from "./tool-context.js";
8
+ import { accumulateAnthropic, anthropicDelta, consumeStream, } from "./streaming.js";
8
9
  export function createAnthropicBrain(opts) {
9
10
  // claude-3-5-sonnet-20241022 was retired (2025-10-28); claude-sonnet-4-6 is
10
11
  // its current drop-in replacement.
@@ -24,10 +25,13 @@ export function createAnthropicBrain(opts) {
24
25
  if (!apiKey)
25
26
  throw new Error("msm-mini: ANTHROPIC_API_KEY not set");
26
27
  const client = new Anthropic({ apiKey });
27
- const messages = input.history.map((m) => ({
28
- role: m.role === "assistant" ? "assistant" : "user",
29
- content: m.content,
30
- }));
28
+ // Derived tool calls / tool results arrive TAGGED rather than flattened
29
+ // into anonymous `user` turns see the wire contract in tool-context.ts.
30
+ // Claude read its own tool round-trips as the user's words before this.
31
+ const messages = input.history.map((m) => {
32
+ const wire = toWireMessage(m);
33
+ return { role: wire.role, content: wire.content };
34
+ });
31
35
  // Fold prior tool results into the current turn so Claude sees what its
32
36
  // tool calls returned (and is nudged to finalize) — without this it
33
37
  // re-emits use_tool every iteration until the guard cap (H1).
@@ -46,25 +50,39 @@ export function createAnthropicBrain(opts) {
46
50
  system: input.system_context,
47
51
  messages: messages,
48
52
  };
49
- const response = await withRetry(() => client.messages.create(tools
53
+ const params = tools
50
54
  ? {
51
55
  ...baseParams,
52
56
  tools: tools,
53
57
  }
54
- : baseParams, input.signal ? { signal: input.signal } : {}), input.signal ? { signal: input.signal } : {});
58
+ : baseParams;
59
+ // ── The one branch streaming adds ──────────────────────────────────
60
+ //
61
+ // No `onChunk`, no stream: the call below is the call this brain has
62
+ // always made, and every line after this block is untouched. Both paths
63
+ // land in `AnthropicMessage` — the fields this brain reads — so the
64
+ // payload is built once, from one shape.
65
+ const response = input.onChunk
66
+ ? await withRetry(() => streamMessage(client, params, input.onChunk, input.signal), input.signal ? { signal: input.signal } : {})
67
+ : await withRetry(() => client.messages.create(params, input.signal ? { signal: input.signal } : {}), input.signal ? { signal: input.signal } : {});
55
68
  const content = response.content;
56
69
  const inputTokens = response.usage.input_tokens;
57
70
  const outputTokens = response.usage.output_tokens;
58
71
  const costUsd = computeCostUsd(model, inputTokens, outputTokens);
59
- // Tool use
60
- const toolUseBlock = content.find((b) => b.type === "tool_use");
61
- if (toolUseBlock && toolUseBlock.type === "tool_use") {
62
- const orchestration = {
63
- action: "use_tool",
64
- confidence: 0.9,
65
- tool_name: toolUseBlock.name,
66
- tool_params: toolUseBlock.input,
67
- };
72
+ // Tool use — EVERY `tool_use` block, in the order Claude emitted them.
73
+ // `.find()` kept the first and dropped the rest, and Claude emits parallel
74
+ // blocks routinely: each dropped block cost a full extra round-trip.
75
+ const calls = [];
76
+ for (const block of content) {
77
+ if (block.type !== "tool_use")
78
+ continue;
79
+ calls.push({
80
+ name: block.name ?? "",
81
+ params: (block.input ?? {}),
82
+ });
83
+ }
84
+ const orchestration = useToolOrchestration(calls, 0.9);
85
+ if (orchestration) {
68
86
  return {
69
87
  orchestration,
70
88
  usage: { inputTokens, outputTokens },
@@ -73,7 +91,7 @@ export function createAnthropicBrain(opts) {
73
91
  }
74
92
  // Text
75
93
  const textBlock = content.find((b) => b.type === "text");
76
- const text = textBlock && textBlock.type === "text" ? textBlock.text : "";
94
+ const text = textBlock?.text ?? "";
77
95
  return {
78
96
  generation: { response_text: text },
79
97
  orchestration: { action: "respond", confidence: 0.95 },
@@ -83,3 +101,18 @@ export function createAnthropicBrain(opts) {
83
101
  },
84
102
  };
85
103
  }
104
+ /**
105
+ * The same request with `stream: true`, read to the end, folded back into the
106
+ * message shape the caller above expects.
107
+ *
108
+ * Claude's stream is block-addressed rather than linear — `content_block_start`
109
+ * opens a block, deltas name its index, `content_block_stop` closes it — so the
110
+ * accumulator rebuilds blocks by index rather than by arrival. Text deltas are
111
+ * emitted; `input_json_delta` fragments of a tool call are not (v1 is text
112
+ * only), and thinking deltas are not text and are emitted by nobody.
113
+ */
114
+ async function streamMessage(client, params, sink, signal) {
115
+ const stream = await client.messages.create({ ...params, stream: true }, signal ? { signal } : {});
116
+ const events = await consumeStream(stream, anthropicDelta, sink, signal);
117
+ return accumulateAnthropic(events);
118
+ }
@@ -3,7 +3,9 @@
3
3
  * Peer dependency: @google/generative-ai >= 0.14.0
4
4
  */
5
5
  import { withRetry } from "./retry.js";
6
- import { foldToolResults } from "./tool-context.js";
6
+ import { foldToolResults, toWireMessage, useToolOrchestration, } from "./tool-context.js";
7
+ import { accumulateGemini, consumeStream, geminiDelta, } from "./streaming.js";
8
+ import { computeCostUsd } from "./pricing.js";
7
9
  export function createGeminiBrain(opts) {
8
10
  const model = opts.model ?? "gemini-2.5-flash";
9
11
  return {
@@ -59,55 +61,86 @@ export function createGeminiBrain(opts) {
59
61
  tools: tools,
60
62
  }
61
63
  : { systemInstruction: input.system_context, contents };
62
- const result = await withRetry(() => geminiModel.generateContent(request, input.signal ? { signal: input.signal } : {}), input.signal ? { signal: input.signal } : {});
63
- const response = result.response;
64
+ // ── The one branch streaming adds ──────────────────────────────────
65
+ //
66
+ // No `onChunk`, no stream: the call below is the call this brain has
67
+ // always made, and every line after this block is untouched. Both paths
68
+ // land in `GeminiResponse` — the fields this brain reads — so the payload
69
+ // is built once, from one shape.
70
+ 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(() => geminiModel.generateContent(request, input.signal ? { signal: input.signal } : {}), input.signal ? { signal: input.signal } : {})).response;
64
73
  const parts = response?.candidates?.[0]?.content?.parts ?? [];
65
- // Check for function call
66
- const fnCallPart = parts.find((p) => "functionCall" in p && p.functionCall);
67
- if (fnCallPart &&
68
- "functionCall" in fnCallPart &&
69
- fnCallPart.functionCall) {
70
- const fc = fnCallPart.functionCall;
71
- const orchestration = {
72
- action: "use_tool",
73
- confidence: 0.9,
74
- tool_name: fc.name,
75
- tool_params: fc.args,
76
- };
77
- return { orchestration };
74
+ // Token usage + cost — computed ONCE and returned on BOTH the tool-call and text
75
+ // paths. Most iterations of an agentic run are tool calls, so omitting usage/cost
76
+ // there (as before) left the loop's cost cap seeing $0 and never firing. Price via
77
+ // the shared table (not hardcoded Flash rates) so gemini-2.5-pro bills correctly.
78
+ const usageMeta = response?.usageMetadata;
79
+ const inputTokens = usageMeta?.promptTokenCount;
80
+ const outputTokens = usageMeta?.candidatesTokenCount;
81
+ const usage = inputTokens !== undefined || outputTokens !== undefined
82
+ ? {
83
+ ...(inputTokens !== undefined ? { inputTokens } : {}),
84
+ ...(outputTokens !== undefined ? { outputTokens } : {}),
85
+ }
86
+ : undefined;
87
+ const costUsd = computeCostUsd(model, inputTokens, outputTokens);
88
+ // Function calls — EVERY `functionCall` part, in emitted order. Gemini
89
+ // returns parallel calls as several parts of one candidate; `.find()`
90
+ // kept the first and paid for the rest with extra round-trips.
91
+ const calls = [];
92
+ for (const part of parts) {
93
+ if (!("functionCall" in part) || !part.functionCall)
94
+ continue;
95
+ const fc = part.functionCall;
96
+ calls.push({
97
+ name: fc.name,
98
+ params: (fc.args ?? {}),
99
+ });
100
+ }
101
+ const orchestration = useToolOrchestration(calls, 0.9);
102
+ if (orchestration) {
103
+ return { orchestration, costUsd, ...(usage ? { usage } : {}) };
78
104
  }
79
105
  // Text response
80
106
  const textPart = parts.find((p) => "text" in p && typeof p.text === "string");
81
107
  const text = textPart && "text" in textPart ? textPart.text : "";
82
- // Token usage
83
- const usage = response?.usageMetadata;
84
- const inputTokens = usage?.promptTokenCount;
85
- const outputTokens = usage?.candidatesTokenCount;
86
108
  return {
87
109
  generation: { response_text: text },
88
110
  orchestration: { action: "respond", confidence: 0.95 },
89
- // Gemini 2.5 Flash pricing: $0.075/1M input, $0.30/1M output
90
- costUsd: usage
91
- ? (usage.promptTokenCount ?? 0) * 0.000_000_075 +
92
- (usage.candidatesTokenCount ?? 0) * 0.0000003
93
- : 0,
94
- ...(inputTokens !== undefined || outputTokens !== undefined
95
- ? {
96
- usage: {
97
- ...(inputTokens !== undefined ? { inputTokens } : {}),
98
- ...(outputTokens !== undefined ? { outputTokens } : {}),
99
- },
100
- }
101
- : {}),
111
+ costUsd,
112
+ ...(usage ? { usage } : {}),
102
113
  };
103
114
  },
104
115
  };
105
116
  }
117
+ /**
118
+ * `generateContentStream` read to the end, folded back into the response shape
119
+ * the caller above expects.
120
+ *
121
+ * The SDK hands back BOTH an iterable `stream` and an already-aggregated
122
+ * `response` promise — and this deliberately ignores the second. The fold that
123
+ * runs in production has to be the fold the tests exercise; taking the SDK's
124
+ * aggregate instead would leave `accumulateGemini` proven and unused, which is
125
+ * the same as unproven.
126
+ *
127
+ * It takes the call as a thunk rather than the model, so `withRetry` re-opens a
128
+ * fresh stream on a retry instead of re-reading a drained one.
129
+ */
130
+ async function streamContent(open, sink, signal) {
131
+ const result = await open();
132
+ const items = await consumeStream(result.stream, geminiDelta, sink, signal);
133
+ return accumulateGemini(items);
134
+ }
106
135
  function buildGeminiContents(input) {
107
136
  const contents = [];
137
+ // Derived tool calls / tool results arrive TAGGED rather than flattened into
138
+ // anonymous `user` turns — see the wire contract in tool-context.ts. Gemini is
139
+ // the one provider that renames the role: `assistant` is spelled `model`.
108
140
  for (const msg of input.history) {
109
- const role = msg.role === "assistant" ? "model" : "user";
110
- contents.push({ role, parts: [{ text: msg.content }] });
141
+ const wire = toWireMessage(msg);
142
+ const role = wire.role === "assistant" ? "model" : "user";
143
+ contents.push({ role, parts: [{ text: wire.content }] });
111
144
  }
112
145
  // Current user message — shared fold + finalize nudge (parity with all brains).
113
146
  const userText = foldToolResults(input.raw, input.tool_results);
@@ -3,16 +3,27 @@
3
3
  * No peer dependencies — plain HTTP fetch.
4
4
  */
5
5
  import { withRetry } from "./retry.js";
6
- import { foldToolResults, toolParamsToJsonSchema } from "./tool-context.js";
6
+ import { foldToolResults, toolParamsToJsonSchema, toWireMessage, useToolOrchestration, } from "./tool-context.js";
7
+ import { accumulateOllama, consumeNdjson, } from "./streaming.js";
7
8
  export function createOllamaBrain(opts) {
8
9
  const endpoint = opts.endpoint ?? process.env["OLLAMA_ENDPOINT"] ?? "http://localhost:11434";
9
10
  const model = opts.model ?? "llama3.2";
10
11
  return {
11
12
  name: "ollama",
12
13
  async run(input) {
14
+ // Ollama is the one adapter that passed roles through verbatim, so a
15
+ // derived `role: "tool"` message went out as a bare `tool` turn with no
16
+ // native pairing — which no /api/chat backend can honour. The two DERIVED
17
+ // tool roles are tagged (the same text the other three send); every other
18
+ // role still passes through untouched.
13
19
  const messages = [
14
20
  { role: "system", content: input.system_context },
15
- ...input.history.map((m) => ({ role: m.role, content: m.content })),
21
+ ...input.history.map((m) => {
22
+ const wire = toWireMessage(m);
23
+ return wire.tagged
24
+ ? { role: wire.role, content: wire.content }
25
+ : { role: m.role, content: m.content };
26
+ }),
16
27
  // Fold prior tool results into the turn (parity with the cloud brains).
17
28
  { role: "user", content: foldToolResults(input.raw, input.tool_results) },
18
29
  ];
@@ -30,37 +41,54 @@ export function createOllamaBrain(opts) {
30
41
  },
31
42
  }))
32
43
  : undefined;
33
- const response = await withRetry(() => fetch(`${endpoint}/api/chat`, {
44
+ // ── The one branch streaming adds ──────────────────────────────────
45
+ //
46
+ // No `onChunk`, no stream: `stream: false` and one `response.json()`, the
47
+ // way this brain has always spoken to Ollama. Both paths land in
48
+ // `OllamaChatResponse` — the fields this brain reads — so the payload is
49
+ // built once, from one shape.
50
+ const post = (stream) => fetch(`${endpoint}/api/chat`, {
34
51
  method: "POST",
35
52
  headers: { "Content-Type": "application/json" },
36
53
  body: JSON.stringify({
37
54
  model,
38
55
  messages,
39
- stream: false,
56
+ stream,
40
57
  ...(tools ? { tools } : {}),
41
58
  }),
42
59
  ...(input.signal ? { signal: input.signal } : {}),
43
- }), input.signal ? { signal: input.signal } : {});
44
- if (!response.ok) {
45
- throw new Error(`Ollama error: ${response.status} ${response.statusText}`);
60
+ });
61
+ let data;
62
+ if (input.onChunk) {
63
+ data = await withRetry(() => streamChat(post, input.onChunk, input.signal), input.signal ? { signal: input.signal } : {});
64
+ }
65
+ else {
66
+ // Verbatim: the fetch is retried, the status check and the body read
67
+ // are not — exactly where those three lines have always sat.
68
+ const response = await withRetry(() => post(false), input.signal ? { signal: input.signal } : {});
69
+ if (!response.ok) {
70
+ throw new Error(`Ollama error: ${response.status} ${response.statusText}`);
71
+ }
72
+ data = (await response.json());
46
73
  }
47
- const data = (await response.json());
48
74
  const inputTokens = data.prompt_eval_count;
49
75
  const outputTokens = data.eval_count;
50
76
  const usageBlock = inputTokens !== undefined || outputTokens !== undefined
51
77
  ? { usage: { ...(inputTokens !== undefined ? { inputTokens } : {}), ...(outputTokens !== undefined ? { outputTokens } : {}) } }
52
78
  : {};
53
- // Tool call (Ollama returns arguments already parsed as an object, unlike
54
- // OpenAI's JSON string).
55
- const call = data.message?.tool_calls?.[0]?.function;
56
- if (call?.name) {
57
- const orchestration = {
58
- action: "use_tool",
59
- confidence: 0.9,
60
- tool_name: call.name,
61
- tool_params: call.arguments ?? {},
62
- };
63
- return { orchestration, ...usageBlock };
79
+ // Tool calls — ALL of them (Ollama returns arguments already parsed as an
80
+ // object, unlike OpenAI's JSON string). The gate stays what it was: the
81
+ // FIRST entry must name a tool, or this is a text response. A local model
82
+ // that emits a nameless first entry behaves exactly as it did yesterday.
83
+ const rawCalls = data.message?.tool_calls ?? [];
84
+ const calls = rawCalls.map((c) => ({
85
+ name: c.function?.name ?? "",
86
+ params: c.function?.arguments ?? {},
87
+ }));
88
+ if (calls[0]?.name) {
89
+ const orchestration = useToolOrchestration(calls, 0.9);
90
+ if (orchestration)
91
+ return { orchestration, ...usageBlock };
64
92
  }
65
93
  const text = data.message?.content ?? "";
66
94
  return {
@@ -71,3 +99,24 @@ export function createOllamaBrain(opts) {
71
99
  },
72
100
  };
73
101
  }
102
+ /**
103
+ * The same POST with `stream: true`, whose body is NDJSON rather than one JSON
104
+ * object, folded back into the response shape the caller above expects.
105
+ *
106
+ * The status check stays where it is on the other path — before a single byte
107
+ * of body is read — so a refusing Ollama fails the same way whether or not
108
+ * anyone asked to watch. A body that never materialises is the one case this
109
+ * has to add: `response.body` is nullable in the fetch spec, and reading a
110
+ * null stream is a `TypeError` with no useful message.
111
+ */
112
+ async function streamChat(post, sink, signal) {
113
+ const response = await post(true);
114
+ if (!response.ok) {
115
+ throw new Error(`Ollama error: ${response.status} ${response.statusText}`);
116
+ }
117
+ if (!response.body) {
118
+ throw new Error("Ollama error: streaming response carried no body");
119
+ }
120
+ const lines = await consumeNdjson(response.body, sink, signal);
121
+ return accumulateOllama(lines);
122
+ }