@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/CHANGELOG.md +364 -0
- package/README.md +54 -0
- package/dist/adapters/index.d.ts +12 -2
- package/dist/adapters/index.js +10 -0
- package/dist/adapters/memory-control.d.ts +43 -0
- package/dist/adapters/memory-control.js +56 -0
- package/dist/adapters/memory-dedup.d.ts +36 -0
- package/dist/adapters/memory-dedup.js +59 -0
- package/dist/adapters/memory-lock.d.ts +45 -0
- package/dist/adapters/memory-lock.js +89 -0
- package/dist/adapters/redis-control.d.ts +2 -1
- package/dist/adapters/redis-lock.d.ts +9 -5
- package/dist/brain/anthropic.js +29 -4
- package/dist/brain/factory.js +55 -2
- package/dist/brain/gemini.d.ts +18 -2
- package/dist/brain/gemini.js +84 -12
- package/dist/brain/ollama.js +8 -2
- package/dist/brain/openai.js +9 -2
- package/dist/brain/retry.d.ts +98 -2
- package/dist/brain/retry.js +132 -2
- package/dist/brain/streaming.d.ts +80 -5
- package/dist/brain/streaming.js +81 -13
- package/dist/core/hooks.d.ts +14 -2
- package/dist/core/hooks.js +20 -3
- package/dist/core/loop.js +189 -25
- package/dist/core/types.d.ts +395 -4
- package/dist/core/types.js +90 -0
- package/dist/definition/parser.d.ts +3 -0
- package/dist/definition/parser.js +52 -1
- package/dist/definition/skills.d.ts +74 -0
- package/dist/definition/skills.js +119 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +28 -0
- package/dist/tools/dedup.d.ts +22 -1
- package/dist/tools/dedup.js +28 -0
- package/dist/tools/executor.d.ts +49 -5
- package/dist/tools/executor.js +38 -5
- package/package.json +4 -3
package/dist/core/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
492
|
-
*
|
|
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[];
|
package/dist/core/types.js
CHANGED
|
@@ -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
|
-
|
|
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)
|