@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.
@@ -142,7 +142,108 @@ export interface BrainRunInput {
142
142
  */
143
143
  export interface BrainChunk {
144
144
  text: string;
145
+ /**
146
+ * "Discard everything I have sent you; this text starts over." (ص٢/٢)
147
+ *
148
+ * Set on the FIRST chunk a retried attempt delivers, and on no other. ص١
149
+ * bought the display invariant — what reaches the reader is always a prefix
150
+ * of the payload — by muting the retry outright, which is honest but costs
151
+ * the tail of the answer: the display stops where the dead attempt stopped.
152
+ * This flag is what buys the same invariant WITHOUT that cost, because a
153
+ * reader who can clear what it drew has no prefix to contradict.
154
+ *
155
+ * **It never reaches a consumer that did not ask for it.** The sink has to
156
+ * declare itself with `acceptResets` (see `ResetAwareChunkSink`); everything
157
+ * else takes the ص١ path unchanged, byte for byte. That is deliberate: a
158
+ * consumer that appends chunks and ignores this field would render "The
159
+ * contract staThe contract states…" — the exact defect ب١/٢ opened — so the
160
+ * flag is delivered only where someone has said, in the type system, that
161
+ * they will act on it.
162
+ *
163
+ * `true` and absent are the only two states. There is no `reset: false`: a
164
+ * chunk either says "start over" or says nothing, and an optional literal
165
+ * makes the compiler enforce that rather than a code review.
166
+ */
167
+ reset?: true;
168
+ }
169
+ /**
170
+ * A chunk sink that has DECLARED it understands `BrainChunk.reset` (ص٢/٢).
171
+ *
172
+ * The declaration lives on the sink rather than in `BrainRunInput` or
173
+ * `RetryOpts`, and the reason is measured, not stylistic: the gate that decides
174
+ * to reset lives in `brain/retry.ts`, and the ONLY thing that crosses from a
175
+ * brain's input into that gate is the sink itself (`emitOnce(input.onChunk!)`).
176
+ * `RetryOpts` carries `signal` alone, and widening either it or `BrainRunInput`
177
+ * would mean editing all four brains to forward the new field. Attaching the
178
+ * declaration to the sink also makes it unmisplaceable: a consumer cannot
179
+ * declare reset support and then hand over a different callback.
180
+ *
181
+ * It also costs the replay harness nothing. `keyof BrainRunInput` does not
182
+ * change, so `InputKeysArePartitioned` in `@msm-core/replay` is untouched and
183
+ * no fingerprint moves — the ب١ precedent of a declared breach is not needed
184
+ * here, because there is no breach.
185
+ */
186
+ export interface ResetAwareChunkSink {
187
+ (chunk: BrainChunk): void;
188
+ /** The declaration itself. Read by `isResetAware`, never by a brain. */
189
+ readonly acceptsReset: true;
190
+ }
191
+ /**
192
+ * Declare that a chunk sink can handle `reset` — the opt-in, and the only way
193
+ * a `reset` chunk is ever produced.
194
+ *
195
+ * The contract you are signing: **when a chunk arrives with `reset: true`,
196
+ * throw away everything you have rendered for this call and start from that
197
+ * chunk.** Do that and the text you hold when the call returns is exactly the
198
+ * payload, with nothing missing and nothing shown twice.
199
+ *
200
+ * Returns a NEW function; the one you pass is not mutated.
201
+ */
202
+ export declare function acceptResets(sink: (chunk: BrainChunk) => void): ResetAwareChunkSink;
203
+ /** True for a sink that went through `acceptResets`. */
204
+ export declare function isResetAware(sink: unknown): sink is ResetAwareChunkSink;
205
+ /**
206
+ * An `AgentHooks.onChunk` that has DECLARED it understands `ChunkInfo.reset`
207
+ * (ص٣/٢) — the same opt-in as `ResetAwareChunkSink`, one layer out.
208
+ *
209
+ * ص٢ carried `reset` as far as a brain's chunk sink and stopped, because the
210
+ * loop's own sink drops everything but `text` into `fireChunk` and a declared
211
+ * loop sink would therefore have taken the flag and thrown it away — handing
212
+ * the hook consumer the duplicated display ب١/٢ exists to prevent. This type
213
+ * is the missing half: with it the loop can tell whether the person on the far
214
+ * end of `onChunk` will act on a reset, and it declares to the brain only when
215
+ * they will.
216
+ *
217
+ * **It is the same shape and the same marker as `ResetAwareChunkSink` on
218
+ * purpose.** A consumer who has learned the opt-in once has learned it in both
219
+ * places, and the loop's job stays a single question — did they declare? —
220
+ * rather than a translation between two conventions.
221
+ *
222
+ * Note that `AgentHooks.onChunk` needs no widening to accept this: a function
223
+ * carrying an extra property is already assignable to `(info: ChunkInfo) =>
224
+ * void`. The published signature of `AgentHooks` does not move.
225
+ */
226
+ export interface ResetAwareChunkHook {
227
+ (info: ChunkInfo): void;
228
+ /** The declaration itself. Read by `isResetAwareHook`, never by a brain. */
229
+ readonly acceptsReset: true;
145
230
  }
231
+ /**
232
+ * Declare that an `onChunk` hook can handle `ChunkInfo.reset` — the opt-in,
233
+ * and the only way a `reset` ever reaches a hook consumer.
234
+ *
235
+ * The contract you are signing: **when a `ChunkInfo` arrives with
236
+ * `reset: true`, throw away every chunk you have rendered for this step and
237
+ * start again from that one.** Do that and the text you hold at the end of the
238
+ * step is exactly the answer, with nothing missing and nothing shown twice.
239
+ * Ignore it and you would render the retried answer glued onto the truncated
240
+ * one — which is why the flag is delivered nowhere else.
241
+ *
242
+ * Returns a NEW function; the one you pass is not mutated.
243
+ */
244
+ export declare function acceptChunkResets(hook: (info: ChunkInfo) => void): ResetAwareChunkHook;
245
+ /** True for an `onChunk` hook that went through `acceptChunkResets`. */
246
+ export declare function isResetAwareHook(hook: unknown): hook is ResetAwareChunkHook;
146
247
  /**
147
248
  * One tool invocation a model asked for, in the model's own order.
148
249
  *
@@ -237,8 +338,73 @@ export interface BrainPayload {
237
338
  inputTokens?: number;
238
339
  outputTokens?: number;
239
340
  };
341
+ /**
342
+ * **Who actually answered** — the model as the PROVIDER's own reply named it.
343
+ *
344
+ * Not the model that was asked for. The two differ more often than they look
345
+ * like they should: a host swaps the brain under an agent definition at
346
+ * composition time (an `llmOverride`), a gateway routes a request elsewhere,
347
+ * or a provider resolves an alias to a dated snapshot. In every one of those
348
+ * cases the configured name is what the request *intended* and this is what
349
+ * the request *got*, and only the second can answer "which model wrote this?"
350
+ * about a reply already sent.
351
+ *
352
+ * The loop copies it into `model_response.data.model` in the session log,
353
+ * beside `model_request.data.model`, which keeps its own meaning — the
354
+ * requested/configured name. Two fields, two questions, neither guessing.
355
+ *
356
+ * **Optional, and absent means absent.** A brain that cannot know (a scripted
357
+ * one, or a provider whose reply does not name a model) leaves it off, and
358
+ * the log carries no `model` on that response rather than a plausible
359
+ * fabrication. Anything derived from the configured name would be exactly the
360
+ * falsehood this field exists to stop telling.
361
+ */
362
+ model?: string;
363
+ /**
364
+ * The model's THINKING for this call, separated from its answer (ت١).
365
+ *
366
+ * Filled by a brain whose provider hands reasoning back tagged as such —
367
+ * Gemini's parts marked `thought: true`, Claude's `thinking` blocks — joined
368
+ * in emitted order and kept OUT of `generation.response_text`. Before this
369
+ * field the Gemini brain took the first text part as the answer, and with
370
+ * thinking on, the first text part was the reasoning: the user read the
371
+ * model's notes instead of its reply.
372
+ *
373
+ * **Optional, and absent means absent.** A call with no thinking carries no
374
+ * key at all — never `""` — so `"thoughts" in payload` is the honest test.
375
+ * The loop copies the LAST step's value to `LoopOutcome.thoughts` and does
376
+ * not write it to the session log: it is an output the model never sees
377
+ * again, so the rule "what the model sees is recorded" does not reach it.
378
+ */
379
+ thoughts?: string;
240
380
  [key: string]: unknown;
241
381
  }
382
+ /**
383
+ * The responder's name lifted off a raw provider reply — `{}` when it named none.
384
+ *
385
+ * Spread into a `BrainPayload` by all four bundled brains, so the reading exists
386
+ * once. Four copies of the same check in four files is four chances to drift,
387
+ * and the one difference between them (Gemini spells the field `modelVersion`;
388
+ * OpenAI, Anthropic and Ollama spell it `model`) is a fact about wire formats
389
+ * that belongs written down, not re-derived per file.
390
+ *
391
+ * **Why the parameter is `unknown`, and why there is no cast anyway.** The
392
+ * declared shape of each provider response in `brain/streaming.ts` is exactly
393
+ * the set of fields its brain reads — by design, stated there — and that set
394
+ * does not include the model name. A parameter typed `{ model?: unknown }`
395
+ * would therefore be rejected for every real argument: TypeScript's weak-type
396
+ * check refuses an object that shares no property with an all-optional target,
397
+ * which is precisely the situation here. So this takes what it is really given
398
+ * — an object off a wire — and narrows it at runtime with `typeof` and `in`.
399
+ * Nothing is asserted about it that has not just been tested.
400
+ *
401
+ * A reply that carries no name, or a non-string one, yields `{}`: the payload
402
+ * gets no `model` key, and the log records the absence honestly instead of
403
+ * inventing a name from the request.
404
+ */
405
+ export declare function respondingModel(reply: unknown): {
406
+ model?: string;
407
+ };
242
408
  /** The Brain interface — any model that implements this works with msm-mini */
243
409
  export interface Brain {
244
410
  name: string;
@@ -331,6 +497,21 @@ export interface LoopOutcome {
331
497
  terminatedBy?: GuardSignalType;
332
498
  /** Output-gate result, when a validator is configured. */
333
499
  validation?: OutputValidation;
500
+ /**
501
+ * The thinking behind the answer, when the last model step reported any
502
+ * (`BrainPayload.thoughts`, ت١). Present only then — a run whose brain never
503
+ * separated thinking carries no key, exactly as before the field existed.
504
+ *
505
+ * **Ungated audit material — not for an end user without the host's
506
+ * filter** (ت١/٢ item 3). The output gate validates `text` and never reads
507
+ * this field. It is DROPPED when the gate blocks the answer (`type:
508
+ * "suppressed"`) — what could not go out as an answer does not go out as
509
+ * notes — and on every other verdict it passes as the model wrote it,
510
+ * including when the gate never ran because the text was empty. It also
511
+ * accompanies only text the model wrote: a guard exit that falls back to
512
+ * the canned "unable to complete" line carries no `thoughts`.
513
+ */
514
+ thoughts?: string;
334
515
  error?: string;
335
516
  }
336
517
  /**
@@ -365,7 +546,9 @@ export interface SessionMetadata {
365
546
  *
366
547
  * The loop talks to this interface and never to a concrete store. Inject an
367
548
  * implementation via `AgentConfig.memory`; when it is omitted the loop builds
368
- * the bundled `RedisMemory` from `AgentConfig.redis` exactly as it always has.
549
+ * the bundled `RedisMemory` from `AgentConfig.redis` exactly as it always has
550
+ * and when `redis` is omitted too, `createAgent` refuses the composition by
551
+ * name rather than failing on the first history read (ص٤).
369
552
  *
370
553
  * Six functions — the surface `RedisMemory` already had, at its current
371
554
  * signatures. Nothing is added speculatively: an append-only event log is a
@@ -381,6 +564,103 @@ export interface SessionStore {
381
564
  getDocumentState(sessionId: string): Promise<DocumentState | null>;
382
565
  setDocumentState(sessionId: string, state: DocumentState): Promise<void>;
383
566
  }
567
+ /**
568
+ * The control bus PORT — out-of-band commands for a running session.
569
+ *
570
+ * One string per session, read once at the top of every loop iteration, before
571
+ * the model is called. The **command vocabulary is part of this contract**,
572
+ * because the loop interprets it (`checkGuards`, `RedisControlBus.disabledTool`):
573
+ *
574
+ * - `null` — no command; the run proceeds.
575
+ * - `"kill"` / `"kill:{reason}"` — hard abort, `terminatedBy: "task_killed"`.
576
+ * - `"pause"` — hard abort, `terminatedBy: "tenant_paused"`.
577
+ * - `"disabled:{tool}"` — that one tool is refused for the step; its
578
+ * siblings run.
579
+ *
580
+ * A verb is matched on the part before the first `:`, so an unknown verb is
581
+ * simply not a command anyone acts on — an implementation may carry its own
582
+ * without breaking the loop.
583
+ */
584
+ export interface ControlBusPort {
585
+ /** The current command for this session, or `null` when there is none. */
586
+ getCommand(sessionId: string): Promise<string | null>;
587
+ /** Write a kill command. `reason` rides along in the guard's message. */
588
+ kill(sessionId: string, reason?: string): Promise<void>;
589
+ pause(sessionId: string): Promise<void>;
590
+ /** Clear a pause. A `kill` is deliberately NOT cleared by this. */
591
+ resume(sessionId: string): Promise<void>;
592
+ }
593
+ /**
594
+ * A held session lock. `release()` is called on every exit path of the loop —
595
+ * normal, guard-terminated and error — and `extend()` is for a holder that
596
+ * outlives its own TTL.
597
+ *
598
+ * Declared here rather than in the Redis adapter for the same reason
599
+ * `SessionMetadata` moved in س١: it is part of the contract every lock
600
+ * implements, not a detail of one of them. `adapters/redis-lock.ts` re-exports
601
+ * it, so every existing import keeps working unchanged.
602
+ */
603
+ export interface LockHandle {
604
+ release(): Promise<void>;
605
+ extend(ttlMs: number): Promise<boolean>;
606
+ }
607
+ /**
608
+ * The session run-lock PORT — one turn per session at a time.
609
+ *
610
+ * **Named `RunLockPort`, not `LockPort`, deliberately.** `@msm-core/jobs` has
611
+ * exported a `LockPort` since long before this existed and it is a different
612
+ * contract entirely (`acquire(key, ttlMs): Promise<boolean>` — no handle, no
613
+ * release); nisus imports it today. Two packages of one SDK exporting one name
614
+ * for two contracts is a collision a consumer pays for in aliases forever, and
615
+ * it costs nothing to avoid before the first publish. The name is also the
616
+ * truer one: what this guards is a session's RUN, one turn at a time.
617
+ *
618
+ * The loop acquires before it does anything with the session and releases on
619
+ * every way out. This is the port whose in-RAM implementation has to be a real
620
+ * mutex and not a stub: a lock that always says yes turns "two workers cannot
621
+ * enter the same session" into a comment, and the failure it prevents (two
622
+ * turns interleaving their writes into one log) is silent when it happens.
623
+ *
624
+ * The loop calls only `acquireWithRetry` — waiting for a busy session is the
625
+ * behaviour it wants, so that is the surface extracted. Implementations are
626
+ * free to expose a non-blocking `acquire` beside it; the port does not require
627
+ * one because nothing in the loop asks for it.
628
+ */
629
+ export interface RunLockPort {
630
+ /**
631
+ * Acquire, retrying until `waitMs` elapses. Throws when it cannot.
632
+ *
633
+ * @param ttlMs lifetime of the lock — the loop passes the effective
634
+ * turn timeout plus a safety margin, so a lock never
635
+ * expires under a run that is still going.
636
+ * @param waitMs how long to keep trying (adapter default: 5000).
637
+ * @param retryInterval base delay between attempts (adapter default: 100).
638
+ */
639
+ acquireWithRetry(sessionId: string, ttlMs: number, waitMs?: number, retryInterval?: number): Promise<LockHandle>;
640
+ }
641
+ /**
642
+ * The tool-dedup PORT — idempotency for repeated tool calls within a session.
643
+ *
644
+ * Keyed by `(sessionId, hash)`, where the hash is `hashToolCall(name, args)`:
645
+ * a deterministic SHA-256 of the tool name and its key-sorted arguments. The
646
+ * hashing stays a pure function in `tools/dedup.ts` — it is not I/O and there
647
+ * is nothing to swap about it — so an implementation of this port is only ever
648
+ * asked to store and fetch by a key it is handed.
649
+ *
650
+ * **Only `ok` results are ever stored** (the executor's rule, not the port's):
651
+ * a failure is worth retrying, and caching one would freeze a transient outage
652
+ * into the session for the whole TTL.
653
+ */
654
+ export interface DedupPort {
655
+ /** A previously stored result for this exact call, or `null`. */
656
+ check(sessionId: string, hash: string): Promise<ToolResult | null>;
657
+ /**
658
+ * Remember a result for `ttlSeconds`. An implementation with no notion of
659
+ * expiry may ignore the TTL — it is a hint about staleness, and the loop
660
+ * passes `redis.ttl.toolDedup ?? 300` for it.
661
+ */
662
+ store(sessionId: string, hash: string, result: ToolResult, ttlSeconds: number): Promise<void>;
663
+ }
384
664
  /**
385
665
  * What a compactor decides when it decides to compact.
386
666
  *
@@ -480,7 +760,34 @@ export interface AgentConfig {
480
760
  /** Path to .md definition file or pre-parsed AgentDefinition object */
481
761
  definition: string | AgentDefinition;
482
762
  brain: Brain;
483
- redis: RedisConfig;
763
+ /**
764
+ * Redis connection for the four ports Redis can back: session `memory`, the
765
+ * `controlBus`, the run `lock` and tool `dedup`.
766
+ *
767
+ * **Optional (ص٤) — and only because س٦ earned it.** While the trio was
768
+ * `new`-ed inside the loop, `redis` was load-bearing for every run and being
769
+ * required was simply true. س٦ made all four injectable and proved a whole
770
+ * turn runs without opening a connection; the field stayed required after
771
+ * that for no reason but the calendar, so a fully-injected caller had to hand
772
+ * over an object it never used. The rule that replaces the requirement:
773
+ *
774
+ * - **Present** — nothing changes, for anyone. Every port that is not
775
+ * injected is built from this exactly as it always was: same
776
+ * `{prefix}:{companyId}:{agentType}` scoping, same key shapes, same TTLs,
777
+ * same moment of connection. `{}` is *present*, not absent: it has
778
+ * neither `url` nor `client`, so the first port that reaches for it
779
+ * throws as it always has — which is precisely what makes it useful as a
780
+ * test config that fails loudly if anything opens Redis.
781
+ * - **Absent** — every one of the four ports MUST be injected, because
782
+ * there is nothing left to fall back to. A composition that omits one
783
+ * is rejected by `createAgent` with an error naming the missing field;
784
+ * see the check at the top of `createAgent`.
785
+ *
786
+ * Absence is read from the value (`undefined`), not from the key, so a host
787
+ * that spreads an options object with `redis: undefined` in it gets the same
788
+ * named composition error rather than a crash inside a turn.
789
+ */
790
+ redis?: RedisConfig;
484
791
  /**
485
792
  * Optional session-memory port. When injected, the loop routes ALL history /
486
793
  * metadata / document-state access through it and never constructs a
@@ -488,10 +795,61 @@ export interface AgentConfig {
488
795
  * `redis` exactly as before — same prefix, same TTLs, same tenant scoping —
489
796
  * so every existing consumer is unaffected.
490
797
  *
491
- * `redis` stays required either way: the control bus, the session lock and
492
- * tool dedup are separate ports and still ride on it.
798
+ * Isolation is the injector's (س١ ruling 3): this port takes a `sessionId`
799
+ * and knows nothing of tenants. Whoever injects a store injects its tenant
800
+ * scoping with it.
801
+ *
802
+ * Omitting this **and** `redis` is a composition error, raised at
803
+ * `createAgent` (ص٤) — not a run that fails on its first history read.
493
804
  */
494
805
  memory?: SessionStore;
806
+ /**
807
+ * Optional **control-bus port** — kill / pause / disable-tool commands.
808
+ *
809
+ * Injected, the loop reads its commands and `agent.kill/pause/resume()` write
810
+ * through it. Omitted, the loop builds `RedisControlBus` from `redis` exactly
811
+ * as before: same `{prefix}:{companyId}:{agentType}` tenant scoping, same
812
+ * `:agent:control:{sessionId}` key, same 7-day TTL on a kill.
813
+ *
814
+ * Isolation is the injector's, as with `memory`. Note in particular that
815
+ * `agent.kill(sessionId, companyId, agentType)` computes a tenant-scoped
816
+ * prefix for the FALLBACK bus only — an injected bus receives the session id
817
+ * and nothing else, and must scope its own keys.
818
+ *
819
+ * Omitting this **and** `redis` is a composition error, raised at
820
+ * `createAgent` (ص٤).
821
+ */
822
+ controlBus?: ControlBusPort;
823
+ /**
824
+ * Optional **session-lock port** — one turn per session at a time.
825
+ *
826
+ * Omitted, the loop builds `RedisDistributedLock` from `redis` exactly as
827
+ * before (`SET NX PX` on `{prefix}:session:{id}:lock`, released by token).
828
+ *
829
+ * An injected lock must actually exclude, or it is not a lock. In-process
830
+ * (`InMemoryLock`) that means one process; across replicas it means a shared
831
+ * one. Isolation is the injector's here too: two tenants that share one
832
+ * injected lock share its key space, and a session id colliding across them
833
+ * will serialise turns that have nothing to do with each other.
834
+ *
835
+ * Omitting this **and** `redis` is a composition error, raised at
836
+ * `createAgent` (ص٤).
837
+ */
838
+ lock?: RunLockPort;
839
+ /**
840
+ * Optional **tool-dedup port** — idempotency for repeated tool calls.
841
+ *
842
+ * Omitted, the loop builds the Redis-backed dedup from `redis` exactly as
843
+ * before: `{prefix}:session:{id}:tools:dedup` as a hash, the call hash as the
844
+ * field, `EXPIRE` refreshed on every store.
845
+ *
846
+ * Isolation is the injector's: a dedup shared unscoped between tenants can
847
+ * serve one tenant's cached tool result to another's identical call.
848
+ *
849
+ * Omitting this **and** `redis` is a composition error, raised at
850
+ * `createAgent` (ص٤).
851
+ */
852
+ dedup?: DedupPort;
495
853
  /**
496
854
  * Optional session **event log** (`@msm-core/session`). Injecting it inverts
497
855
  * where conversation context comes from:
@@ -590,6 +948,25 @@ export interface ChunkInfo {
590
948
  /** The delta — not the running total. Concatenating every chunk of a step
591
949
  * yields the text that step's payload carries. */
592
950
  text: string;
951
+ /**
952
+ * "Discard everything I have sent you for this step; it starts over." (ص٣/٢)
953
+ *
954
+ * The hook-side face of `BrainChunk.reset`. Set on the FIRST chunk a retried
955
+ * attempt delivers, and on no other, so a consumer that clears and restarts
956
+ * on it ends the step holding exactly the answer — no truncation from the
957
+ * attempt that died, no duplication from the one that succeeded.
958
+ *
959
+ * **It never reaches a consumer that did not ask for it.** The hook must go
960
+ * through `acceptChunkResets` (see `ResetAwareChunkHook`); for every other
961
+ * hook the loop hands the brain an undeclared sink, `brain/retry.ts` takes
962
+ * the ص١ mute path, and the object delivered here carries the same three
963
+ * keys it carried before this field existed — the key is not merely
964
+ * `undefined`, it is absent. That is deliberate: a consumer that appends
965
+ * chunks and ignores this field would render the answer twice over.
966
+ *
967
+ * `true` and absent are the only two states, as on `BrainChunk`.
968
+ */
969
+ reset?: true;
593
970
  }
594
971
  /**
595
972
  * Return value from onBeforeTool hook:
@@ -657,6 +1034,20 @@ export interface AgentDefinition {
657
1034
  model: string;
658
1035
  endpoint?: string;
659
1036
  apiKey?: string;
1037
+ /**
1038
+ * Thinking, as `## Brain` spells it (ت١/٢): `thinking.budget` and
1039
+ * `thinking.includeThoughts` — flat dotted keys, parsed into this object;
1040
+ * neither key → no `thinking` at all. `buildBrain` hands it to
1041
+ * `createGeminiBrain({ thinking })` after checking its shape (an object;
1042
+ * `budget` a finite number ≥ 0 when given; `includeThoughts` a boolean
1043
+ * when given — anything else is a named error at composition, not
1044
+ * silence). Only the Gemini brain consumes it in this release; the other
1045
+ * providers do not read the key. An empty object is absence.
1046
+ */
1047
+ thinking?: {
1048
+ budget?: number;
1049
+ includeThoughts?: boolean;
1050
+ };
660
1051
  [key: string]: unknown;
661
1052
  };
662
1053
  capabilities: string[];
@@ -4,6 +4,63 @@
4
4
  * All contracts for the lite agent runtime. Brain-agnostic, zero embedded
5
5
  * databases. Application layer owns persistence; agent owns the loop.
6
6
  */
7
+ /**
8
+ * Declare that a chunk sink can handle `reset` — the opt-in, and the only way
9
+ * a `reset` chunk is ever produced.
10
+ *
11
+ * The contract you are signing: **when a chunk arrives with `reset: true`,
12
+ * throw away everything you have rendered for this call and start from that
13
+ * chunk.** Do that and the text you hold when the call returns is exactly the
14
+ * payload, with nothing missing and nothing shown twice.
15
+ *
16
+ * Returns a NEW function; the one you pass is not mutated.
17
+ */
18
+ export function acceptResets(sink) {
19
+ const declared = (chunk) => {
20
+ sink(chunk);
21
+ };
22
+ return Object.assign(declared, { acceptsReset: true });
23
+ }
24
+ /**
25
+ * The declaration marker, read in ONE place (ص٣/٢).
26
+ *
27
+ * There are two public predicates over this marker — one for a brain's chunk
28
+ * sink, one for the `AgentHooks.onChunk` a person writes — and س١'s rule is
29
+ * that two lookalikes drift apart in silence. They share this body so there is
30
+ * nothing to drift: the marker `acceptResets` and `acceptChunkResets` stamp is
31
+ * the same marker, and it is recognised the same way or not at all.
32
+ */
33
+ function declaresResets(value) {
34
+ return (typeof value === "function" &&
35
+ value.acceptsReset === true);
36
+ }
37
+ /** True for a sink that went through `acceptResets`. */
38
+ export function isResetAware(sink) {
39
+ return declaresResets(sink);
40
+ }
41
+ /**
42
+ * Declare that an `onChunk` hook can handle `ChunkInfo.reset` — the opt-in,
43
+ * and the only way a `reset` ever reaches a hook consumer.
44
+ *
45
+ * The contract you are signing: **when a `ChunkInfo` arrives with
46
+ * `reset: true`, throw away every chunk you have rendered for this step and
47
+ * start again from that one.** Do that and the text you hold at the end of the
48
+ * step is exactly the answer, with nothing missing and nothing shown twice.
49
+ * Ignore it and you would render the retried answer glued onto the truncated
50
+ * one — which is why the flag is delivered nowhere else.
51
+ *
52
+ * Returns a NEW function; the one you pass is not mutated.
53
+ */
54
+ export function acceptChunkResets(hook) {
55
+ const declared = (info) => {
56
+ hook(info);
57
+ };
58
+ return Object.assign(declared, { acceptsReset: true });
59
+ }
60
+ /** True for an `onChunk` hook that went through `acceptChunkResets`. */
61
+ export function isResetAwareHook(hook) {
62
+ return declaresResets(hook);
63
+ }
7
64
  /**
8
65
  * Every action the loop DISPATCHES ON — declared as data, and the only list.
9
66
  *
@@ -44,3 +101,36 @@ export const BRAIN_ACTIONS = [
44
101
  "clarify",
45
102
  "escalate",
46
103
  ];
104
+ /**
105
+ * The responder's name lifted off a raw provider reply — `{}` when it named none.
106
+ *
107
+ * Spread into a `BrainPayload` by all four bundled brains, so the reading exists
108
+ * once. Four copies of the same check in four files is four chances to drift,
109
+ * and the one difference between them (Gemini spells the field `modelVersion`;
110
+ * OpenAI, Anthropic and Ollama spell it `model`) is a fact about wire formats
111
+ * that belongs written down, not re-derived per file.
112
+ *
113
+ * **Why the parameter is `unknown`, and why there is no cast anyway.** The
114
+ * declared shape of each provider response in `brain/streaming.ts` is exactly
115
+ * the set of fields its brain reads — by design, stated there — and that set
116
+ * does not include the model name. A parameter typed `{ model?: unknown }`
117
+ * would therefore be rejected for every real argument: TypeScript's weak-type
118
+ * check refuses an object that shares no property with an all-optional target,
119
+ * which is precisely the situation here. So this takes what it is really given
120
+ * — an object off a wire — and narrows it at runtime with `typeof` and `in`.
121
+ * Nothing is asserted about it that has not just been tested.
122
+ *
123
+ * A reply that carries no name, or a non-string one, yields `{}`: the payload
124
+ * gets no `model` key, and the log records the absence honestly instead of
125
+ * inventing a name from the request.
126
+ */
127
+ export function respondingModel(reply) {
128
+ if (typeof reply !== "object" || reply === null)
129
+ return {};
130
+ const named = "model" in reply
131
+ ? reply.model
132
+ : "modelVersion" in reply
133
+ ? reply.modelVersion
134
+ : undefined;
135
+ return typeof named === "string" && named.length > 0 ? { model: named } : {};
136
+ }
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Supported sections: Persona, Brain, Capabilities, Limits, Sections
5
5
  * Stripped: Equipment, Skills, Hours, Memory rules (owned by the app layer)
6
+ *
7
+ * `## Brain` additionally reads `thinking.budget` and `thinking.includeThoughts`
8
+ * (flat dotted keys) into `brain.thinking` (ت١/٢).
6
9
  */
7
10
  import type { AgentDefinition } from "../core/types.js";
8
11
  /**
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Supported sections: Persona, Brain, Capabilities, Limits, Sections
5
5
  * Stripped: Equipment, Skills, Hours, Memory rules (owned by the app layer)
6
+ *
7
+ * `## Brain` additionally reads `thinking.budget` and `thinking.includeThoughts`
8
+ * (flat dotted keys) into `brain.thinking` (ت١/٢).
6
9
  */
7
10
  import { readFileSync } from "fs";
8
11
  /**
@@ -34,6 +37,7 @@ function parseMd(md) {
34
37
  const personaName = extractKey(persona, "name") ?? extractKey(persona, "Name");
35
38
  const personaStyle = extractKey(persona, "style") ?? extractKey(persona, "Style");
36
39
  const brainEndpoint = brain["endpoint"] ?? brain["Endpoint"];
40
+ const brainThinking = parseBrainThinking(brain);
37
41
  const domain = meta["domain"] ?? meta["Domain"];
38
42
  const language = meta["language"] ?? meta["Language"];
39
43
  return {
@@ -50,6 +54,7 @@ function parseMd(md) {
50
54
  "openai"),
51
55
  model: brain["model"] ?? brain["Model"] ?? "gpt-4o-mini",
52
56
  ...(brainEndpoint !== undefined ? { endpoint: brainEndpoint } : {}),
57
+ ...(brainThinking !== undefined ? { thinking: brainThinking } : {}),
53
58
  },
54
59
  capabilities,
55
60
  limits: {
@@ -107,8 +112,54 @@ function parseSection(lines, sectionName) {
107
112
  }
108
113
  return result;
109
114
  }
115
+ /**
116
+ * `## Brain` accepts DOTTED keys (`thinking.budget: 1024`) on top of the plain
117
+ * ones every section accepts — that is how a nested option is written flat in
118
+ * a definition file (ت١/٢). The other sections keep the plain-key grammar; a
119
+ * dotted line there is ignored exactly as it was.
120
+ */
110
121
  function parseBrainSection(lines) {
111
- return parseSection(lines, "Brain");
122
+ const result = {};
123
+ for (const line of getSectionLines(lines, "Brain")) {
124
+ const m = /^([A-Za-z]+(?:\.[A-Za-z]+)*)\s*:\s*(.+)$/.exec(line.trim());
125
+ if (m && m[1] && m[2])
126
+ result[m[1]] = m[2].trim();
127
+ }
128
+ return result;
129
+ }
130
+ /**
131
+ * `thinking.budget` / `thinking.includeThoughts` → `brain.thinking`, or
132
+ * `undefined` when neither key was written (then the definition has no
133
+ * `thinking` key at all, and `buildBrain` composes yesterday's brain).
134
+ *
135
+ * A value that does not parse is a NAMED error, not a fallback: `limits` may
136
+ * fall back to a default because a default exists, but there is no default
137
+ * thinking budget to fall back to, and a typo that silently switched the
138
+ * feature off is the silence this key was added to end. `budget` must be a
139
+ * finite number ≥ 0 (`0` is a value — thinking OFF); `includeThoughts` must
140
+ * be `true` or `false`.
141
+ */
142
+ function parseBrainThinking(brain) {
143
+ const rawBudget = brain["thinking.budget"] ?? brain["Thinking.budget"];
144
+ const rawInclude = brain["thinking.includeThoughts"] ?? brain["Thinking.includeThoughts"];
145
+ if (rawBudget === undefined && rawInclude === undefined)
146
+ return undefined;
147
+ const thinking = {};
148
+ if (rawBudget !== undefined) {
149
+ const budget = Number(rawBudget);
150
+ if (!Number.isFinite(budget) || budget < 0) {
151
+ throw new Error(`msm-mini: ## Brain thinking.budget must be a finite number >= 0, got "${rawBudget}"`);
152
+ }
153
+ thinking.budget = budget;
154
+ }
155
+ if (rawInclude !== undefined) {
156
+ const lowered = rawInclude.toLowerCase();
157
+ if (lowered !== "true" && lowered !== "false") {
158
+ throw new Error(`msm-mini: ## Brain thinking.includeThoughts must be true or false, got "${rawInclude}"`);
159
+ }
160
+ thinking.includeThoughts = lowered === "true";
161
+ }
162
+ return thinking;
112
163
  }
113
164
  function parseListSection(lines, sectionName) {
114
165
  return getSectionLines(lines, sectionName)