@msm-core/mini 0.5.2 → 0.9.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.
@@ -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,8 @@
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";
7
8
  import { computeCostUsd } from "./pricing.js";
8
9
  export function createGeminiBrain(opts) {
9
10
  const model = opts.model ?? "gemini-2.5-flash";
@@ -60,8 +61,15 @@ export function createGeminiBrain(opts) {
60
61
  tools: tools,
61
62
  }
62
63
  : { systemInstruction: input.system_context, contents };
63
- const result = await withRetry(() => geminiModel.generateContent(request, input.signal ? { signal: input.signal } : {}), input.signal ? { signal: input.signal } : {});
64
- 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;
65
73
  const parts = response?.candidates?.[0]?.content?.parts ?? [];
66
74
  // Token usage + cost — computed ONCE and returned on BOTH the tool-call and text
67
75
  // paths. Most iterations of an agentic run are tool calls, so omitting usage/cost
@@ -77,18 +85,21 @@ export function createGeminiBrain(opts) {
77
85
  }
78
86
  : undefined;
79
87
  const costUsd = computeCostUsd(model, inputTokens, outputTokens);
80
- // Check for function call
81
- const fnCallPart = parts.find((p) => "functionCall" in p && p.functionCall);
82
- if (fnCallPart &&
83
- "functionCall" in fnCallPart &&
84
- fnCallPart.functionCall) {
85
- const fc = fnCallPart.functionCall;
86
- const orchestration = {
87
- action: "use_tool",
88
- confidence: 0.9,
89
- tool_name: fc.name,
90
- tool_params: fc.args,
91
- };
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) {
92
103
  return { orchestration, costUsd, ...(usage ? { usage } : {}) };
93
104
  }
94
105
  // Text response
@@ -103,11 +114,33 @@ export function createGeminiBrain(opts) {
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
+ }
@@ -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 { accumulateOpenAI, consumeStream, openAIDelta, } from "./streaming.js";
8
9
  export function createOpenAIBrain(opts) {
9
10
  const model = opts.model ?? "gpt-4o-mini";
10
11
  return {
@@ -19,12 +20,14 @@ export function createOpenAIBrain(opts) {
19
20
  // brains) so the model sees what its tool calls returned and is nudged to
20
21
  // finalize rather than re-calling forever.
21
22
  const userContent = foldToolResults(input.raw, input.tool_results);
23
+ // Derived tool calls / tool results arrive TAGGED rather than flattened
24
+ // into anonymous `user` turns — see the wire contract in tool-context.ts.
22
25
  const messages = [
23
26
  { role: "system", content: input.system_context },
24
- ...input.history.map((m) => ({
25
- role: (m.role === "assistant" ? "assistant" : "user"),
26
- content: m.content,
27
- })),
27
+ ...input.history.map((m) => {
28
+ const wire = toWireMessage(m);
29
+ return { role: wire.role, content: wire.content };
30
+ }),
28
31
  { role: "user", content: userContent },
29
32
  ];
30
33
  const tools = input.tools.length > 0
@@ -45,7 +48,16 @@ export function createOpenAIBrain(opts) {
45
48
  tool_choice: "auto",
46
49
  }
47
50
  : { model, messages: messages };
48
- const response = await withRetry(() => client.chat.completions.create(body, input.signal ? { signal: input.signal } : {}), input.signal ? { signal: input.signal } : {});
51
+ // ── The one branch streaming adds ──────────────────────────────────
52
+ //
53
+ // No `onChunk`, no stream: the call below is the call this brain has
54
+ // always made, and every line after this block is untouched — the
55
+ // streamed and non-streamed responses are the same shape by
56
+ // construction (`OpenAICompletion`), so there is one payload builder,
57
+ // not two that must be kept in step.
58
+ const response = input.onChunk
59
+ ? await withRetry(() => streamCompletion(client, body, input.onChunk, input.signal), input.signal ? { signal: input.signal } : {})
60
+ : await withRetry(() => client.chat.completions.create(body, input.signal ? { signal: input.signal } : {}), input.signal ? { signal: input.signal } : {});
49
61
  const choice = response.choices[0];
50
62
  if (!choice)
51
63
  return {};
@@ -62,24 +74,26 @@ export function createOpenAIBrain(opts) {
62
74
  costUsd,
63
75
  }
64
76
  : {};
65
- // Tool call response
77
+ // Tool calls — ALL of them, in the order the model emitted them. Taking
78
+ // only `tool_calls[0]` threw away every sibling call and bought each one
79
+ // back at the price of a whole extra model round-trip.
66
80
  if (msg.tool_calls?.length) {
67
- const call = msg.tool_calls[0];
68
- if (!call)
69
- return {};
70
- let params = {};
71
- try {
72
- params = JSON.parse(call.function
73
- ?.arguments ?? "{}");
74
- }
75
- catch { }
76
- const orchestration = {
77
- action: "use_tool",
78
- confidence: 0.9,
79
- tool_name: call.function?.name ?? "",
80
- tool_params: params,
81
- };
82
- return { orchestration, ...usageBlock };
81
+ const calls = msg.tool_calls.map((call) => {
82
+ const fn = call.function;
83
+ let params = {};
84
+ try {
85
+ params = JSON.parse(fn?.arguments ?? "{}");
86
+ }
87
+ catch {
88
+ // A call whose arguments are unparseable is still a call the model
89
+ // made: it goes through with `{}` and fails validation downstream,
90
+ // rather than silently vanishing from the step.
91
+ }
92
+ return { name: fn?.name ?? "", params };
93
+ });
94
+ const orchestration = useToolOrchestration(calls, 0.9);
95
+ if (orchestration)
96
+ return { orchestration, ...usageBlock };
83
97
  }
84
98
  const text = msg.content ?? "";
85
99
  return {
@@ -90,3 +104,23 @@ export function createOpenAIBrain(opts) {
90
104
  },
91
105
  };
92
106
  }
107
+ /**
108
+ * The same request with `stream: true`, read to the end, folded back into the
109
+ * completion shape the caller above expects.
110
+ *
111
+ * `stream_options.include_usage` is not optional politeness: without it OpenAI
112
+ * sends no `usage` on a streamed call, `computeCostUsd` would see `undefined`,
113
+ * and the loop's cost cap would silently stop counting for exactly the runs a
114
+ * user is watching. Streaming must not cost less to the accountant than it
115
+ * costs in fact.
116
+ */
117
+ async function streamCompletion(client, body, sink, signal) {
118
+ const streamBody = {
119
+ ...body,
120
+ stream: true,
121
+ stream_options: { include_usage: true },
122
+ };
123
+ const stream = await client.chat.completions.create(streamBody, signal ? { signal } : {});
124
+ const chunks = await consumeStream(stream, openAIDelta, sink, signal);
125
+ return accumulateOpenAI(chunks);
126
+ }