@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.
- package/dist/adapters/index.d.ts +4 -0
- package/dist/adapters/index.js +2 -0
- package/dist/adapters/memory-store.d.ts +38 -0
- package/dist/adapters/memory-store.js +73 -0
- package/dist/adapters/redis-memory.d.ts +13 -8
- package/dist/adapters/redis-memory.js +5 -0
- package/dist/brain/anthropic.js +50 -17
- package/dist/brain/gemini.js +50 -17
- package/dist/brain/ollama.js +68 -19
- package/dist/brain/openai.js +57 -23
- package/dist/brain/streaming.d.ts +315 -0
- package/dist/brain/streaming.js +439 -0
- package/dist/brain/tool-context.d.ts +50 -2
- package/dist/brain/tool-context.js +88 -0
- package/dist/bridge/pipeline.js +11 -8
- package/dist/core/context-builder.d.ts +11 -0
- package/dist/core/context-builder.js +11 -1
- package/dist/core/hooks.d.ts +10 -0
- package/dist/core/hooks.js +14 -0
- package/dist/core/loop.d.ts +43 -1
- package/dist/core/loop.js +749 -98
- package/dist/core/types.d.ts +261 -1
- package/dist/core/types.js +40 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +4 -0
- package/dist/tools/delegate.d.ts +134 -0
- package/dist/tools/delegate.js +223 -0
- package/package.json +11 -11
|
@@ -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
|
|
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
|
+
}
|
package/dist/bridge/pipeline.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* The pipeline is duck-typed — no direct import of @msm-core/pipeline.
|
|
16
16
|
*/
|
|
17
|
+
import { BRAIN_ACTIONS } from "../core/types.js";
|
|
17
18
|
// ─── Adapter ──────────────────────────────────────────────────
|
|
18
19
|
/**
|
|
19
20
|
* Wrap an @msm-core/pipeline as a Brain for @msm-core/mini.
|
|
@@ -57,14 +58,16 @@ export function wrapPipeline(pipeline, name = "pipeline") {
|
|
|
57
58
|
: {}),
|
|
58
59
|
});
|
|
59
60
|
const brain = result.payload.brain;
|
|
60
|
-
// Normalise action — pipeline may return
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
61
|
+
// Normalise action — a duck-typed pipeline may return any string, and
|
|
62
|
+
// mini dispatches on four. The set is built FROM `BRAIN_ACTIONS` and is
|
|
63
|
+
// no longer a second hand-written copy of it (ر٢): a copy is how the
|
|
64
|
+
// union and this list came to disagree about a fifth action that the loop
|
|
65
|
+
// never dispatched on, and how that disagreement went unnoticed.
|
|
66
|
+
//
|
|
67
|
+
// The fallback is unchanged: an unrecognised string becomes `respond`,
|
|
68
|
+
// which is also what the loop itself does with one that arrives past this
|
|
69
|
+
// point. Two layers, one rule.
|
|
70
|
+
const VALID_ACTIONS = new Set(BRAIN_ACTIONS);
|
|
68
71
|
const rawAction = brain?.action ?? "respond";
|
|
69
72
|
const action = VALID_ACTIONS.has(rawAction)
|
|
70
73
|
? rawAction
|
|
@@ -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
|
-
|
|
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
|
};
|
package/dist/core/hooks.d.ts
CHANGED
|
@@ -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;
|