@msm-core/mini 0.9.0 → 0.14.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.
@@ -10,13 +10,25 @@ export declare function fireSectionComplete(hooks: AgentHooks | undefined, sessi
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
12
  /**
13
- * One streamed piece of model text (ب١).
13
+ * One streamed piece of model text (ب١), carrying `reset` when there is one
14
+ * and a consumer who asked for it (ص٣/٢).
14
15
  *
15
16
  * Same shape as its siblings, and the same contract: `safely` swallows a
16
17
  * throwing hook. That matters more here than anywhere else — this one fires
17
18
  * dozens of times per step, in the middle of reading a provider stream, and a
18
19
  * consumer whose SSE socket closed mid-answer must not take the run down with
19
20
  * it. A failed display is a failed display, not a failed run.
21
+ *
22
+ * **`reset` is spread, not assigned.** `{ sessionId, iteration, text }` is what
23
+ * an undeclared consumer received before this parameter existed and it is
24
+ * exactly what it receives now — the key is ABSENT, not present-and-undefined,
25
+ * so `Object.keys`, `toEqual`, `JSON.stringify` and a `for…in` all see what
26
+ * they saw yesterday. That equality is the mandatory control of ص٣/٢, and
27
+ * writing `reset` unconditionally would have broken it while every existing
28
+ * assertion stayed green.
29
+ *
30
+ * The parameter is optional and last, so the pre-ص٣ call shape still compiles
31
+ * and still means what it meant.
20
32
  */
21
- export declare function fireChunk(hooks: AgentHooks | undefined, sessionId: string, iteration: number, text: string): void;
33
+ export declare function fireChunk(hooks: AgentHooks | undefined, sessionId: string, iteration: number, text: string, reset?: true): void;
22
34
  export declare function fireFatalError(hooks: AgentHooks | undefined, error: Error, sessionId: string): void;
@@ -54,18 +54,35 @@ export function fireGuard(hooks, sessionId, signal, iteration) {
54
54
  safely(() => hooks.onGuard({ sessionId, signal, iteration }));
55
55
  }
56
56
  /**
57
- * One streamed piece of model text (ب١).
57
+ * One streamed piece of model text (ب١), carrying `reset` when there is one
58
+ * and a consumer who asked for it (ص٣/٢).
58
59
  *
59
60
  * Same shape as its siblings, and the same contract: `safely` swallows a
60
61
  * throwing hook. That matters more here than anywhere else — this one fires
61
62
  * dozens of times per step, in the middle of reading a provider stream, and a
62
63
  * consumer whose SSE socket closed mid-answer must not take the run down with
63
64
  * it. A failed display is a failed display, not a failed run.
65
+ *
66
+ * **`reset` is spread, not assigned.** `{ sessionId, iteration, text }` is what
67
+ * an undeclared consumer received before this parameter existed and it is
68
+ * exactly what it receives now — the key is ABSENT, not present-and-undefined,
69
+ * so `Object.keys`, `toEqual`, `JSON.stringify` and a `for…in` all see what
70
+ * they saw yesterday. That equality is the mandatory control of ص٣/٢, and
71
+ * writing `reset` unconditionally would have broken it while every existing
72
+ * assertion stayed green.
73
+ *
74
+ * The parameter is optional and last, so the pre-ص٣ call shape still compiles
75
+ * and still means what it meant.
64
76
  */
65
- export function fireChunk(hooks, sessionId, iteration, text) {
77
+ export function fireChunk(hooks, sessionId, iteration, text, reset) {
66
78
  if (!hooks?.onChunk)
67
79
  return;
68
- safely(() => hooks.onChunk({ sessionId, iteration, text }));
80
+ safely(() => hooks.onChunk({
81
+ sessionId,
82
+ iteration,
83
+ text,
84
+ ...(reset === true ? { reset } : {}),
85
+ }));
69
86
  }
70
87
  export function fireFatalError(hooks, error, sessionId) {
71
88
  if (!hooks?.onFatalError)
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
  ? {
@@ -889,8 +1021,7 @@ export function createAgent(config) {
889
1021
  agentName: def.name,
890
1022
  ...(event.tenantContext ? { tenantContext: event.tenantContext } : {}),
891
1023
  }, {
892
- redis,
893
- redisPrefix: tenantPrefix,
1024
+ dedup,
894
1025
  dedupTtlSeconds: ttl.toolDedup ?? 300,
895
1026
  ...(config.hooks?.onBeforeTool
896
1027
  ? { hooks: { onBeforeTool: config.hooks.onBeforeTool } }
@@ -978,21 +1109,15 @@ export function createAgent(config) {
978
1109
  }
979
1110
  }
980
1111
  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);
1112
+ const bus = await resolveControlBus(commandPrefix(companyId, agentType));
984
1113
  await bus.kill(sessionId);
985
1114
  }
986
1115
  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);
1116
+ const bus = await resolveControlBus(commandPrefix(companyId, agentType));
990
1117
  await bus.pause(sessionId);
991
1118
  }
992
1119
  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);
1120
+ const bus = await resolveControlBus(commandPrefix(companyId, agentType));
996
1121
  await bus.resume(sessionId);
997
1122
  }
998
1123
  return { handle, kill, pause, resume };