@msm-core/mini 0.5.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/core/loop.js CHANGED
@@ -8,10 +8,12 @@
8
8
  * budget exhausted → force_respond → done
9
9
  */
10
10
  import { randomBytes } from "crypto";
11
+ import { compactionAnchor, compactionBoundary, deriveMessages, } from "@msm-core/session";
12
+ import { toWireMessage } from "../brain/tool-context.js";
11
13
  import { resolveGuards, checkGuards, hasHardBlock, hardAction, } from "./guards.js";
12
14
  import { runGates } from "./gates.js";
13
- import { buildContext } from "./context-builder.js";
14
- import { fireIteration, fireToolCall, fireGuard, fireFatalError, fireSectionComplete, } from "./hooks.js";
15
+ import { buildContext, DEFAULT_BUDGET } from "./context-builder.js";
16
+ import { fireChunk, fireIteration, fireToolCall, fireGuard, fireFatalError, fireSectionComplete, } from "./hooks.js";
15
17
  import { RedisMemory, connectRedis } from "../adapters/redis-memory.js";
16
18
  import { RedisControlBus } from "../adapters/redis-control.js";
17
19
  import { RedisDistributedLock } from "../adapters/redis-lock.js";
@@ -20,6 +22,289 @@ import { parseDefinition } from "../definition/parser.js";
20
22
  function makeTaskId() {
21
23
  return randomBytes(8).toString("hex");
22
24
  }
25
+ /**
26
+ * How many past tool results ride along on a brain call. One constant, because
27
+ * the loop applies it and the invariant has to reproduce it — two copies of `5`
28
+ * would drift and the invariant would then fail on a difference that is not a
29
+ * difference.
30
+ */
31
+ const TOOL_RESULT_WINDOW = 5;
32
+ /**
33
+ * The budget the derived history will ACTUALLY be trimmed to — the loop's own
34
+ * `historyLimit` (derived from `maxIterations`) reconciled with the context
35
+ * builder's `maxHistoryMessages`, because the request passes through both cuts.
36
+ *
37
+ * A compactor is told this number and not `DEFAULT_BUDGET`, because the number
38
+ * that matters to it is the one that decides what gets dropped. Being told 20
39
+ * while the real cap is 8 would have it compact eleven messages too late.
40
+ */
41
+ function effectiveBudget(historyLimit) {
42
+ return {
43
+ maxTokens: DEFAULT_BUDGET.maxTokens,
44
+ maxHistoryMessages: Math.min(historyLimit, DEFAULT_BUDGET.maxHistoryMessages),
45
+ };
46
+ }
47
+ // ═══ One step, N tool calls (س٤) ══════════════════════════════════════════════
48
+ /** `{}` for anything that is not an object — a call still runs and still fails validation. */
49
+ function asParams(value) {
50
+ return value !== null && typeof value === "object" && !Array.isArray(value)
51
+ ? value
52
+ : {};
53
+ }
54
+ /**
55
+ * The calls of ONE step, normalized — the single reading of a model response
56
+ * that both the log and the dispatcher use.
57
+ *
58
+ * Two shapes arrive here and exactly one leaves:
59
+ *
60
+ * - `tool_calls` present → every call, in the model's order.
61
+ * - only `tool_name` → one call. Every brain written before س٤, and
62
+ * every scripted brain in the suite, lands here and
63
+ * takes the path it always took.
64
+ *
65
+ * It is pure and it is called TWICE per step — once to write
66
+ * `model_response.toolCalls`, once to dispatch — because the alternative is
67
+ * threading a list between two places that must agree on ordinals, and a
68
+ * `callId` that disagrees with the call it names is a log that cannot be
69
+ * replayed. One function, one answer, no drift.
70
+ *
71
+ * Entries with a non-string `name` are dropped rather than carried: they are
72
+ * not callable by any path, and keeping them would shift the ordinals of the
73
+ * calls that ARE callable. An EMPTY name survives — that is a call the model
74
+ * genuinely made to a tool that does not exist, and it is recorded and answered
75
+ * as such.
76
+ */
77
+ function stepToolCalls(orch) {
78
+ const declared = orch.tool_calls;
79
+ if (Array.isArray(declared) && declared.length > 0) {
80
+ return declared
81
+ .filter((c) => c !== null && typeof c === "object" && typeof c.name === "string")
82
+ .map((c) => ({ name: c.name, params: asParams(c.params) }));
83
+ }
84
+ return orch.tool_name
85
+ ? [{ name: orch.tool_name, params: asParams(orch.tool_params) }]
86
+ : [];
87
+ }
88
+ /**
89
+ * The loop's writer for ONE turn: it mints the identifiers, keeps the sequence,
90
+ * and appends.
91
+ *
92
+ * Identifiers are minted here and not in `@msm-core/session` on purpose — that
93
+ * package is pure. They are *structured* rather than opaque: `t{seq}` for the
94
+ * turn, then `turn.sN` per step and `turn.sN.cM` per call. Unique within the
95
+ * session by construction, and readable in an audit trail, where `t41.s2.c1`
96
+ * says what it is and a random hex string would not.
97
+ *
98
+ * ── Why the turn id is DERIVED and no longer random (ض١ §6, س٥'s lesson) ────
99
+ *
100
+ * It used to be `t${randomBytes(8)}`. It is now `t${head + 1}` — the `seq` this
101
+ * turn's first event will occupy. A random id per turn kills every comparison
102
+ * that crosses a run: a tape fingerprint, a golden log, a diff of two sessions
103
+ * that should be identical. `@msm-core/replay` had to *normalise* `toolCallId`
104
+ * to its order of appearance precisely because the value underneath was
105
+ * arbitrary; deriving the id removes the arbitrariness at the source instead of
106
+ * papering over it downstream.
107
+ *
108
+ * It is still unique: `seq` rises by exactly one per session and never repeats,
109
+ * so no two turns that write anything can claim the same head. (A turn that
110
+ * writes NOTHING leaves the head where it was and the next turn takes the same
111
+ * id — harmless, because the first turn put no event in the log to collide
112
+ * with.) And it is still meaningful: `t41` says the turn opened at seq 41.
113
+ *
114
+ * `seq` is held in memory for the turn and starts at `head + 1`. Two writers on
115
+ * one session would collide — which is why this is created *after* the session
116
+ * lock is held, and why a collision raises `SeqGapError` instead of silently
117
+ * renumbering.
118
+ */
119
+ class TurnWriter {
120
+ turnId;
121
+ #seq;
122
+ #log;
123
+ #sessionId;
124
+ constructor(log, sessionId, head) {
125
+ this.#log = log;
126
+ this.#sessionId = sessionId;
127
+ this.#seq = head;
128
+ this.turnId = `t${head + 1}`;
129
+ }
130
+ /** The id of step `index` (1-based) in this turn. */
131
+ stepId(index) {
132
+ return `${this.turnId}.s${index}`;
133
+ }
134
+ /** The id of the `ordinal`-th (1-based) tool call of a step. */
135
+ callId(stepId, ordinal) {
136
+ return `${stepId}.c${ordinal}`;
137
+ }
138
+ /** Append a turn-scoped event. Returns the `seq` it was written at. */
139
+ async writeTurn(type, data, time = Date.now()) {
140
+ return this.#append({ type, data, time });
141
+ }
142
+ /**
143
+ * Append a step-scoped event. `stepId` is required by the signature, not by a
144
+ * runtime check — the seven step types cannot be written without one.
145
+ */
146
+ async writeStep(type, stepId, data, time = Date.now()) {
147
+ return this.#append({ type, data, time, stepId });
148
+ }
149
+ async #append(parts) {
150
+ const seq = this.#seq + 1;
151
+ // The cast is the one place the type/data correlation is asserted rather
152
+ // than proved: `writeTurn` / `writeStep` above are what actually pair a
153
+ // type with its payload, and every caller goes through them.
154
+ const event = {
155
+ seq,
156
+ time: parts.time,
157
+ type: parts.type,
158
+ data: parts.data,
159
+ turnId: this.turnId,
160
+ ...(parts.stepId !== undefined ? { stepId: parts.stepId } : {}),
161
+ };
162
+ await this.#log.append(this.#sessionId, event);
163
+ // Advance only after the store accepted it — a rejected append must not
164
+ // leave a hole that the next write would silently skip over.
165
+ this.#seq = seq;
166
+ return seq;
167
+ }
168
+ }
169
+ /**
170
+ * Is the invariant armed?
171
+ *
172
+ * Silent by default in production, strict under the test runner. `MSM_SESSION_STRICT`
173
+ * overrides in both directions: set it to turn the check on in a live service
174
+ * (recommended in staging), set it to `0`/`false` to turn it off in a test run.
175
+ */
176
+ function sessionStrict() {
177
+ const flag = process.env["MSM_SESSION_STRICT"];
178
+ if (flag !== undefined && flag !== "") {
179
+ return flag !== "0" && flag.toLowerCase() !== "false";
180
+ }
181
+ return (process.env["VITEST"] !== undefined || process.env["NODE_ENV"] === "test");
182
+ }
183
+ /** JSON with object keys sorted, so two equal values always compare equal. */
184
+ function stable(value) {
185
+ return (JSON.stringify(value, (_key, val) => {
186
+ if (val === null || typeof val !== "object" || Array.isArray(val))
187
+ return val;
188
+ const source = val;
189
+ const sorted = {};
190
+ for (const key of Object.keys(source).sort())
191
+ sorted[key] = source[key];
192
+ return sorted;
193
+ }) ?? "undefined");
194
+ }
195
+ function invariantError(what, turnId, stepId, fromLog, sent) {
196
+ return new Error(`msm-mini session-log invariant: ${what} is not reconstructible from the ` +
197
+ `session log (turn=${turnId} step=${stepId}). ` +
198
+ `log=${stable(fromLog)} sent=${stable(sent)}`);
199
+ }
200
+ /** The four fields of a tool result that actually reach the model (see `foldToolResults`). */
201
+ function deliveredToolResult(result) {
202
+ return {
203
+ tool: result.tool,
204
+ status: result.status,
205
+ ...(result.result !== undefined ? { result: result.result } : {}),
206
+ ...(result.error !== undefined ? { error: result.error } : {}),
207
+ };
208
+ }
209
+ /**
210
+ * **The rule this whole session exists to enforce**: everything handed to the
211
+ * model must be rebuildable from the log alone.
212
+ *
213
+ * `BrainRunInput` carries four things into a model call, and each has exactly
214
+ * one counterpart in the log:
215
+ *
216
+ * | sent | rebuilt from |
217
+ * |------------------|-----------------------------------------------------|
218
+ * | `raw` | this turn's `user_message` |
219
+ * | `system_context` | this step's `model_request.data.system` |
220
+ * | `history` | `deriveMessages(events before this turn)`, same trim |
221
+ * | `tool_results` | this turn's PAIRED `tool_call`/`tool_result` events |
222
+ *
223
+ * The system context needs its own comparison because no message event can
224
+ * produce it: the assembled persona + memories + tool catalogue never passes
225
+ * through the conversation, so it is recorded as audit data or it is lost. One
226
+ * comparison against `deriveMessages` alone would report a difference that is
227
+ * not a difference.
228
+ *
229
+ * Tool results are rebuilt through the **pairing** rule, not from
230
+ * `tool_result` events alone. That is what gives the check teeth: drop the
231
+ * `tool_call` and the pair vanishes from the reconstruction; drop the
232
+ * `tool_result` and it vanishes too. Either half missing is a log that cannot
233
+ * replay the conversation it claims to record.
234
+ *
235
+ * The events are re-read from the store rather than remembered, so a store that
236
+ * loses, reorders or mangles a write is caught here and not at replay time.
237
+ *
238
+ * Throws on any difference; the caller runs it only when `sessionStrict()`.
239
+ */
240
+ function assertRequestReconstructible(args) {
241
+ const { events, turnId, stepId, historyLimit, sent } = args;
242
+ // The cut is this turn's USER MESSAGE, not this turn's first event.
243
+ //
244
+ // They were the same thing until ض١: the user message opened every turn. A
245
+ // turn that compacts now writes its `compaction` event first, and that event
246
+ // is precisely a statement ABOUT the past — it belongs on the `before` side of
247
+ // the cut, because the history handed to the model was derived with it
248
+ // applied. Cutting at the turn's first event would put the compaction on the
249
+ // `inTurn` side, reconstruct an UNcompacted conversation, and fire the
250
+ // invariant on a difference that is not one.
251
+ //
252
+ // On every path without compaction the two definitions pick the same index,
253
+ // so nothing else moves.
254
+ const turnStart = events.findIndex((e) => e.turnId === turnId && e.type === "user_message");
255
+ const before = turnStart < 0 ? events : events.slice(0, turnStart);
256
+ const inTurn = turnStart < 0 ? [] : events.slice(turnStart);
257
+ // 1 ── the user's message
258
+ const opening = inTurn.find((e) => e.type === "user_message");
259
+ if (!opening || opening.data.text !== sent.raw) {
260
+ throw invariantError("the user message", turnId, stepId, opening?.data.text, sent.raw);
261
+ }
262
+ // 2 ── the assembled system context, read back OUT of the log
263
+ //
264
+ // Its own comparison, because no message event can produce it: the assembled
265
+ // persona + memories + tool catalogue never travels through the conversation,
266
+ // so it is recorded as audit data or it is lost. Checking `deriveMessages`
267
+ // alone would report a difference that is not a difference (س٢ ruling 6).
268
+ let request;
269
+ for (const e of inTurn) {
270
+ if (e.type === "model_request" && e.stepId === stepId)
271
+ request = e;
272
+ }
273
+ if (!request) {
274
+ throw invariantError("the model request", turnId, stepId, null, "a model_request event for this step");
275
+ }
276
+ if (request.data.system !== sent.system_context) {
277
+ throw invariantError("the system context", turnId, stepId, request.data.system, sent.system_context);
278
+ }
279
+ // 3 ── the conversation, derived and trimmed exactly as the request was
280
+ const expectedHistory = deriveMessages(before)
281
+ .slice(-historyLimit)
282
+ .slice(-DEFAULT_BUDGET.maxHistoryMessages);
283
+ if (stable(expectedHistory) !== stable(sent.history)) {
284
+ throw invariantError("the conversation history", turnId, stepId, expectedHistory, sent.history);
285
+ }
286
+ // 4 ── the tool results, rebuilt through the pairing rule
287
+ const called = new Set();
288
+ const paired = [];
289
+ for (const e of inTurn) {
290
+ if (e.type === "tool_call") {
291
+ called.add(e.data.callId);
292
+ }
293
+ else if (e.type === "tool_result" && called.has(e.data.callId)) {
294
+ paired.push(deliveredToolResult({
295
+ tool: e.data.name,
296
+ status: e.data.status,
297
+ ...(e.data.result !== undefined ? { result: e.data.result } : {}),
298
+ ...(e.data.error !== undefined ? { error: e.data.error } : {}),
299
+ }));
300
+ }
301
+ }
302
+ const expectedResults = paired.slice(-TOOL_RESULT_WINDOW);
303
+ const sentResults = (sent.tool_results ?? []).map(deliveredToolResult);
304
+ if (stable(expectedResults) !== stable(sentResults)) {
305
+ throw invariantError("the tool results", turnId, stepId, expectedResults, sentResults);
306
+ }
307
+ }
23
308
  /**
24
309
  * Create a stateful agent from config.
25
310
  * Returns an Agent object — call .handle() on every incoming event.
@@ -87,19 +372,43 @@ export function createAgent(config) {
87
372
  ? `${prefix}:${event.tenantContext.companyId}:${event.tenantContext.agentType}`
88
373
  : prefix;
89
374
  }
375
+ /**
376
+ * The session store for one event — the loop's ONLY door to session memory.
377
+ *
378
+ * An injected `config.memory` wins and no Redis store is built (nor is the
379
+ * connection opened on its account). Otherwise the bundled `RedisMemory` is
380
+ * built from `config.redis` exactly as before: same tenant prefix, same
381
+ * history/document TTLs. Both `handle()` and `handleCore()` go through here,
382
+ * so the two construction sites that used to drift apart are now one.
383
+ */
384
+ async function resolveStore(event) {
385
+ // Read `config.memory` ONCE — the guard for "resolved once per cycle"
386
+ // counts property reads through a getter, and a second read here would be
387
+ // indistinguishable from a second resolve.
388
+ const injected = config.memory;
389
+ if (injected)
390
+ return injected;
391
+ const redis = await getRedis();
392
+ return new RedisMemory(redis, {
393
+ prefix: tenantPrefixFor(event),
394
+ ...(ttl.history !== undefined ? { historyTtl: ttl.history } : {}),
395
+ ...(ttl.document !== undefined ? { documentTtl: ttl.document } : {}),
396
+ });
397
+ }
90
398
  /**
91
399
  * Public entry point — runs the loop, then persists final run metadata
92
400
  * (status / iterations / cost) for ops dashboards. Metadata is best-effort:
93
401
  * it never blocks or fails the response.
402
+ *
403
+ * The store is resolved ONCE here and threaded into the loop. It used to be
404
+ * resolved twice per cycle — once inside `handleCore` and once again for this
405
+ * metadata write — which built two `RedisMemory` instances on the fallback
406
+ * path and read an injected port twice (س١ ruling 6). One turn, one store.
94
407
  */
95
408
  async function handle(event) {
96
- const outcome = await handleCore(event);
409
+ const memory = await resolveStore(event);
410
+ const outcome = await handleCore(event, memory);
97
411
  try {
98
- const redis = await getRedis();
99
- const memory = new RedisMemory(redis, {
100
- prefix: tenantPrefixFor(event),
101
- ...(ttl.history !== undefined ? { historyTtl: ttl.history } : {}),
102
- });
103
412
  await memory.setMetadata(event.sessionId, {
104
413
  iterationCount: outcome.metrics.iterations,
105
414
  startedAt: Date.now() - outcome.metrics.durationMs,
@@ -116,7 +425,7 @@ export function createAgent(config) {
116
425
  }
117
426
  return outcome;
118
427
  }
119
- async function handleCore(event) {
428
+ async function handleCore(event, memory) {
120
429
  const sessionId = event.sessionId;
121
430
  const taskId = makeTaskId();
122
431
  const started = Date.now();
@@ -125,12 +434,9 @@ export function createAgent(config) {
125
434
  const effectiveGuards = event.guardsOverride
126
435
  ? resolveGuards({ ...guards, ...event.guardsOverride })
127
436
  : guards;
437
+ // Redis still backs the control bus, the session lock and tool dedup —
438
+ // separate ports, untouched. Session memory arrives resolved from handle().
128
439
  const redis = await getRedis();
129
- const memory = new RedisMemory(redis, {
130
- prefix: tenantPrefix,
131
- ...(ttl.history !== undefined ? { historyTtl: ttl.history } : {}),
132
- ...(ttl.document !== undefined ? { documentTtl: ttl.document } : {}),
133
- });
134
440
  const controlBus = new RedisControlBus(redis, tenantPrefix);
135
441
  const lock = new RedisDistributedLock(redis, tenantPrefix);
136
442
  // ── Gates (zero-LLM filters) ─────────────────────────────
@@ -151,15 +457,241 @@ export function createAgent(config) {
151
457
  totalToolCalls: 0,
152
458
  consecutiveToolFailures: 0,
153
459
  };
460
+ /**
461
+ * The streaming request, built once — or not built at all (ب١).
462
+ *
463
+ * **This `undefined` is the feature.** With no `onChunk` hook configured,
464
+ * `BrainRunInput` carries no `onChunk`, every brain sees the field missing
465
+ * and takes its ordinary non-streaming path, and nothing anywhere in this
466
+ * run differs from yesterday. Passing a no-op sink instead would have put
467
+ * every agent on earth onto the streaming code path to feed a function that
468
+ * throws its argument away.
469
+ *
470
+ * `state.iteration` is read when a chunk fires, not when this closure is
471
+ * made, so each chunk carries the step it actually belongs to — including
472
+ * the finalize call, which streams under the iteration it aborted on.
473
+ */
474
+ const chunkSink = config.hooks?.onChunk
475
+ ? (chunk) => fireChunk(config.hooks, sessionId, state.iteration, chunk.text)
476
+ : undefined;
154
477
  const toolDefs = toToolDefinitions(config.tools);
155
478
  const toolMap = new Map(config.tools.map((t) => [t.name, t]));
156
479
  const toolResults = [];
480
+ const sessionLog = config.sessionLog;
481
+ const strict = sessionLog ? sessionStrict() : false;
482
+ /** The open step, if any — the error path uses it to close the step it died in. */
483
+ let openStep;
484
+ let turnLog;
157
485
  try {
158
- // Load history
159
- const history = await memory.getHistory(sessionId, effectiveGuards.maxIterations * 4);
486
+ const historyLimit = effectiveGuards.maxIterations * 4;
487
+ let history;
488
+ if (sessionLog) {
489
+ // ── The log is the truth, and the conversation is derived from it ──
490
+ //
491
+ // Read BEFORE the turn's first write: these are the events of every
492
+ // PREVIOUS turn, and they are what the conversation is derived from.
493
+ // The current message is not part of it — it travels as `raw`, the way
494
+ // it always has, and appending it to the history too would deliver it
495
+ // twice.
496
+ //
497
+ // The derived conversation carries the `assistant`/`tool` pairs of past
498
+ // turns, which `getHistory` never held (there is not one `role: "tool"`
499
+ // write in this package). That difference is the whole point of the
500
+ // log — and it is guarded explicitly on both paths, not left to be
501
+ // noticed in production.
502
+ turnLog = new TurnWriter(sessionLog, sessionId, await sessionLog.head(sessionId));
503
+ let past = await sessionLog.read(sessionId);
504
+ // ── The compaction seat (ض١) ──────────────────────────────────────
505
+ //
506
+ // Consulted BEFORE the turn's user message is written, so the range it
507
+ // proposes covers only completed turns and the summary is in place for
508
+ // the very first model call of this turn.
509
+ //
510
+ // Everything below is skipped when no port is injected — which is the
511
+ // default, and which is why an agent that does not opt in cannot tell
512
+ // ض١ happened. The seat needs the log and says so at the type level via
513
+ // `sessionLog` being the branch it lives in: a compaction is an EVENT,
514
+ // and without a log there is nowhere to record it.
515
+ if (config.compaction) {
516
+ const decision = await config.compaction.maybeCompact(deriveMessages(past), effectiveBudget(historyLimit));
517
+ const summary = decision?.summary?.trim();
518
+ if (summary) {
519
+ // Two defences, and this is the first: the port asked to keep N
520
+ // turns, `compactionBoundary` decides what that can safely mean.
521
+ // It returns 0 when nothing can be compacted without severing a
522
+ // tool call from its result or splitting a turn — and 0 means the
523
+ // loop writes nothing at all rather than writing a range the
524
+ // derivation would then refuse.
525
+ const toSeq = compactionBoundary(past, decision?.keepTurns ?? 0);
526
+ const fromSeq = compactionAnchor(past);
527
+ if (toSeq >= fromSeq) {
528
+ await turnLog.writeTurn("compaction", {
529
+ fromSeq,
530
+ toSeq,
531
+ summary,
532
+ // Who asked, not why — the loop does not know the port's
533
+ // reasoning and will not invent one for an audit record.
534
+ reason: "compaction_port",
535
+ });
536
+ // Re-read rather than patch what is in hand: the events come back
537
+ // through the store, so a log that lost or mangled the write is
538
+ // caught here, on this turn, and not at replay time.
539
+ past = await sessionLog.read(sessionId);
540
+ }
541
+ }
542
+ }
543
+ // The same cap the store applied on the legacy path, so the budget of a
544
+ // long session is bounded identically even when nothing compacts.
545
+ history = deriveMessages(past).slice(-historyLimit);
546
+ await turnLog.writeTurn("user_message", { text: event.message },
547
+ // `started`, not "now": the user's message arrived when the turn
548
+ // opened. It also keeps the derived `timestamp` equal to the one
549
+ // `appendHistory` writes for the same message.
550
+ started);
551
+ }
552
+ else {
553
+ history = await memory.getHistory(sessionId, historyLimit);
554
+ }
555
+ /**
556
+ * Close the turn: the delivered reply goes to BOTH records — the event
557
+ * log (the truth) and the history array (transitional compatibility,
558
+ * §5) — and the step that produced it is closed.
559
+ *
560
+ * One helper for all three delivery paths, because three copies of the
561
+ * same two writes is exactly how two of them silently drift apart (س١'s
562
+ * lesson, paid for once already).
563
+ */
564
+ const closeTurn = async (finalText, stepId, reason) => {
565
+ const now = Date.now();
566
+ await turnLog?.writeTurn("assistant_message", { text: finalText }, now);
567
+ await turnLog?.writeStep("step_ended", stepId, {
568
+ index: state.iteration,
569
+ reason,
570
+ });
571
+ openStep = undefined;
572
+ await memory.appendHistory(sessionId, {
573
+ role: "user",
574
+ content: event.message,
575
+ timestamp: started,
576
+ });
577
+ await memory.appendHistory(sessionId, {
578
+ role: "assistant",
579
+ content: finalText,
580
+ timestamp: now,
581
+ });
582
+ };
583
+ /** Close a step that produced no reply (a tool round-trip, or an abort). */
584
+ const endStep = async (stepId, reason) => {
585
+ await turnLog?.writeStep("step_ended", stepId, {
586
+ index: state.iteration,
587
+ reason,
588
+ });
589
+ openStep = undefined;
590
+ };
591
+ /** Record a guard that fired, at the step it fired in. */
592
+ const logGuard = async (stepId, guard, action, reason) => {
593
+ await turnLog?.writeStep("guard_fired", stepId, {
594
+ guard,
595
+ ...(action !== undefined ? { action } : {}),
596
+ ...(reason !== undefined ? { reason } : {}),
597
+ });
598
+ };
599
+ /** The output gate, plus the `guard_fired` record when it does something. */
600
+ const gateAndLog = async (text, stepId) => {
601
+ const gated = await applyOutputGate(text);
602
+ if (gated.validation && gated.validation.action !== "release") {
603
+ await logGuard(stepId, "output_gate", gated.validation.action, gated.validation.violations.join(", ") || undefined);
604
+ }
605
+ return gated;
606
+ };
607
+ /**
608
+ * Record what is about to be sent — then prove it is reconstructible.
609
+ *
610
+ * The invariant runs BEFORE the call goes out, not after: a request that
611
+ * cannot be rebuilt from the log is a request that should not be paid
612
+ * for, and failing after the money is spent teaches nothing the receipt
613
+ * would not.
614
+ */
615
+ const logRequest = async (input, stepId) => {
616
+ if (!turnLog || !sessionLog)
617
+ return;
618
+ await turnLog.writeStep("model_request", stepId, {
619
+ model: def.brain.model,
620
+ system: input.system_context,
621
+ messageCount: input.history.length,
622
+ toolNames: input.tools.map((t) => t.name),
623
+ });
624
+ if (!strict)
625
+ return;
626
+ // Re-read from the store rather than reuse what was just written: a log
627
+ // that loses, reorders or mangles a write is caught here, not at replay.
628
+ assertRequestReconstructible({
629
+ events: await sessionLog.read(sessionId),
630
+ turnId: turnLog.turnId,
631
+ stepId,
632
+ historyLimit,
633
+ sent: input,
634
+ });
635
+ };
636
+ /**
637
+ * Record what came back, verbatim — before any output gate touches it.
638
+ *
639
+ * `toolCalls` carries ALL the calls of the step, in the model's order,
640
+ * numbered `stepId.c1 … stepId.cN`. The log shape was built for this from
641
+ * the start (س٢ §4: "a step carries as many tool calls as the model asked
642
+ * for") — this is the writer finally filling it.
643
+ */
644
+ const logResponse = async (payload, stepId) => {
645
+ if (!turnLog)
646
+ return;
647
+ const writer = turnLog;
648
+ const orch = payload.orchestration;
649
+ const text = payload.generation?.response_text ?? payload.final_output?.text;
650
+ const calls = orch?.action === "use_tool" ? stepToolCalls(orch) : [];
651
+ await writer.writeStep("model_response", stepId, {
652
+ ...(text !== undefined ? { text } : {}),
653
+ ...(calls.length > 0
654
+ ? {
655
+ toolCalls: calls.map((call, index) => ({
656
+ callId: writer.callId(stepId, index + 1),
657
+ name: call.name,
658
+ args: call.params,
659
+ })),
660
+ }
661
+ : {}),
662
+ ...(orch?.action !== undefined ? { finishReason: orch.action } : {}),
663
+ ...(payload.usage !== undefined ? { usage: payload.usage } : {}),
664
+ ...(payload.costUsd !== undefined ? { costUsd: payload.costUsd } : {}),
665
+ });
666
+ };
667
+ /**
668
+ * One tool invocation, both halves. Written together because the pair is
669
+ * the unit: a call with no result replays as a question the model must
670
+ * answer and cannot, and a result with no call replays as an answer to
671
+ * nothing. Every provider rejects half a pair.
672
+ */
673
+ const logToolPair = async (stepId, ordinal, name, args, result) => {
674
+ if (!turnLog)
675
+ return;
676
+ const callId = turnLog.callId(stepId, ordinal);
677
+ await turnLog.writeStep("tool_call", stepId, { callId, name, args });
678
+ await turnLog.writeStep("tool_result", stepId, {
679
+ callId,
680
+ name,
681
+ status: result.status,
682
+ ...(result.result !== undefined ? { result: result.result } : {}),
683
+ ...(result.error !== undefined ? { error: result.error } : {}),
684
+ });
685
+ };
160
686
  // Loop
161
687
  while (true) {
162
688
  state.iteration++;
689
+ // ── Step opens ────────────────────────────────────────
690
+ const stepId = turnLog ? turnLog.stepId(state.iteration) : "";
691
+ openStep = { id: stepId, index: state.iteration };
692
+ await turnLog?.writeStep("step_started", stepId, {
693
+ index: state.iteration,
694
+ });
163
695
  // ── Control bus check ─────────────────────────────────
164
696
  const command = await controlBus.getCommand(sessionId);
165
697
  // ── Guards ────────────────────────────────────────────
@@ -170,9 +702,13 @@ export function createAgent(config) {
170
702
  const hardSignal = guardSignals.find((s) => s.hard);
171
703
  fireGuard(config.hooks, sessionId, hardSignal, state.iteration);
172
704
  const ha = hardAction(guardSignals);
705
+ // Logged at the same moment the hook fires, and only for the signal
706
+ // the loop acts on: a signal the loop ignores did not "fire".
707
+ await logGuard(stepId, hardSignal.type, ha, hardSignal.message);
173
708
  if (process.env["MINI_DEBUG"])
174
709
  console.error(`[mini] hardBlock type=${hardSignal.type} action=${ha} toolResults=${toolResults.length}`);
175
710
  if (ha === "abort") {
711
+ await endStep(stepId, "guard");
176
712
  await lockHandle.release();
177
713
  return makeOutcome("error", sessionId, taskId, state, started, {
178
714
  text: `Aborted: ${hardSignal.message}`,
@@ -193,14 +729,22 @@ export function createAgent(config) {
193
729
  const finalRemainingMs = effectiveGuards.timeoutMs > 0
194
730
  ? Math.max(1, effectiveGuards.timeoutMs - (Date.now() - state.startedAt))
195
731
  : 0;
196
- const finalPayload = await runWithTimeout(config.brain.run({
732
+ const finalInput = {
197
733
  raw: event.message,
198
734
  system_context,
199
735
  history: finalHistory,
200
736
  tools: [], // no tools → the model must produce a textual answer
201
- tool_results: toolResults.slice(-5),
737
+ tool_results: toolResults.slice(-TOOL_RESULT_WINDOW),
738
+ };
739
+ await logRequest(finalInput, stepId);
740
+ const finalPayload = await runWithTimeout(config.brain.run({
741
+ ...finalInput,
202
742
  signal: finalController.signal,
743
+ // The forced answer is the one the user actually reads —
744
+ // if anything in a run deserves to stream, it is this.
745
+ ...(chunkSink ? { onChunk: chunkSink } : {}),
203
746
  }), finalRemainingMs, finalController);
747
+ await logResponse(finalPayload, stepId);
204
748
  state.totalCostUsd += finalPayload.costUsd ?? 0;
205
749
  const forced = finalPayload.generation?.response_text ??
206
750
  finalPayload.final_output?.text ??
@@ -208,17 +752,8 @@ export function createAgent(config) {
208
752
  if (process.env["MINI_DEBUG"])
209
753
  console.error(`[mini] finalize fired; forced.len=${forced.length} orch=${finalPayload.orchestration?.action}`);
210
754
  if (forced) {
211
- const gated = await applyOutputGate(forced);
212
- await memory.appendHistory(sessionId, {
213
- role: "user",
214
- content: event.message,
215
- timestamp: started,
216
- });
217
- await memory.appendHistory(sessionId, {
218
- role: "assistant",
219
- content: gated.finalText,
220
- timestamp: Date.now(),
221
- });
755
+ const gated = await gateAndLog(forced, stepId);
756
+ await closeTurn(gated.finalText, stepId, hardSignal.type);
222
757
  await lockHandle.release();
223
758
  return makeOutcome(gated.validation?.action === "block" ? "suppressed" : "response", sessionId, taskId, state, started, {
224
759
  text: gated.finalText,
@@ -236,7 +771,7 @@ export function createAgent(config) {
236
771
  const lastText = state.lastPayload?.generation?.response_text ??
237
772
  state.lastPayload?.final_output?.text ??
238
773
  "I was unable to complete this request within the allowed limits. Please try a more specific question.";
239
- const gatedLast = await applyOutputGate(lastText);
774
+ const gatedLast = await gateAndLog(lastText, stepId);
240
775
  const outcomeType = gatedLast.validation?.action === "block"
241
776
  ? "suppressed"
242
777
  : ha === "escalate"
@@ -244,16 +779,7 @@ export function createAgent(config) {
244
779
  : "response";
245
780
  // Record BOTH sides of the turn — assistant-only history leaves
246
781
  // orphaned turns that misalign the conversation on the next call.
247
- await memory.appendHistory(sessionId, {
248
- role: "user",
249
- content: event.message,
250
- timestamp: started,
251
- });
252
- await memory.appendHistory(sessionId, {
253
- role: "assistant",
254
- content: gatedLast.finalText,
255
- timestamp: Date.now(),
256
- });
782
+ await closeTurn(gatedLast.finalText, stepId, hardSignal.type);
257
783
  await lockHandle.release();
258
784
  return makeOutcome(outcomeType, sessionId, taskId, state, started, {
259
785
  text: gatedLast.finalText,
@@ -274,9 +800,10 @@ export function createAgent(config) {
274
800
  history: trimmedHistory,
275
801
  tools: toolDefs,
276
802
  ...(toolResults.length > 0
277
- ? { tool_results: toolResults.slice(-5) }
803
+ ? { tool_results: toolResults.slice(-TOOL_RESULT_WINDOW) }
278
804
  : {}),
279
805
  };
806
+ await logRequest(brainInput, stepId);
280
807
  // Bound the brain call by the remaining time budget. Without this a hung
281
808
  // LLM call blocks forever — the wall-clock guard only runs *between*
282
809
  // iterations, never during one.
@@ -285,58 +812,111 @@ export function createAgent(config) {
285
812
  const remainingMs = effectiveGuards.timeoutMs > 0
286
813
  ? Math.max(1, effectiveGuards.timeoutMs - elapsed)
287
814
  : 0;
288
- const payload = await runWithTimeout(config.brain.run({ ...brainInput, signal: controller.signal }), remainingMs, controller);
815
+ const payload = await runWithTimeout(config.brain.run({
816
+ ...brainInput,
817
+ signal: controller.signal,
818
+ ...(chunkSink ? { onChunk: chunkSink } : {}),
819
+ }), remainingMs, controller);
820
+ await logResponse(payload, stepId);
289
821
  state.lastPayload = payload;
290
822
  state.totalCostUsd += payload.costUsd ?? 0;
291
823
  const orch = payload.orchestration;
292
- // ── Tool call ─────────────────────────────────────────
293
- if (orch?.action === "use_tool" && orch.tool_name) {
294
- const toolName = orch.tool_name;
295
- // Check if tool is disabled
824
+ // ── Tool calls ────────────────────────────────────────
825
+ //
826
+ // A step is N calls, not one. They run SEQUENTIALLY, in the model's
827
+ // order, and the loop returns to the model ONCE when they are all
828
+ // done — that single round-trip instead of N is the whole point.
829
+ //
830
+ // Sequential and not `Promise.all` on purpose: domain tools here write
831
+ // to shared systems, and nothing in the `Tool` contract promises they
832
+ // are safe to run concurrently. Parallel *requesting* is what the models
833
+ // do; parallel *executing* is a separate decision with a separate
834
+ // blast radius, and it is not made here.
835
+ //
836
+ // The gate is unchanged (`action === "use_tool"` plus a first call that
837
+ // names something), so a brain that fills only `tool_name` walks the
838
+ // path it always walked.
839
+ const stepCalls = orch?.action === "use_tool" ? stepToolCalls(orch) : [];
840
+ if (stepCalls.length > 0) {
841
+ // Read once for the whole step: one control-bus command governs the
842
+ // step it was read in, and re-reading per call would let a mid-step
843
+ // change split one step's siblings under two different policies.
296
844
  const disabledTool = RedisControlBus.disabledTool(command);
297
- if (disabledTool === toolName) {
298
- toolResults.push({
299
- tool: toolName,
300
- status: "failed",
301
- error: `Tool "${toolName}" is currently disabled`,
302
- });
303
- state.consecutiveToolFailures++;
304
- continue;
305
- }
306
- const tool = toolMap.get(toolName);
307
- if (!tool) {
308
- toolResults.push({
309
- tool: toolName,
310
- status: "failed",
311
- error: `Unknown tool: "${toolName}"`,
845
+ /** Did ANY call of this step come back ok? — §4's counter turns on it. */
846
+ let anySucceeded = false;
847
+ for (const [index, call] of stepCalls.entries()) {
848
+ // 1-based within the step, and stable: `stepId.c1 … stepId.cN`,
849
+ // matching the ids `model_response` already announced. A blocked or
850
+ // unknown call keeps its ordinal — renumbering around it would make
851
+ // the ids in the response and the ids on the pairs disagree.
852
+ const ordinal = index + 1;
853
+ const toolName = call.name;
854
+ const params = call.params;
855
+ // Disabled via the control bus — this call only. Its siblings are
856
+ // not this tool and are not refused on its account.
857
+ if (disabledTool === toolName) {
858
+ const blocked = {
859
+ tool: toolName,
860
+ status: "failed",
861
+ error: `Tool "${toolName}" is currently disabled`,
862
+ };
863
+ toolResults.push(blocked);
864
+ // A refused call is still a call the model made and a result it
865
+ // was shown. Logging only the calls that reached a tool would
866
+ // leave the model reasoning about an answer the log cannot
867
+ // account for.
868
+ await logToolPair(stepId, ordinal, toolName, params, blocked);
869
+ continue;
870
+ }
871
+ const tool = toolMap.get(toolName);
872
+ if (!tool) {
873
+ const unknown = {
874
+ tool: toolName,
875
+ status: "failed",
876
+ error: `Unknown tool: "${toolName}"`,
877
+ };
878
+ toolResults.push(unknown);
879
+ await logToolPair(stepId, ordinal, toolName, params, unknown);
880
+ continue;
881
+ }
882
+ // Per CALL, not per step: the approval gate, the onBeforeTool hook
883
+ // and the dedup key all live inside `executeTool`, so a
884
+ // `requiresApproval` tool fails closed for ITS call alone while its
885
+ // siblings run. A step is not an approval unit.
886
+ const exec = await executeTool(tool, params, {
887
+ sessionId,
888
+ iteration: state.iteration,
889
+ agentName: def.name,
890
+ ...(event.tenantContext ? { tenantContext: event.tenantContext } : {}),
891
+ }, {
892
+ redis,
893
+ redisPrefix: tenantPrefix,
894
+ dedupTtlSeconds: ttl.toolDedup ?? 300,
895
+ ...(config.hooks?.onBeforeTool
896
+ ? { hooks: { onBeforeTool: config.hooks.onBeforeTool } }
897
+ : {}),
312
898
  });
313
- state.consecutiveToolFailures++;
314
- continue;
315
- }
316
- const params = (orch.tool_params ?? {});
317
- const exec = await executeTool(tool, params, {
318
- sessionId,
319
- iteration: state.iteration,
320
- agentName: def.name,
321
- ...(event.tenantContext ? { tenantContext: event.tenantContext } : {}),
322
- }, {
323
- redis,
324
- redisPrefix: tenantPrefix,
325
- dedupTtlSeconds: ttl.toolDedup ?? 300,
326
- ...(config.hooks?.onBeforeTool
327
- ? { hooks: { onBeforeTool: config.hooks.onBeforeTool } }
328
- : {}),
329
- });
330
- fireToolCall(config.hooks, sessionId, exec.result, state.iteration, exec.cached, exec.durationMs);
331
- toolResults.push(exec.result);
332
- state.totalToolCalls++;
333
- if (exec.result.status === "failed") {
334
- state.consecutiveToolFailures++;
899
+ fireToolCall(config.hooks, sessionId, exec.result, state.iteration, exec.cached, exec.durationMs);
900
+ toolResults.push(exec.result);
901
+ state.totalToolCalls++;
902
+ if (exec.result.status !== "failed")
903
+ anySucceeded = true;
904
+ await logToolPair(stepId, ordinal, toolName, params, exec.result);
905
+ // …and on to the next sibling. One tool's failure is that tool's
906
+ // failure: it does not abort the step, because the model asked for
907
+ // several answers and a partial set is still worth more than none.
335
908
  }
336
- else {
909
+ // §4 — the counter counts STEPS, not calls. A step with at least one
910
+ // success clears it; a step where everything failed adds exactly one.
911
+ // Counting per call would let a single bad step of five calls burn a
912
+ // three-failure budget outright, which turns a broken tool into a
913
+ // terminated conversation.
914
+ if (anySucceeded)
337
915
  state.consecutiveToolFailures = 0;
338
- }
339
- continue; // next iteration
916
+ else
917
+ state.consecutiveToolFailures++;
918
+ await endStep(stepId, "tool_calls");
919
+ continue; // ONE model round-trip for the whole step
340
920
  }
341
921
  // ── Terminal outcome ──────────────────────────────────
342
922
  const text = payload.generation?.response_text ?? payload.final_output?.text ?? "";
@@ -354,18 +934,10 @@ export function createAgent(config) {
354
934
  }
355
935
  }
356
936
  // ── Optional output gate (shared with all guard exits) ─
357
- const { finalText, validation } = await applyOutputGate(text);
358
- // Record to history (the delivered text, post-gate)
359
- await memory.appendHistory(sessionId, {
360
- role: "user",
361
- content: event.message,
362
- timestamp: started,
363
- });
364
- await memory.appendHistory(sessionId, {
365
- role: "assistant",
366
- content: finalText,
367
- timestamp: Date.now(),
368
- });
937
+ const { finalText, validation } = await gateAndLog(text, stepId);
938
+ // Record the delivered text, post-gate — to the log and to the history
939
+ // array both, for as long as both exist.
940
+ await closeTurn(finalText, stepId, "final_response");
369
941
  const baseType = orch?.action === "clarify"
370
942
  ? "clarify"
371
943
  : orch?.action === "escalate"
@@ -383,6 +955,21 @@ export function createAgent(config) {
383
955
  catch (err) {
384
956
  const error = err instanceof Error ? err : new Error(String(err));
385
957
  fireFatalError(config.hooks, error, sessionId);
958
+ // Close the step the run died inside — best effort, and ONLY here. The
959
+ // log may well be the thing that just failed, and a second failure must
960
+ // not replace the original error with a less informative one. A log that
961
+ // simply stops mid-step is itself an honest record of a run that did.
962
+ if (turnLog && openStep) {
963
+ try {
964
+ await turnLog.writeStep("step_ended", openStep.id, {
965
+ index: openStep.index,
966
+ reason: "error",
967
+ });
968
+ }
969
+ catch {
970
+ // unreachable log — nothing more to record it with
971
+ }
972
+ }
386
973
  await lockHandle.release();
387
974
  return makeOutcome("error", sessionId, taskId, state, started, {
388
975
  text: "An unexpected error occurred. Please try again.",
@@ -410,6 +997,70 @@ export function createAgent(config) {
410
997
  }
411
998
  return { handle, kill, pause, resume };
412
999
  }
1000
+ const DEFAULT_COMPACTOR_SYSTEM = "You compress conversation transcripts for an AI agent's working memory. " +
1001
+ "You are not talking to the user and you never answer the user's question.";
1002
+ const DEFAULT_COMPACTOR_INSTRUCTION = "Summarise the transcript below so that an assistant can continue the " +
1003
+ "conversation without it. Preserve: the user's goal, every decision and " +
1004
+ "commitment made, every fact, figure, name and identifier established, and " +
1005
+ "any question still open. Drop pleasantries and repetition. Write plain " +
1006
+ "prose, no preamble, no headings.";
1007
+ /**
1008
+ * A `CompactionPort` that summarises with an injected `Brain`.
1009
+ *
1010
+ * **A reference implementation, not a requirement.** The port is the contract;
1011
+ * this is one way to satisfy it, and a consumer with a cheaper model, a local
1012
+ * one, or a rule-based summariser wires up its own and the loop cannot tell.
1013
+ *
1014
+ * It opens no connection of its own. The summary is an ordinary `brain.run`
1015
+ * call on a brain the caller built and injected — the same covenant as every
1016
+ * other port in this package: nothing here knows the network.
1017
+ *
1018
+ * Two deliberate refusals:
1019
+ *
1020
+ * - It does not use the agent's own tools, history or system context. A
1021
+ * summariser that could call tools is a second agent with no budget and no
1022
+ * guards.
1023
+ * - It does not swallow a provider failure. An empty answer is a decision
1024
+ * ("nothing to compact"); a thrown error is a broken compactor, and a
1025
+ * compactor that has been quietly failing is a session quietly losing its
1026
+ * head — the failure ض١ exists to end.
1027
+ *
1028
+ * The rendering is deterministic and reuses `toWireMessage`, so the summariser
1029
+ * reads a tool round-trip exactly as the agent's own model reads it (س٤'s wire
1030
+ * contract), and the same conversation always produces the same prompt.
1031
+ */
1032
+ export function createBrainCompactor(brain, opts = {}) {
1033
+ const keepTurns = opts.keepTurns ?? 2;
1034
+ const systemContext = opts.systemContext ?? DEFAULT_COMPACTOR_SYSTEM;
1035
+ const instruction = opts.instruction ?? DEFAULT_COMPACTOR_INSTRUCTION;
1036
+ return {
1037
+ async maybeCompact(messages, budget) {
1038
+ const threshold = opts.threshold ?? budget.maxHistoryMessages;
1039
+ if (messages.length <= threshold)
1040
+ return null;
1041
+ const transcript = messages
1042
+ .map((message) => {
1043
+ const wire = toWireMessage(message);
1044
+ return `${wire.role}: ${wire.content}`;
1045
+ })
1046
+ .join("\n");
1047
+ const payload = await brain.run({
1048
+ raw: `${instruction}\n\n<transcript>\n${transcript}\n</transcript>`,
1049
+ system_context: systemContext,
1050
+ history: [],
1051
+ tools: [],
1052
+ });
1053
+ const summary = (payload.generation?.response_text ??
1054
+ payload.final_output?.text ??
1055
+ "").trim();
1056
+ // No text is not an error, it is "I have nothing to say about this" —
1057
+ // and the loop then leaves the conversation exactly as it found it.
1058
+ if (!summary)
1059
+ return null;
1060
+ return { summary, keepTurns };
1061
+ },
1062
+ };
1063
+ }
413
1064
  // ─── Helpers ──────────────────────────────────────────────────
414
1065
  /**
415
1066
  * Race a brain call against a time budget. On timeout, abort the controller