@msm-core/mini 0.9.0 → 0.15.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
@@ -9,6 +9,9 @@
9
9
  */
10
10
  import { randomBytes } from "crypto";
11
11
  import { compactionAnchor, compactionBoundary, deriveMessages, } from "@msm-core/session";
12
+ // The declaration thread of ص٣/٢, and the only values this file takes from
13
+ // `types.js`: one predicate to read the hook's opt-in, one wrapper to pass it on.
14
+ import { acceptResets, isResetAwareHook } from "./types.js";
12
15
  import { toWireMessage } from "../brain/tool-context.js";
13
16
  import { resolveGuards, checkGuards, hasHardBlock, hardAction, } from "./guards.js";
14
17
  import { runGates } from "./gates.js";
@@ -18,6 +21,7 @@ import { RedisMemory, connectRedis } from "../adapters/redis-memory.js";
18
21
  import { RedisControlBus } from "../adapters/redis-control.js";
19
22
  import { RedisDistributedLock } from "../adapters/redis-lock.js";
20
23
  import { executeTool, toToolDefinitions } from "../tools/executor.js";
24
+ import { RedisToolDedup } from "../tools/dedup.js";
21
25
  import { parseDefinition } from "../definition/parser.js";
22
26
  function makeTaskId() {
23
27
  return randomBytes(8).toString("hex");
@@ -305,11 +309,62 @@ function assertRequestReconstructible(args) {
305
309
  throw invariantError("the tool results", turnId, stepId, expectedResults, sentResults);
306
310
  }
307
311
  }
312
+ /**
313
+ * The four ports Redis can back, each paired with what a reader of the error
314
+ * will recognise it as. In the order a turn resolves them, so an error listing
315
+ * several reads in the order the run would have needed them.
316
+ */
317
+ const REDIS_BACKED_PORTS = [
318
+ ["memory", "session memory — history, metadata, document state"],
319
+ ["controlBus", "the control bus — kill / pause / disable-tool"],
320
+ ["lock", "the session run-lock"],
321
+ ["dedup", "tool-call dedup"],
322
+ ];
323
+ /**
324
+ * `redis` is optional (ص٤) under exactly one rule: **whatever it would have
325
+ * backed must be injected instead.** This is where that rule is enforced, and
326
+ * where it is enforced is half the point.
327
+ *
328
+ * A port is resolved lazily, deep inside a turn, on a path a given event may
329
+ * not even take. Left to itself, a composition that dropped `redis` while
330
+ * forgetting `dedup` would run fine until the first tool call — possibly in
331
+ * production, hours after the process started — and then fail with a message
332
+ * about a Redis config nobody wrote. A composition mistake belongs at
333
+ * composition time, named by the exact field that is missing, before a single
334
+ * event is handled and before a single token is paid for.
335
+ *
336
+ * Absence is read from the VALUE, not from the key: `exactOptionalPropertyTypes`
337
+ * stops a TypeScript caller writing `redis: undefined`, but a JavaScript host
338
+ * spreading an options bag can hand one over, and that caller deserves this
339
+ * error rather than a `TypeError` from inside `getRedis()`.
340
+ *
341
+ * With `redis` present this returns immediately and nothing about any existing
342
+ * composition changes.
343
+ */
344
+ function assertRedisOrPorts(config) {
345
+ if (config.redis !== undefined)
346
+ return;
347
+ const missing = REDIS_BACKED_PORTS.filter(([field]) => config[field] === undefined);
348
+ if (missing.length === 0)
349
+ return;
350
+ const named = missing
351
+ .map(([field, what]) => `'${field}' (${what})`)
352
+ .join(", ");
353
+ const one = missing.length === 1;
354
+ throw new Error(`msm-mini: AgentConfig has no 'redis', so every port Redis would have ` +
355
+ `backed must be injected — missing ${one ? "port" : "ports"}: ${named}. ` +
356
+ `Inject ${one ? "it" : "them"} (InMemorySessionStore / InMemoryControlBus / ` +
357
+ `InMemoryLock / InMemoryToolDedup ship from '@msm-core/mini/adapters'), ` +
358
+ `or supply 'redis'.`);
359
+ }
308
360
  /**
309
361
  * Create a stateful agent from config.
310
362
  * Returns an Agent object — call .handle() on every incoming event.
311
363
  */
312
364
  export function createAgent(config) {
365
+ // Before anything else: a redis-less config with an unbacked port is a
366
+ // composition error, and it is raised here rather than mid-turn (ص٤).
367
+ assertRedisOrPorts(config);
313
368
  const def = typeof config.definition === "string"
314
369
  ? parseDefinition(config.definition)
315
370
  : config.definition;
@@ -318,16 +373,24 @@ export function createAgent(config) {
318
373
  // never applied — the documented limits silently no-op'd (M1). resolveGuards
319
374
  // then rejects any NaN/garbage and clamps to sane bounds (H10).
320
375
  const guards = resolveGuards({ ...def.limits, ...config.guards });
321
- const prefix = config.redis.prefix ?? "msm";
322
- const ttl = config.redis.ttl ?? {};
323
- // Lazy-initialized shared Redis connection
376
+ const prefix = config.redis?.prefix ?? "msm";
377
+ const ttl = config.redis?.ttl ?? {};
378
+ // Lazy-initialized shared Redis connection.
379
+ //
380
+ // Reachable only when `config.redis` is present: `assertRedisOrPorts` has
381
+ // already refused a redis-less config that left any of the four ports
382
+ // unbacked, and a redis-less config with all four injected never calls a
383
+ // resolver's fallback arm. The `?.`s below are therefore about a config
384
+ // object that EXISTS but is empty — `redis: {}` keeps throwing the same
385
+ // message it always has, which is what makes it a usable "explode if opened"
386
+ // test config (ports.test.ts leans on exactly that).
324
387
  let _redis = null;
325
388
  async function getRedis() {
326
389
  if (!_redis) {
327
- if (config.redis.client) {
390
+ if (config.redis?.client) {
328
391
  _redis = config.redis.client;
329
392
  }
330
- else if (config.redis.url) {
393
+ else if (config.redis?.url) {
331
394
  _redis = (await connectRedis(config.redis.url));
332
395
  }
333
396
  else {
@@ -395,6 +458,49 @@ export function createAgent(config) {
395
458
  ...(ttl.document !== undefined ? { documentTtl: ttl.document } : {}),
396
459
  });
397
460
  }
461
+ // ── The other three ports (س٦) ─────────────────────────────
462
+ //
463
+ // Same shape as `resolveStore`, three times: an injected port wins and no
464
+ // Redis object is built for it — and, critically, `getRedis()` is not called
465
+ // on its account, so a run with all four ports injected never opens a
466
+ // connection at all. Omit one and the loop builds the bundled Redis adapter
467
+ // exactly as it did before, with the tenant-scoped prefix it computed.
468
+ //
469
+ // They take the prefix rather than the event because `kill/pause/resume` have
470
+ // no event — they are handed a `companyId`/`agentType` pair instead. That is
471
+ // also why these are one function each and not four `new RedisControlBus`
472
+ // calls: the four sites that used to construct a bus by hand (handleCore plus
473
+ // the three public commands) had already begun to differ in how they built
474
+ // their prefix, and two sites that do the same thing separately are two sites
475
+ // that will one day do it differently (س١).
476
+ async function resolveControlBus(tenantPrefix) {
477
+ const injected = config.controlBus;
478
+ if (injected)
479
+ return injected;
480
+ return new RedisControlBus(await getRedis(), tenantPrefix);
481
+ }
482
+ async function resolveLock(tenantPrefix) {
483
+ const injected = config.lock;
484
+ if (injected)
485
+ return injected;
486
+ return new RedisDistributedLock(await getRedis(), tenantPrefix);
487
+ }
488
+ async function resolveDedup(tenantPrefix) {
489
+ const injected = config.dedup;
490
+ if (injected)
491
+ return injected;
492
+ return new RedisToolDedup(await getRedis(), tenantPrefix);
493
+ }
494
+ /**
495
+ * The prefix the three public commands scope their bus with — the one
496
+ * expression that used to be copy-pasted into `kill`, `pause` and `resume`.
497
+ * Note it is NOT `tenantPrefixFor`: those take the two ids separately and
498
+ * fall back to the base prefix when either is missing, which is the behaviour
499
+ * being preserved, not tidied.
500
+ */
501
+ function commandPrefix(companyId, agentType) {
502
+ return companyId && agentType ? `${prefix}:${companyId}:${agentType}` : prefix;
503
+ }
398
504
  /**
399
505
  * Public entry point — runs the loop, then persists final run metadata
400
506
  * (status / iterations / cost) for ops dashboards. Metadata is best-effort:
@@ -434,11 +540,13 @@ export function createAgent(config) {
434
540
  const effectiveGuards = event.guardsOverride
435
541
  ? resolveGuards({ ...guards, ...event.guardsOverride })
436
542
  : guards;
437
- // Redis still backs the control bus, the session lock and tool dedup —
438
- // separate ports, untouched. Session memory arrives resolved from handle().
439
- const redis = await getRedis();
440
- const controlBus = new RedisControlBus(redis, tenantPrefix);
441
- const lock = new RedisDistributedLock(redis, tenantPrefix);
543
+ // The three run-control ports, resolved where the Redis trio used to be
544
+ // constructed injected if the composition provided them, Redis-backed
545
+ // from `config.redis` if not. Session memory arrives resolved from
546
+ // handle(). With all four injected, nothing below touches Redis.
547
+ const controlBus = await resolveControlBus(tenantPrefix);
548
+ const lock = await resolveLock(tenantPrefix);
549
+ const dedup = await resolveDedup(tenantPrefix);
442
550
  // ── Gates (zero-LLM filters) ─────────────────────────────
443
551
  const gated = runGates(sessionId, taskId, event.message, config.gates);
444
552
  if (gated)
@@ -470,10 +578,24 @@ export function createAgent(config) {
470
578
  * `state.iteration` is read when a chunk fires, not when this closure is
471
579
  * made, so each chunk carries the step it actually belongs to — including
472
580
  * the finalize call, which streams under the iteration it aborted on.
581
+ *
582
+ * **And it declares for resets only when the hook did (ص٣/٢).** ص٢ built
583
+ * the cure in `brain/retry.ts` and could not deliver it here, because a
584
+ * declared sink whose `fireChunk` had no reset channel would take the flag
585
+ * and drop it — showing the hook consumer the retried answer glued onto
586
+ * the truncated one, which is ب١/٢ over again. `fireChunk` now carries the
587
+ * flag, so the loop can ask the one question worth asking: did the person
588
+ * on the far end of `onChunk` say they would act on it? Declared, the sink
589
+ * is wrapped and `withRetry` clears-and-restarts; undeclared, `isResetAware`
590
+ * is false in `retry.ts`, the ص١ mute path runs, and not a byte of this
591
+ * run differs from yesterday. The default did not move — it was extended.
473
592
  */
474
- const chunkSink = config.hooks?.onChunk
475
- ? (chunk) => fireChunk(config.hooks, sessionId, state.iteration, chunk.text)
593
+ const rawChunkSink = config.hooks?.onChunk
594
+ ? (chunk) => fireChunk(config.hooks, sessionId, state.iteration, chunk.text, chunk.reset)
476
595
  : undefined;
596
+ const chunkSink = rawChunkSink !== undefined && isResetAwareHook(config.hooks?.onChunk)
597
+ ? acceptResets(rawChunkSink)
598
+ : rawChunkSink;
477
599
  const toolDefs = toToolDefinitions(config.tools);
478
600
  const toolMap = new Map(config.tools.map((t) => [t.name, t]));
479
601
  const toolResults = [];
@@ -640,6 +762,15 @@ export function createAgent(config) {
640
762
  * numbered `stepId.c1 … stepId.cN`. The log shape was built for this from
641
763
  * the start (س٢ §4: "a step carries as many tool calls as the model asked
642
764
  * for") — this is the writer finally filling it.
765
+ *
766
+ * **And `model` is who answered, not who was asked** (س٦/٤). The request
767
+ * event carries `def.brain.model` — the configured name, which is the
768
+ * honest answer to "what did this agent ask for". It is NOT the honest
769
+ * answer to "what replied", and the two come apart the moment a host
770
+ * swaps the brain under a definition. So the responder travels on the
771
+ * payload, from the provider's own reply, and is written here beside the
772
+ * text it produced. A brain that does not report one writes no `model`
773
+ * key at all — an absent field, never an inferred one.
643
774
  */
644
775
  const logResponse = async (payload, stepId) => {
645
776
  if (!turnLog)
@@ -649,6 +780,7 @@ export function createAgent(config) {
649
780
  const text = payload.generation?.response_text ?? payload.final_output?.text;
650
781
  const calls = orch?.action === "use_tool" ? stepToolCalls(orch) : [];
651
782
  await writer.writeStep("model_response", stepId, {
783
+ ...(payload.model !== undefined ? { model: payload.model } : {}),
652
784
  ...(text !== undefined ? { text } : {}),
653
785
  ...(calls.length > 0
654
786
  ? {
@@ -760,6 +892,7 @@ export function createAgent(config) {
760
892
  terminatedBy: hardSignal.type,
761
893
  toolCalls: toolResults,
762
894
  ...(gated.validation ? { validation: gated.validation } : {}),
895
+ ...thoughtsOf(finalPayload, gated.validation),
763
896
  });
764
897
  }
765
898
  }
@@ -768,8 +901,15 @@ export function createAgent(config) {
768
901
  }
769
902
  }
770
903
  // force_respond / escalate → emit last text if available
771
- const lastText = state.lastPayload?.generation?.response_text ??
772
- state.lastPayload?.final_output?.text ??
904
+ //
905
+ // Two sources, told apart on purpose (ت١/٢ item 4): text the model
906
+ // wrote in its last payload, or the canned line below when it wrote
907
+ // none. The last payload's `thoughts` rides out ONLY with the former
908
+ // — thinking accompanies the answer it produced, never a sentence
909
+ // the model did not write.
910
+ const lastWritten = state.lastPayload?.generation?.response_text ??
911
+ state.lastPayload?.final_output?.text;
912
+ const lastText = lastWritten ??
773
913
  "I was unable to complete this request within the allowed limits. Please try a more specific question.";
774
914
  const gatedLast = await gateAndLog(lastText, stepId);
775
915
  const outcomeType = gatedLast.validation?.action === "block"
@@ -786,6 +926,9 @@ export function createAgent(config) {
786
926
  terminatedBy: hardSignal.type,
787
927
  toolCalls: toolResults,
788
928
  ...(gatedLast.validation ? { validation: gatedLast.validation } : {}),
929
+ ...(lastWritten !== undefined
930
+ ? thoughtsOf(state.lastPayload, gatedLast.validation)
931
+ : {}),
789
932
  });
790
933
  }
791
934
  // ── Context build ─────────────────────────────────────
@@ -889,8 +1032,7 @@ export function createAgent(config) {
889
1032
  agentName: def.name,
890
1033
  ...(event.tenantContext ? { tenantContext: event.tenantContext } : {}),
891
1034
  }, {
892
- redis,
893
- redisPrefix: tenantPrefix,
1035
+ dedup,
894
1036
  dedupTtlSeconds: ttl.toolDedup ?? 300,
895
1037
  ...(config.hooks?.onBeforeTool
896
1038
  ? { hooks: { onBeforeTool: config.hooks.onBeforeTool } }
@@ -949,6 +1091,9 @@ export function createAgent(config) {
949
1091
  text: finalText,
950
1092
  toolCalls: toolResults,
951
1093
  ...(validation ? { validation } : {}),
1094
+ // The last step's thinking rides out with its answer (ت١) — and only
1095
+ // here, not into the log: the model never sees it again.
1096
+ ...thoughtsOf(payload, validation),
952
1097
  });
953
1098
  }
954
1099
  }
@@ -978,21 +1123,15 @@ export function createAgent(config) {
978
1123
  }
979
1124
  }
980
1125
  async function kill(sessionId, companyId, agentType) {
981
- const redis = await getRedis();
982
- const p = companyId && agentType ? `${prefix}:${companyId}:${agentType}` : prefix;
983
- const bus = new RedisControlBus(redis, p);
1126
+ const bus = await resolveControlBus(commandPrefix(companyId, agentType));
984
1127
  await bus.kill(sessionId);
985
1128
  }
986
1129
  async function pause(sessionId, companyId, agentType) {
987
- const redis = await getRedis();
988
- const p = companyId && agentType ? `${prefix}:${companyId}:${agentType}` : prefix;
989
- const bus = new RedisControlBus(redis, p);
1130
+ const bus = await resolveControlBus(commandPrefix(companyId, agentType));
990
1131
  await bus.pause(sessionId);
991
1132
  }
992
1133
  async function resume(sessionId, companyId, agentType) {
993
- const redis = await getRedis();
994
- const p = companyId && agentType ? `${prefix}:${companyId}:${agentType}` : prefix;
995
- const bus = new RedisControlBus(redis, p);
1134
+ const bus = await resolveControlBus(commandPrefix(companyId, agentType));
996
1135
  await bus.resume(sessionId);
997
1136
  }
998
1137
  return { handle, kill, pause, resume };
@@ -1086,6 +1225,31 @@ async function runWithTimeout(brainCall, budgetMs, controller) {
1086
1225
  clearTimeout(timer);
1087
1226
  }
1088
1227
  }
1228
+ /**
1229
+ * `{ thoughts }` off the payload the outcome's text came from, or `{}` (ت١).
1230
+ *
1231
+ * Spread into the outcome at every site that takes its `text` from a payload,
1232
+ * and nowhere else — the thinking travels with the answer it belongs to. A
1233
+ * conditional spread so an outcome without thinking has no key, not an
1234
+ * `undefined` one that `toEqual` would wave through (تعميم المجلس ٦).
1235
+ *
1236
+ * **A blocked answer takes its thinking with it** (ت١/٢ item 3). The output
1237
+ * gate validates the TEXT; it never reads `thoughts`. So when the gate says
1238
+ * `block` — the outcome is `suppressed` and the text is the canned line — the
1239
+ * reasoning that produced the blocked answer is dropped too: what the gate
1240
+ * would not let out as an answer does not leave as notes. On every other
1241
+ * verdict (`release`, `review`, or no validator, including a gate that never
1242
+ * ran because the text was empty) `thoughts` passes UNGATED — it is audit
1243
+ * material for the host, declared as such on `LoopOutcome.thoughts`, and not
1244
+ * a second answer for an end user.
1245
+ */
1246
+ function thoughtsOf(payload, validation) {
1247
+ if (validation?.action === "block")
1248
+ return {};
1249
+ return typeof payload?.thoughts === "string" && payload.thoughts !== ""
1250
+ ? { thoughts: payload.thoughts }
1251
+ : {};
1252
+ }
1089
1253
  function makeOutcome(type, sessionId, taskId, state, started, extra = {}) {
1090
1254
  return {
1091
1255
  type,