@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.
@@ -0,0 +1,439 @@
1
+ /**
2
+ * Streaming — the four providers' wire formats reduced to pure functions (ب١).
3
+ *
4
+ * **Why this file exists at all.** A streamed answer and a non-streamed answer
5
+ * are the same answer delivered twice over. The only way to be sure of that is
6
+ * to be able to *prove* it, and you cannot prove anything about code that only
7
+ * runs with a socket open and a key in the environment. So every provider's
8
+ * stream is split in two here:
9
+ *
10
+ * - `…Delta(event)` — what text just arrived. Pure.
11
+ * - `accumulate…(events)` — the events folded back into the SAME object shape
12
+ * the provider's NON-streaming call returns. Pure.
13
+ *
14
+ * Feed `accumulate…` a fixed array of chunks in a test and you get an object
15
+ * that the brain's own post-call code — not a copy of it, the same lines —
16
+ * turns into a payload. That is what makes "the stream aggregates to what the
17
+ * non-stream returns" a checkable claim rather than a hope, and it is why the
18
+ * brains route BOTH paths into one `response` variable and one payload builder.
19
+ *
20
+ * **The read set is the contract.** Each `…Response` interface below is exactly
21
+ * the fields its brain reads off a provider response — no more. It is small on
22
+ * purpose: `id`, `created`, `finish_reason`, block ids and the rest are things
23
+ * a stream cannot reconstruct and no brain here consumes, so promising them
24
+ * would be a lie the compiler would happily keep.
25
+ *
26
+ * **Display, not truth (the governing limit of ب١).** Nothing in this file
27
+ * writes a log event, touches a fingerprint, or decides anything. Chunks are
28
+ * emitted for a human to look at; the payload the brain returns is the record.
29
+ *
30
+ * **Text only in v1.** Tool-call fragments are accumulated but never emitted —
31
+ * a half-arrived tool call is not something a consumer can do anything with,
32
+ * and `BrainChunk` says `{ text }` precisely so that stays true.
33
+ */
34
+ /**
35
+ * Read a provider stream to the end, emitting text as it arrives, and return
36
+ * every raw event for the accumulator.
37
+ *
38
+ * **Abort stops the consumption, and no chunk follows it.** The check sits
39
+ * before the emit, not after, so an abort that lands between two events ends
40
+ * the stream silently — the loop's `runWithTimeout` has already rejected by
41
+ * then and whatever is returned here is discarded. What must never happen is a
42
+ * chunk arriving at a consumer that has been told the call is over.
43
+ *
44
+ * Buffering every event is deliberate: one model response is small, and the
45
+ * accumulator that has to see all of them is the same function the tests feed
46
+ * by hand. A fold that ran only live would be a second implementation.
47
+ */
48
+ export async function consumeStream(source, deltaOf, sink, signal) {
49
+ const events = [];
50
+ for await (const event of source) {
51
+ if (signal?.aborted)
52
+ break;
53
+ events.push(event);
54
+ const text = deltaOf(event);
55
+ if (text)
56
+ sink({ text });
57
+ }
58
+ return events;
59
+ }
60
+ /**
61
+ * The text this chunk carries, or "" for a tool-call or usage-only chunk.
62
+ *
63
+ * **Why no fold here.** Two multiplicities are worth ruling out by name, since
64
+ * an extractor that silently reads "the first of several" is the exact bug this
65
+ * file was written to make impossible:
66
+ *
67
+ * - WITHIN a choice, `delta.content` is one string, never a list. Nothing to
68
+ * concatenate.
69
+ * - ACROSS choices, `choices` IS an array — but only when a request sets
70
+ * `n > 1`, which this brain never does. And if one ever did, several
71
+ * choices are ALTERNATIVE answers, not pieces of one: summing them would
72
+ * splice two different replies together. So `[0]` is right, and the rule
73
+ * that actually matters is that `accumulateOpenAI` reads `[0]` too — what
74
+ * the user watched is what the payload ends up holding, always.
75
+ */
76
+ export function openAIDelta(chunk) {
77
+ const content = chunk.choices?.[0]?.delta?.content;
78
+ return typeof content === "string" ? content : "";
79
+ }
80
+ /**
81
+ * Fold OpenAI chunks into the completion they describe.
82
+ *
83
+ * Tool calls arrive as fragments keyed by `index`: the name once, the arguments
84
+ * in pieces to be concatenated into the JSON string the non-streamed API hands
85
+ * over whole. Keyed by index and re-sorted rather than pushed in arrival order,
86
+ * because the model interleaves parallel calls and the ORDER of calls is the
87
+ * one thing س٤ made load-bearing.
88
+ *
89
+ * `content` stays `null` when no text delta ever arrived — that is what the
90
+ * non-streaming API returns for a pure tool-call response, and the two spellings
91
+ * must not differ.
92
+ */
93
+ export function accumulateOpenAI(chunks) {
94
+ let content = "";
95
+ let sawText = false;
96
+ let usage;
97
+ const fragments = new Map();
98
+ for (const chunk of chunks) {
99
+ if (chunk.usage)
100
+ usage = chunk.usage;
101
+ const delta = chunk.choices?.[0]?.delta;
102
+ if (!delta)
103
+ continue;
104
+ if (typeof delta.content === "string") {
105
+ content += delta.content;
106
+ sawText = true;
107
+ }
108
+ for (const call of delta.tool_calls ?? []) {
109
+ let slot = fragments.get(call.index);
110
+ if (!slot) {
111
+ slot = { name: "", args: "" };
112
+ fragments.set(call.index, slot);
113
+ }
114
+ if (call.function?.name)
115
+ slot.name += call.function.name;
116
+ if (call.function?.arguments)
117
+ slot.args += call.function.arguments;
118
+ }
119
+ }
120
+ const toolCalls = [...fragments.entries()]
121
+ .sort(([a], [b]) => a - b)
122
+ .map(([, slot]) => ({
123
+ function: { name: slot.name, arguments: slot.args },
124
+ }));
125
+ return {
126
+ choices: [
127
+ {
128
+ message: {
129
+ content: sawText ? content : null,
130
+ ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
131
+ },
132
+ },
133
+ ],
134
+ ...(usage ? { usage } : {}),
135
+ };
136
+ }
137
+ function blockDelta(delta) {
138
+ return delta && typeof delta === "object" ? delta : {};
139
+ }
140
+ /**
141
+ * The text this event carries, or "" for anything else (thinking included).
142
+ *
143
+ * **Why no fold here.** One `content_block_delta` carries exactly one `delta`
144
+ * for exactly one `index` — the event type is `{ delta, index, type }`, not a
145
+ * list — so there is never more than one piece of text in an event and nothing
146
+ * to concatenate. Multiplicity in Claude's stream lives BETWEEN events, and
147
+ * that is `accumulateAnthropic`'s job, keyed by `index`.
148
+ */
149
+ export function anthropicDelta(event) {
150
+ if (event.type !== "content_block_delta")
151
+ return "";
152
+ const delta = blockDelta(event.delta);
153
+ if (delta.type !== "text_delta")
154
+ return "";
155
+ return typeof delta.text === "string" ? delta.text : "";
156
+ }
157
+ /**
158
+ * Fold Messages-API events into the message they describe.
159
+ *
160
+ * Blocks are addressed by `index` and rebuilt in index order: `text_delta`
161
+ * pieces concatenate into the block's text, `input_json_delta` pieces
162
+ * concatenate into the JSON that non-streamed responses deliver already parsed
163
+ * as `input`.
164
+ *
165
+ * Usage arrives in two halves — `input_tokens` at `message_start`, the final
166
+ * `output_tokens` at `message_delta` — so both are taken where they appear,
167
+ * with the later `message_delta` count winning.
168
+ *
169
+ * A tool block that never receives a delta ends as `input: {}`, which is what
170
+ * a zero-argument tool call looks like unstreamed.
171
+ */
172
+ export function accumulateAnthropic(events) {
173
+ const blocks = new Map();
174
+ let inputTokens = 0;
175
+ let outputTokens = 0;
176
+ for (const event of events) {
177
+ if (event.type === "message_start") {
178
+ const usage = event.message?.usage;
179
+ if (typeof usage?.input_tokens === "number")
180
+ inputTokens = usage.input_tokens;
181
+ if (typeof usage?.output_tokens === "number")
182
+ outputTokens = usage.output_tokens;
183
+ continue;
184
+ }
185
+ if (event.type === "message_delta") {
186
+ if (typeof event.usage?.output_tokens === "number") {
187
+ outputTokens = event.usage.output_tokens;
188
+ }
189
+ continue;
190
+ }
191
+ if (event.index === undefined)
192
+ continue;
193
+ if (event.type === "content_block_start") {
194
+ blocks.set(event.index, {
195
+ type: event.content_block?.type ?? "text",
196
+ text: "",
197
+ name: event.content_block?.name ?? "",
198
+ json: "",
199
+ });
200
+ continue;
201
+ }
202
+ if (event.type === "content_block_delta") {
203
+ const slot = blocks.get(event.index);
204
+ if (!slot)
205
+ continue;
206
+ const delta = blockDelta(event.delta);
207
+ if (delta.type === "text_delta" && typeof delta.text === "string") {
208
+ slot.text += delta.text;
209
+ }
210
+ else if (delta.type === "input_json_delta" &&
211
+ typeof delta.partial_json === "string") {
212
+ slot.json += delta.partial_json;
213
+ }
214
+ }
215
+ }
216
+ const content = [...blocks.entries()]
217
+ .sort(([a], [b]) => a - b)
218
+ .map(([, slot]) => slot.type === "tool_use"
219
+ ? { type: "tool_use", name: slot.name, input: parseJsonObject(slot.json) }
220
+ : { type: slot.type, text: slot.text });
221
+ return { content, usage: { input_tokens: inputTokens, output_tokens: outputTokens } };
222
+ }
223
+ /**
224
+ * The text this stream item carries, across ALL of its text parts.
225
+ *
226
+ * **This is the one extractor of the four that must fold**, and the fold is not
227
+ * defensive: Gemini's stream element carries an ARRAY of parts, and it really
228
+ * does put several — a sentence, a function call, a trailing sentence — into
229
+ * one candidate. Return `parts[0].text` and the watcher sees the first fragment
230
+ * of a paragraph while the payload holds all of it, which is the precise shape
231
+ * of "the stream lied to the user's face".
232
+ */
233
+ export function geminiDelta(item) {
234
+ const parts = item.candidates?.[0]?.content?.parts ?? [];
235
+ let text = "";
236
+ for (const part of parts) {
237
+ if (typeof part.text === "string")
238
+ text += part.text;
239
+ }
240
+ return text;
241
+ }
242
+ /**
243
+ * Fold Gemini stream items into the response they describe.
244
+ *
245
+ * **The text parts MERGE into one.** This is the whole reason this function is
246
+ * not a flat concatenation of `parts` arrays: the brain finds its answer with
247
+ * `parts.find(p => "text" in p)`, so a response left as fifty little text parts
248
+ * would return the first syllable of the answer and drop the rest. One part in,
249
+ * one part out — the same shape `generateContent` returns.
250
+ *
251
+ * The merged text keeps the POSITION of the first text part it saw, so a
252
+ * response that opened with a function call still reads in emitted order.
253
+ * (Order between text and calls does not reach the payload — the brain collects
254
+ * calls by filter and text by find — but a shape that reorders parts for no
255
+ * reason is a shape that will eventually be believed.)
256
+ *
257
+ * Usage is cumulative in Gemini's stream: the last item that carries it wins.
258
+ */
259
+ export function accumulateGemini(items) {
260
+ const parts = [];
261
+ let textSlot = -1;
262
+ let usage;
263
+ for (const item of items) {
264
+ if (item.usageMetadata)
265
+ usage = item.usageMetadata;
266
+ for (const part of item.candidates?.[0]?.content?.parts ?? []) {
267
+ if (typeof part.text === "string") {
268
+ if (textSlot === -1) {
269
+ textSlot = parts.length;
270
+ parts.push({ text: part.text });
271
+ }
272
+ else {
273
+ parts[textSlot] = { text: (parts[textSlot]?.text ?? "") + part.text };
274
+ }
275
+ }
276
+ else if (part.functionCall) {
277
+ parts.push({ functionCall: part.functionCall });
278
+ }
279
+ }
280
+ }
281
+ return {
282
+ candidates: [{ content: { parts } }],
283
+ ...(usage ? { usageMetadata: usage } : {}),
284
+ };
285
+ }
286
+ /**
287
+ * The text this line carries.
288
+ *
289
+ * **Why no fold here.** One NDJSON line carries one `message`, and that message
290
+ * carries one `content` string — neither is a list, so there is nothing to
291
+ * concatenate within a line. Ollama's multiplicity is one token per LINE, which
292
+ * is `accumulateOllama`'s job.
293
+ */
294
+ export function ollamaDelta(line) {
295
+ return typeof line.message?.content === "string" ? line.message.content : "";
296
+ }
297
+ /**
298
+ * Split whatever has arrived into whole NDJSON lines plus the leftover.
299
+ *
300
+ * Pure, and separate from the reader, because the bug this prevents is invisible
301
+ * in a happy-path test: a chunk boundary can fall anywhere, including the middle
302
+ * of a JSON object, and a splitter that forgets `rest` silently drops the token
303
+ * that straddled the seam. Blank lines are dropped; the trailing fragment is
304
+ * always handed back to be prefixed onto the next read.
305
+ */
306
+ export function splitNdjsonFrame(buffer) {
307
+ const parts = buffer.split("\n");
308
+ const rest = parts.pop() ?? "";
309
+ return {
310
+ lines: parts.map((line) => line.trim()).filter((line) => line.length > 0),
311
+ rest,
312
+ };
313
+ }
314
+ /**
315
+ * Fold NDJSON lines into the single response `stream: false` would return.
316
+ *
317
+ * Ollama streams content token by token, but hands over tool calls whole (with
318
+ * `arguments` already an object, not a JSON string) — so calls are collected,
319
+ * never concatenated. Counts arrive on the final line; last one wins.
320
+ */
321
+ export function accumulateOllama(lines) {
322
+ let content = "";
323
+ const toolCalls = [];
324
+ let promptEvalCount;
325
+ let evalCount;
326
+ for (const line of lines) {
327
+ if (typeof line.message?.content === "string")
328
+ content += line.message.content;
329
+ for (const call of line.message?.tool_calls ?? [])
330
+ toolCalls.push(call);
331
+ if (typeof line.prompt_eval_count === "number") {
332
+ promptEvalCount = line.prompt_eval_count;
333
+ }
334
+ if (typeof line.eval_count === "number")
335
+ evalCount = line.eval_count;
336
+ }
337
+ return {
338
+ message: {
339
+ content,
340
+ ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
341
+ },
342
+ ...(promptEvalCount !== undefined ? { prompt_eval_count: promptEvalCount } : {}),
343
+ ...(evalCount !== undefined ? { eval_count: evalCount } : {}),
344
+ };
345
+ }
346
+ /**
347
+ * Read an NDJSON body to the end, emitting text as it arrives.
348
+ *
349
+ * The Ollama brain owns no SDK, so this is the equivalent of `consumeStream`
350
+ * for a raw `fetch` body: decode, split on newlines, parse, emit, keep the
351
+ * remainder. A line that will not parse is skipped rather than thrown — a
352
+ * local server that emits a stray keep-alive should not fail a run — and the
353
+ * abort check sits before the emit for the same reason it does upstream.
354
+ */
355
+ export async function consumeNdjson(body, sink, signal) {
356
+ const decoder = new TextDecoder();
357
+ const lines = [];
358
+ let buffer = "";
359
+ const take = (raw) => {
360
+ let parsed;
361
+ try {
362
+ parsed = JSON.parse(raw);
363
+ }
364
+ catch {
365
+ return;
366
+ }
367
+ lines.push(parsed);
368
+ const text = ollamaDelta(parsed);
369
+ if (text)
370
+ sink({ text });
371
+ };
372
+ for await (const bytes of toAsyncIterable(body)) {
373
+ if (signal?.aborted)
374
+ break;
375
+ buffer += decoder.decode(bytes, { stream: true });
376
+ const frame = splitNdjsonFrame(buffer);
377
+ buffer = frame.rest;
378
+ for (const line of frame.lines) {
379
+ if (signal?.aborted)
380
+ break;
381
+ take(line);
382
+ }
383
+ }
384
+ const tail = buffer.trim();
385
+ if (tail && !signal?.aborted)
386
+ take(tail);
387
+ return lines;
388
+ }
389
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
390
+ /**
391
+ * A `ReadableStream` is async-iterable in Node 18+ and in undici, but the type
392
+ * only says so on some lib targets. One narrowing here keeps the branch out of
393
+ * the reader above.
394
+ */
395
+ function toAsyncIterable(body) {
396
+ if (Symbol.asyncIterator in body) {
397
+ return body;
398
+ }
399
+ const reader = body.getReader();
400
+ return {
401
+ async *[Symbol.asyncIterator]() {
402
+ try {
403
+ for (;;) {
404
+ const { done, value } = await reader.read();
405
+ if (done)
406
+ return;
407
+ if (value)
408
+ yield value;
409
+ }
410
+ }
411
+ finally {
412
+ reader.releaseLock();
413
+ }
414
+ },
415
+ };
416
+ }
417
+ /**
418
+ * Concatenated `input_json_delta` fragments back into the object the
419
+ * non-streaming API returns.
420
+ *
421
+ * Empty means "no arguments" (`{}`), which is a real tool call. Unparseable
422
+ * means the model produced broken JSON — the same condition the OpenAI brain
423
+ * already meets with `{}` and a downstream validation failure, so it is met the
424
+ * same way here: the call survives to be rejected on its merits instead of
425
+ * vanishing from the step.
426
+ */
427
+ function parseJsonObject(json) {
428
+ if (!json)
429
+ return {};
430
+ try {
431
+ const parsed = JSON.parse(json);
432
+ return parsed && typeof parsed === "object"
433
+ ? parsed
434
+ : {};
435
+ }
436
+ catch {
437
+ return {};
438
+ }
439
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Shared brain helpers — the two things EVERY provider must do identically so
2
+ * Shared brain helpers — the things EVERY provider must do identically so
3
3
  * tool-using agents behave the same across OpenAI / Anthropic / Gemini / Ollama.
4
4
  * Previously each brain hand-rolled these (or skipped them), which is why the
5
5
  * Anthropic and Ollama paths regressed (audit H1/H2):
@@ -13,8 +13,17 @@
13
13
  * 2. toolParamsToJsonSchema() — map a tool's ToolParameter record to a JSON
14
14
  * Schema object INCLUDING `enum`. The Anthropic brain dropped `enum`, so an
15
15
  * enum-constrained parameter was silently unconstrained there.
16
+ *
17
+ * 3. useToolOrchestration() — build the `use_tool` payload from the N calls a
18
+ * model emitted. One function, not four, because the covenant it carries
19
+ * ("`tool_name`/`tool_params` are ALWAYS the first call") is exactly the kind
20
+ * of rule that survives in three copies and quietly dies in the fourth.
21
+ *
22
+ * 4. toWireMessage() — the declared representation of the two DERIVED tool
23
+ * roles. See the block above it: this is a wire contract, not a formatting
24
+ * preference.
16
25
  */
17
- import type { ToolParameter, ToolResult } from "../core/types.js";
26
+ import type { BrainOrchestration, BrainToolCall, Message, ToolParameter, ToolResult } from "../core/types.js";
18
27
  /**
19
28
  * Fold prior-iteration tool results into the user turn. Returns `raw` unchanged
20
29
  * when there are none, so it is safe to call on the first iteration.
@@ -38,3 +47,42 @@ export interface JsonSchemaObject {
38
47
  * from this shape; the property/required/enum semantics stay identical.
39
48
  */
40
49
  export declare function toolParamsToJsonSchema(parameters: Record<string, ToolParameter>): JsonSchemaObject;
50
+ /**
51
+ * Build the `use_tool` orchestration for the N calls a model emitted in ONE
52
+ * response.
53
+ *
54
+ * `tool_name` / `tool_params` are filled from `calls[0]` unconditionally. That
55
+ * is the whole backward-compatibility contract in one line, and it lives here —
56
+ * in one function the four brains call — rather than as four copies of the same
57
+ * two assignments, because four copies is how three stay right and one drifts
58
+ * (paid for once already: Anthropic dropped `enum`, Ollama dropped `tools`).
59
+ *
60
+ * Returns `undefined` for an empty list, so a provider response with no usable
61
+ * call falls through to its text path exactly as before.
62
+ */
63
+ export declare function useToolOrchestration(calls: readonly BrainToolCall[], confidence: number): BrainOrchestration | undefined;
64
+ /** Marks a derived tool CALL. Part of the wire contract — changing it is a change of format. */
65
+ export declare const TOOL_CALL_TAG = "tool_call";
66
+ /** Marks a derived tool RESULT. Part of the wire contract — changing it is a change of format. */
67
+ export declare const TOOL_RESULT_TAG = "tool_result";
68
+ export interface WireMessage {
69
+ /**
70
+ * The role handed to the provider. Only two, because only two are legal
71
+ * everywhere: Gemini renames `assistant` to `model`, and no provider accepts
72
+ * a bare `tool` turn without its native pairing.
73
+ */
74
+ role: "user" | "assistant";
75
+ content: string;
76
+ /**
77
+ * True when this message is a DERIVED tool role and its content carries a tag.
78
+ * Adapters that pass ordinary roles through untouched (Ollama) branch on this
79
+ * so nothing but the two tool roles changes shape.
80
+ */
81
+ tagged: boolean;
82
+ }
83
+ /**
84
+ * Render one history message for the wire. Ordinary `user`/`assistant` messages
85
+ * come back exactly as they went in (same role mapping every brain already
86
+ * applied); the two derived tool roles come back tagged.
87
+ */
88
+ export declare function toWireMessage(m: Message): WireMessage;
@@ -35,3 +35,91 @@ export function toolParamsToJsonSchema(parameters) {
35
35
  .map(([k]) => k),
36
36
  };
37
37
  }
38
+ /**
39
+ * Build the `use_tool` orchestration for the N calls a model emitted in ONE
40
+ * response.
41
+ *
42
+ * `tool_name` / `tool_params` are filled from `calls[0]` unconditionally. That
43
+ * is the whole backward-compatibility contract in one line, and it lives here —
44
+ * in one function the four brains call — rather than as four copies of the same
45
+ * two assignments, because four copies is how three stay right and one drifts
46
+ * (paid for once already: Anthropic dropped `enum`, Ollama dropped `tools`).
47
+ *
48
+ * Returns `undefined` for an empty list, so a provider response with no usable
49
+ * call falls through to its text path exactly as before.
50
+ */
51
+ export function useToolOrchestration(calls, confidence) {
52
+ const first = calls[0];
53
+ if (!first)
54
+ return undefined;
55
+ return {
56
+ action: "use_tool",
57
+ confidence,
58
+ tool_name: first.name,
59
+ tool_params: first.params,
60
+ tool_calls: calls.map((c) => ({ name: c.name, params: c.params })),
61
+ };
62
+ }
63
+ // ─── The derived tool roles on the wire (س٤ §7) ──────────────────────────────
64
+ //
65
+ // `deriveMessages` projects a session log into a conversation that contains two
66
+ // roles the history array never held: an `assistant` message that IS a tool call
67
+ // (it carries `toolName`/`toolCallId`), and a `tool` message that is its result.
68
+ //
69
+ // Every provider adapter used to flatten both into a bare `user` message. That
70
+ // makes the model read its own tool call, and the tool's answer, as things the
71
+ // USER said — a false transcript, and the falser the more tools a step uses.
72
+ //
73
+ // The minimum honest fix, and the one declared here, is a **deterministic
74
+ // textual tag**: the two derived roles keep provider-legal roles but arrive
75
+ // visibly marked, so the model can tell a tool round-trip from human speech.
76
+ //
77
+ // a tool call → `[tool_call:<callId>] <name>({…})` role: assistant
78
+ // a tool result → `[tool_result:<callId>] <name> <json>` role: user
79
+ //
80
+ // Identical text across all four providers; only the role name is translated
81
+ // (Gemini says `model` where the others say `assistant`). No clock, no
82
+ // randomness, no provider branching in the content — the same message always
83
+ // renders to the same bytes, which is what makes it replayable.
84
+ //
85
+ // `:<callId>` is omitted when the message carries none, so a legacy `role:
86
+ // "tool"` entry from some other store still renders deterministically.
87
+ //
88
+ // **What this is NOT**: the provider-native representation (Anthropic's
89
+ // `tool_use`/`tool_result` blocks, OpenAI's `role: "tool"` + `tool_call_id`,
90
+ // Gemini's `functionCall`/`functionResponse` parts). That is a separate,
91
+ // larger contract — it changes what the provider validates, not just what the
92
+ // model reads — and it is deliberately NOT done here.
93
+ /** Marks a derived tool CALL. Part of the wire contract — changing it is a change of format. */
94
+ export const TOOL_CALL_TAG = "tool_call";
95
+ /** Marks a derived tool RESULT. Part of the wire contract — changing it is a change of format. */
96
+ export const TOOL_RESULT_TAG = "tool_result";
97
+ /**
98
+ * Render one history message for the wire. Ordinary `user`/`assistant` messages
99
+ * come back exactly as they went in (same role mapping every brain already
100
+ * applied); the two derived tool roles come back tagged.
101
+ */
102
+ export function toWireMessage(m) {
103
+ const id = m.toolCallId ? `:${m.toolCallId}` : "";
104
+ const name = m.toolName ?? "unknown";
105
+ if (m.role === "tool") {
106
+ return {
107
+ role: "user",
108
+ content: `[${TOOL_RESULT_TAG}${id}] ${name} ${m.content}`,
109
+ tagged: true,
110
+ };
111
+ }
112
+ if (m.role === "assistant" &&
113
+ (m.toolCallId !== undefined || m.toolName !== undefined)) {
114
+ return {
115
+ role: "assistant",
116
+ content: `[${TOOL_CALL_TAG}${id}] ${m.content}`,
117
+ tagged: true,
118
+ };
119
+ }
120
+ return {
121
+ role: m.role === "assistant" ? "assistant" : "user",
122
+ content: m.content,
123
+ tagged: false,
124
+ };
125
+ }
@@ -17,6 +17,17 @@ export interface ContextBudget {
17
17
  /** Max history messages to include (default: 20) */
18
18
  maxHistoryMessages: number;
19
19
  }
20
+ /**
21
+ * The budget `buildContext` applies when a caller does not pass one — which is
22
+ * every call the loop makes.
23
+ *
24
+ * Exported because the session-log invariant has to reproduce the *exact* trim
25
+ * chain the request went through in order to compare what was sent against what
26
+ * the log derives. A private copy of `20` in `loop.ts` would be a second source
27
+ * of truth that drifts the day this one is tuned, and the invariant would then
28
+ * fail on a difference that is not a difference.
29
+ */
30
+ export declare const DEFAULT_BUDGET: ContextBudget;
20
31
  /**
21
32
  * Build the full system context string for the brain.
22
33
  * Enforces token budget by truncating memories from the bottom.
@@ -10,7 +10,17 @@
10
10
  * Token budget is enforced by dropping lowest-priority memories first.
11
11
  * The agent never queries external stores — context arrives pre-assembled.
12
12
  */
13
- const DEFAULT_BUDGET = {
13
+ /**
14
+ * The budget `buildContext` applies when a caller does not pass one — which is
15
+ * every call the loop makes.
16
+ *
17
+ * Exported because the session-log invariant has to reproduce the *exact* trim
18
+ * chain the request went through in order to compare what was sent against what
19
+ * the log derives. A private copy of `20` in `loop.ts` would be a second source
20
+ * of truth that drifts the day this one is tuned, and the invariant would then
21
+ * fail on a difference that is not a difference.
22
+ */
23
+ export const DEFAULT_BUDGET = {
14
24
  maxTokens: 32_000,
15
25
  maxHistoryMessages: 20,
16
26
  };
@@ -9,4 +9,14 @@ export declare function fireIteration(hooks: AgentHooks | undefined, state: RunS
9
9
  export declare function fireSectionComplete(hooks: AgentHooks | undefined, sessionId: string, sectionName: string, sectionIndex: number, totalSections: number): void;
10
10
  export declare function fireToolCall(hooks: AgentHooks | undefined, sessionId: string, result: ToolResult, iteration: number, cached: boolean, durationMs: number): void;
11
11
  export declare function fireGuard(hooks: AgentHooks | undefined, sessionId: string, signal: GuardSignal, iteration: number): void;
12
+ /**
13
+ * One streamed piece of model text (ب١).
14
+ *
15
+ * Same shape as its siblings, and the same contract: `safely` swallows a
16
+ * throwing hook. That matters more here than anywhere else — this one fires
17
+ * dozens of times per step, in the middle of reading a provider stream, and a
18
+ * consumer whose SSE socket closed mid-answer must not take the run down with
19
+ * it. A failed display is a failed display, not a failed run.
20
+ */
21
+ export declare function fireChunk(hooks: AgentHooks | undefined, sessionId: string, iteration: number, text: string): void;
12
22
  export declare function fireFatalError(hooks: AgentHooks | undefined, error: Error, sessionId: string): void;
@@ -53,6 +53,20 @@ export function fireGuard(hooks, sessionId, signal, iteration) {
53
53
  return;
54
54
  safely(() => hooks.onGuard({ sessionId, signal, iteration }));
55
55
  }
56
+ /**
57
+ * One streamed piece of model text (ب١).
58
+ *
59
+ * Same shape as its siblings, and the same contract: `safely` swallows a
60
+ * throwing hook. That matters more here than anywhere else — this one fires
61
+ * dozens of times per step, in the middle of reading a provider stream, and a
62
+ * consumer whose SSE socket closed mid-answer must not take the run down with
63
+ * it. A failed display is a failed display, not a failed run.
64
+ */
65
+ export function fireChunk(hooks, sessionId, iteration, text) {
66
+ if (!hooks?.onChunk)
67
+ return;
68
+ safely(() => hooks.onChunk({ sessionId, iteration, text }));
69
+ }
56
70
  export function fireFatalError(hooks, error, sessionId) {
57
71
  if (!hooks?.onFatalError)
58
72
  return;