@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.
- package/CHANGELOG.md +279 -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 +8 -1
- package/dist/brain/gemini.js +15 -2
- 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 +42 -0
- package/dist/brain/streaming.js +44 -1
- package/dist/core/hooks.d.ts +14 -2
- package/dist/core/hooks.js +20 -3
- package/dist/core/loop.js +148 -23
- package/dist/core/types.d.ts +349 -4
- package/dist/core/types.js +90 -0
- package/dist/definition/skills.d.ts +74 -0
- package/dist/definition/skills.js +119 -0
- package/dist/index.d.ts +5 -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,56 @@ 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;
|
|
240
363
|
[key: string]: unknown;
|
|
241
364
|
}
|
|
365
|
+
/**
|
|
366
|
+
* The responder's name lifted off a raw provider reply — `{}` when it named none.
|
|
367
|
+
*
|
|
368
|
+
* Spread into a `BrainPayload` by all four bundled brains, so the reading exists
|
|
369
|
+
* once. Four copies of the same check in four files is four chances to drift,
|
|
370
|
+
* and the one difference between them (Gemini spells the field `modelVersion`;
|
|
371
|
+
* OpenAI, Anthropic and Ollama spell it `model`) is a fact about wire formats
|
|
372
|
+
* that belongs written down, not re-derived per file.
|
|
373
|
+
*
|
|
374
|
+
* **Why the parameter is `unknown`, and why there is no cast anyway.** The
|
|
375
|
+
* declared shape of each provider response in `brain/streaming.ts` is exactly
|
|
376
|
+
* the set of fields its brain reads — by design, stated there — and that set
|
|
377
|
+
* does not include the model name. A parameter typed `{ model?: unknown }`
|
|
378
|
+
* would therefore be rejected for every real argument: TypeScript's weak-type
|
|
379
|
+
* check refuses an object that shares no property with an all-optional target,
|
|
380
|
+
* which is precisely the situation here. So this takes what it is really given
|
|
381
|
+
* — an object off a wire — and narrows it at runtime with `typeof` and `in`.
|
|
382
|
+
* Nothing is asserted about it that has not just been tested.
|
|
383
|
+
*
|
|
384
|
+
* A reply that carries no name, or a non-string one, yields `{}`: the payload
|
|
385
|
+
* gets no `model` key, and the log records the absence honestly instead of
|
|
386
|
+
* inventing a name from the request.
|
|
387
|
+
*/
|
|
388
|
+
export declare function respondingModel(reply: unknown): {
|
|
389
|
+
model?: string;
|
|
390
|
+
};
|
|
242
391
|
/** The Brain interface — any model that implements this works with msm-mini */
|
|
243
392
|
export interface Brain {
|
|
244
393
|
name: string;
|
|
@@ -365,7 +514,9 @@ export interface SessionMetadata {
|
|
|
365
514
|
*
|
|
366
515
|
* The loop talks to this interface and never to a concrete store. Inject an
|
|
367
516
|
* implementation via `AgentConfig.memory`; when it is omitted the loop builds
|
|
368
|
-
* the bundled `RedisMemory` from `AgentConfig.redis` exactly as it always has
|
|
517
|
+
* the bundled `RedisMemory` from `AgentConfig.redis` exactly as it always has —
|
|
518
|
+
* and when `redis` is omitted too, `createAgent` refuses the composition by
|
|
519
|
+
* name rather than failing on the first history read (ص٤).
|
|
369
520
|
*
|
|
370
521
|
* Six functions — the surface `RedisMemory` already had, at its current
|
|
371
522
|
* signatures. Nothing is added speculatively: an append-only event log is a
|
|
@@ -381,6 +532,103 @@ export interface SessionStore {
|
|
|
381
532
|
getDocumentState(sessionId: string): Promise<DocumentState | null>;
|
|
382
533
|
setDocumentState(sessionId: string, state: DocumentState): Promise<void>;
|
|
383
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* The control bus PORT — out-of-band commands for a running session.
|
|
537
|
+
*
|
|
538
|
+
* One string per session, read once at the top of every loop iteration, before
|
|
539
|
+
* the model is called. The **command vocabulary is part of this contract**,
|
|
540
|
+
* because the loop interprets it (`checkGuards`, `RedisControlBus.disabledTool`):
|
|
541
|
+
*
|
|
542
|
+
* - `null` — no command; the run proceeds.
|
|
543
|
+
* - `"kill"` / `"kill:{reason}"` — hard abort, `terminatedBy: "task_killed"`.
|
|
544
|
+
* - `"pause"` — hard abort, `terminatedBy: "tenant_paused"`.
|
|
545
|
+
* - `"disabled:{tool}"` — that one tool is refused for the step; its
|
|
546
|
+
* siblings run.
|
|
547
|
+
*
|
|
548
|
+
* A verb is matched on the part before the first `:`, so an unknown verb is
|
|
549
|
+
* simply not a command anyone acts on — an implementation may carry its own
|
|
550
|
+
* without breaking the loop.
|
|
551
|
+
*/
|
|
552
|
+
export interface ControlBusPort {
|
|
553
|
+
/** The current command for this session, or `null` when there is none. */
|
|
554
|
+
getCommand(sessionId: string): Promise<string | null>;
|
|
555
|
+
/** Write a kill command. `reason` rides along in the guard's message. */
|
|
556
|
+
kill(sessionId: string, reason?: string): Promise<void>;
|
|
557
|
+
pause(sessionId: string): Promise<void>;
|
|
558
|
+
/** Clear a pause. A `kill` is deliberately NOT cleared by this. */
|
|
559
|
+
resume(sessionId: string): Promise<void>;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* A held session lock. `release()` is called on every exit path of the loop —
|
|
563
|
+
* normal, guard-terminated and error — and `extend()` is for a holder that
|
|
564
|
+
* outlives its own TTL.
|
|
565
|
+
*
|
|
566
|
+
* Declared here rather than in the Redis adapter for the same reason
|
|
567
|
+
* `SessionMetadata` moved in س١: it is part of the contract every lock
|
|
568
|
+
* implements, not a detail of one of them. `adapters/redis-lock.ts` re-exports
|
|
569
|
+
* it, so every existing import keeps working unchanged.
|
|
570
|
+
*/
|
|
571
|
+
export interface LockHandle {
|
|
572
|
+
release(): Promise<void>;
|
|
573
|
+
extend(ttlMs: number): Promise<boolean>;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* The session run-lock PORT — one turn per session at a time.
|
|
577
|
+
*
|
|
578
|
+
* **Named `RunLockPort`, not `LockPort`, deliberately.** `@msm-core/jobs` has
|
|
579
|
+
* exported a `LockPort` since long before this existed and it is a different
|
|
580
|
+
* contract entirely (`acquire(key, ttlMs): Promise<boolean>` — no handle, no
|
|
581
|
+
* release); nisus imports it today. Two packages of one SDK exporting one name
|
|
582
|
+
* for two contracts is a collision a consumer pays for in aliases forever, and
|
|
583
|
+
* it costs nothing to avoid before the first publish. The name is also the
|
|
584
|
+
* truer one: what this guards is a session's RUN, one turn at a time.
|
|
585
|
+
*
|
|
586
|
+
* The loop acquires before it does anything with the session and releases on
|
|
587
|
+
* every way out. This is the port whose in-RAM implementation has to be a real
|
|
588
|
+
* mutex and not a stub: a lock that always says yes turns "two workers cannot
|
|
589
|
+
* enter the same session" into a comment, and the failure it prevents (two
|
|
590
|
+
* turns interleaving their writes into one log) is silent when it happens.
|
|
591
|
+
*
|
|
592
|
+
* The loop calls only `acquireWithRetry` — waiting for a busy session is the
|
|
593
|
+
* behaviour it wants, so that is the surface extracted. Implementations are
|
|
594
|
+
* free to expose a non-blocking `acquire` beside it; the port does not require
|
|
595
|
+
* one because nothing in the loop asks for it.
|
|
596
|
+
*/
|
|
597
|
+
export interface RunLockPort {
|
|
598
|
+
/**
|
|
599
|
+
* Acquire, retrying until `waitMs` elapses. Throws when it cannot.
|
|
600
|
+
*
|
|
601
|
+
* @param ttlMs lifetime of the lock — the loop passes the effective
|
|
602
|
+
* turn timeout plus a safety margin, so a lock never
|
|
603
|
+
* expires under a run that is still going.
|
|
604
|
+
* @param waitMs how long to keep trying (adapter default: 5000).
|
|
605
|
+
* @param retryInterval base delay between attempts (adapter default: 100).
|
|
606
|
+
*/
|
|
607
|
+
acquireWithRetry(sessionId: string, ttlMs: number, waitMs?: number, retryInterval?: number): Promise<LockHandle>;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* The tool-dedup PORT — idempotency for repeated tool calls within a session.
|
|
611
|
+
*
|
|
612
|
+
* Keyed by `(sessionId, hash)`, where the hash is `hashToolCall(name, args)`:
|
|
613
|
+
* a deterministic SHA-256 of the tool name and its key-sorted arguments. The
|
|
614
|
+
* hashing stays a pure function in `tools/dedup.ts` — it is not I/O and there
|
|
615
|
+
* is nothing to swap about it — so an implementation of this port is only ever
|
|
616
|
+
* asked to store and fetch by a key it is handed.
|
|
617
|
+
*
|
|
618
|
+
* **Only `ok` results are ever stored** (the executor's rule, not the port's):
|
|
619
|
+
* a failure is worth retrying, and caching one would freeze a transient outage
|
|
620
|
+
* into the session for the whole TTL.
|
|
621
|
+
*/
|
|
622
|
+
export interface DedupPort {
|
|
623
|
+
/** A previously stored result for this exact call, or `null`. */
|
|
624
|
+
check(sessionId: string, hash: string): Promise<ToolResult | null>;
|
|
625
|
+
/**
|
|
626
|
+
* Remember a result for `ttlSeconds`. An implementation with no notion of
|
|
627
|
+
* expiry may ignore the TTL — it is a hint about staleness, and the loop
|
|
628
|
+
* passes `redis.ttl.toolDedup ?? 300` for it.
|
|
629
|
+
*/
|
|
630
|
+
store(sessionId: string, hash: string, result: ToolResult, ttlSeconds: number): Promise<void>;
|
|
631
|
+
}
|
|
384
632
|
/**
|
|
385
633
|
* What a compactor decides when it decides to compact.
|
|
386
634
|
*
|
|
@@ -480,7 +728,34 @@ export interface AgentConfig {
|
|
|
480
728
|
/** Path to .md definition file or pre-parsed AgentDefinition object */
|
|
481
729
|
definition: string | AgentDefinition;
|
|
482
730
|
brain: Brain;
|
|
483
|
-
|
|
731
|
+
/**
|
|
732
|
+
* Redis connection for the four ports Redis can back: session `memory`, the
|
|
733
|
+
* `controlBus`, the run `lock` and tool `dedup`.
|
|
734
|
+
*
|
|
735
|
+
* **Optional (ص٤) — and only because س٦ earned it.** While the trio was
|
|
736
|
+
* `new`-ed inside the loop, `redis` was load-bearing for every run and being
|
|
737
|
+
* required was simply true. س٦ made all four injectable and proved a whole
|
|
738
|
+
* turn runs without opening a connection; the field stayed required after
|
|
739
|
+
* that for no reason but the calendar, so a fully-injected caller had to hand
|
|
740
|
+
* over an object it never used. The rule that replaces the requirement:
|
|
741
|
+
*
|
|
742
|
+
* - **Present** — nothing changes, for anyone. Every port that is not
|
|
743
|
+
* injected is built from this exactly as it always was: same
|
|
744
|
+
* `{prefix}:{companyId}:{agentType}` scoping, same key shapes, same TTLs,
|
|
745
|
+
* same moment of connection. `{}` is *present*, not absent: it has
|
|
746
|
+
* neither `url` nor `client`, so the first port that reaches for it
|
|
747
|
+
* throws as it always has — which is precisely what makes it useful as a
|
|
748
|
+
* test config that fails loudly if anything opens Redis.
|
|
749
|
+
* - **Absent** — every one of the four ports MUST be injected, because
|
|
750
|
+
* there is nothing left to fall back to. A composition that omits one
|
|
751
|
+
* is rejected by `createAgent` with an error naming the missing field;
|
|
752
|
+
* see the check at the top of `createAgent`.
|
|
753
|
+
*
|
|
754
|
+
* Absence is read from the value (`undefined`), not from the key, so a host
|
|
755
|
+
* that spreads an options object with `redis: undefined` in it gets the same
|
|
756
|
+
* named composition error rather than a crash inside a turn.
|
|
757
|
+
*/
|
|
758
|
+
redis?: RedisConfig;
|
|
484
759
|
/**
|
|
485
760
|
* Optional session-memory port. When injected, the loop routes ALL history /
|
|
486
761
|
* metadata / document-state access through it and never constructs a
|
|
@@ -488,10 +763,61 @@ export interface AgentConfig {
|
|
|
488
763
|
* `redis` exactly as before — same prefix, same TTLs, same tenant scoping —
|
|
489
764
|
* so every existing consumer is unaffected.
|
|
490
765
|
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
766
|
+
* Isolation is the injector's (س١ ruling 3): this port takes a `sessionId`
|
|
767
|
+
* and knows nothing of tenants. Whoever injects a store injects its tenant
|
|
768
|
+
* scoping with it.
|
|
769
|
+
*
|
|
770
|
+
* Omitting this **and** `redis` is a composition error, raised at
|
|
771
|
+
* `createAgent` (ص٤) — not a run that fails on its first history read.
|
|
493
772
|
*/
|
|
494
773
|
memory?: SessionStore;
|
|
774
|
+
/**
|
|
775
|
+
* Optional **control-bus port** — kill / pause / disable-tool commands.
|
|
776
|
+
*
|
|
777
|
+
* Injected, the loop reads its commands and `agent.kill/pause/resume()` write
|
|
778
|
+
* through it. Omitted, the loop builds `RedisControlBus` from `redis` exactly
|
|
779
|
+
* as before: same `{prefix}:{companyId}:{agentType}` tenant scoping, same
|
|
780
|
+
* `:agent:control:{sessionId}` key, same 7-day TTL on a kill.
|
|
781
|
+
*
|
|
782
|
+
* Isolation is the injector's, as with `memory`. Note in particular that
|
|
783
|
+
* `agent.kill(sessionId, companyId, agentType)` computes a tenant-scoped
|
|
784
|
+
* prefix for the FALLBACK bus only — an injected bus receives the session id
|
|
785
|
+
* and nothing else, and must scope its own keys.
|
|
786
|
+
*
|
|
787
|
+
* Omitting this **and** `redis` is a composition error, raised at
|
|
788
|
+
* `createAgent` (ص٤).
|
|
789
|
+
*/
|
|
790
|
+
controlBus?: ControlBusPort;
|
|
791
|
+
/**
|
|
792
|
+
* Optional **session-lock port** — one turn per session at a time.
|
|
793
|
+
*
|
|
794
|
+
* Omitted, the loop builds `RedisDistributedLock` from `redis` exactly as
|
|
795
|
+
* before (`SET NX PX` on `{prefix}:session:{id}:lock`, released by token).
|
|
796
|
+
*
|
|
797
|
+
* An injected lock must actually exclude, or it is not a lock. In-process
|
|
798
|
+
* (`InMemoryLock`) that means one process; across replicas it means a shared
|
|
799
|
+
* one. Isolation is the injector's here too: two tenants that share one
|
|
800
|
+
* injected lock share its key space, and a session id colliding across them
|
|
801
|
+
* will serialise turns that have nothing to do with each other.
|
|
802
|
+
*
|
|
803
|
+
* Omitting this **and** `redis` is a composition error, raised at
|
|
804
|
+
* `createAgent` (ص٤).
|
|
805
|
+
*/
|
|
806
|
+
lock?: RunLockPort;
|
|
807
|
+
/**
|
|
808
|
+
* Optional **tool-dedup port** — idempotency for repeated tool calls.
|
|
809
|
+
*
|
|
810
|
+
* Omitted, the loop builds the Redis-backed dedup from `redis` exactly as
|
|
811
|
+
* before: `{prefix}:session:{id}:tools:dedup` as a hash, the call hash as the
|
|
812
|
+
* field, `EXPIRE` refreshed on every store.
|
|
813
|
+
*
|
|
814
|
+
* Isolation is the injector's: a dedup shared unscoped between tenants can
|
|
815
|
+
* serve one tenant's cached tool result to another's identical call.
|
|
816
|
+
*
|
|
817
|
+
* Omitting this **and** `redis` is a composition error, raised at
|
|
818
|
+
* `createAgent` (ص٤).
|
|
819
|
+
*/
|
|
820
|
+
dedup?: DedupPort;
|
|
495
821
|
/**
|
|
496
822
|
* Optional session **event log** (`@msm-core/session`). Injecting it inverts
|
|
497
823
|
* where conversation context comes from:
|
|
@@ -590,6 +916,25 @@ export interface ChunkInfo {
|
|
|
590
916
|
/** The delta — not the running total. Concatenating every chunk of a step
|
|
591
917
|
* yields the text that step's payload carries. */
|
|
592
918
|
text: string;
|
|
919
|
+
/**
|
|
920
|
+
* "Discard everything I have sent you for this step; it starts over." (ص٣/٢)
|
|
921
|
+
*
|
|
922
|
+
* The hook-side face of `BrainChunk.reset`. Set on the FIRST chunk a retried
|
|
923
|
+
* attempt delivers, and on no other, so a consumer that clears and restarts
|
|
924
|
+
* on it ends the step holding exactly the answer — no truncation from the
|
|
925
|
+
* attempt that died, no duplication from the one that succeeded.
|
|
926
|
+
*
|
|
927
|
+
* **It never reaches a consumer that did not ask for it.** The hook must go
|
|
928
|
+
* through `acceptChunkResets` (see `ResetAwareChunkHook`); for every other
|
|
929
|
+
* hook the loop hands the brain an undeclared sink, `brain/retry.ts` takes
|
|
930
|
+
* the ص١ mute path, and the object delivered here carries the same three
|
|
931
|
+
* keys it carried before this field existed — the key is not merely
|
|
932
|
+
* `undefined`, it is absent. That is deliberate: a consumer that appends
|
|
933
|
+
* chunks and ignores this field would render the answer twice over.
|
|
934
|
+
*
|
|
935
|
+
* `true` and absent are the only two states, as on `BrainChunk`.
|
|
936
|
+
*/
|
|
937
|
+
reset?: true;
|
|
593
938
|
}
|
|
594
939
|
/**
|
|
595
940
|
* Return value from onBeforeTool hook:
|
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
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills — reusable instruction packs an agent loads, "files all the way down."
|
|
3
|
+
*
|
|
4
|
+
* Drop markdown into a folder and each file becomes part of the agent's
|
|
5
|
+
* definition, so the model gains that know-how with no rebuild and no code
|
|
6
|
+
* change. Two shapes are read, and they are the shapes Claude-style skill packs
|
|
7
|
+
* already use:
|
|
8
|
+
*
|
|
9
|
+
* skills/
|
|
10
|
+
* summarizing/SKILL.md ← a folder pack
|
|
11
|
+
* tone-of-voice.md ← a single-file skill
|
|
12
|
+
*
|
|
13
|
+
* `loadSkills(dir)` returns ONE markdown block (`## Skills`), or `""` when
|
|
14
|
+
* there is nothing to load. **Composition stays with the host**: this module
|
|
15
|
+
* reads files and concatenates them; appending the block to a definition is the
|
|
16
|
+
* caller's line of code. The loop is not involved and does not know skills
|
|
17
|
+
* exist — `parseDefinition` has always *stripped* a `## Skills` section as
|
|
18
|
+
* "owned by the app layer", and that stays true.
|
|
19
|
+
*
|
|
20
|
+
* ── Lifted from `nisus/runtime/skills/loader.ts`, hardened where it mattered ──
|
|
21
|
+
*
|
|
22
|
+
* The pattern was right, so it was carried over rather than reinvented. Three
|
|
23
|
+
* things changed, each for a failure mode, and each pinned by a guard in
|
|
24
|
+
* `tests/skills.test.ts` — including a differential test against a verbatim
|
|
25
|
+
* transcription of the original, so any divergence beyond these is a bug:
|
|
26
|
+
*
|
|
27
|
+
* 1. **The order is deterministic.** The original iterated `readdirSync`
|
|
28
|
+
* directly, and that order is filesystem- and platform-dependent (APFS
|
|
29
|
+
* hands back roughly hash order; ext4 differs again). Two machines with
|
|
30
|
+
* the same two skills therefore built two different definitions — the same
|
|
31
|
+
* agent with two fingerprints, which is the enemy of replay
|
|
32
|
+
* (`@msm-core/replay` fingerprints `system_context`) and of any diff a
|
|
33
|
+
* human tries to read. Entries are now sorted by code unit before they are
|
|
34
|
+
* read. **Not** `localeCompare`: that depends on ICU data and the ambient
|
|
35
|
+
* locale, which would reintroduce exactly the nondeterminism being
|
|
36
|
+
* removed.
|
|
37
|
+
*
|
|
38
|
+
* 2. **Diagnostics go to an injected port, never to `console`.** Same reason
|
|
39
|
+
* as `@msm-core/mcp`'s `McpLogPort`: a library that prints is a library you
|
|
40
|
+
* cannot embed. Absent a port, the loader is silent.
|
|
41
|
+
*
|
|
42
|
+
* 3. **An empty skill file contributes nothing instead of a bare separator.**
|
|
43
|
+
* The original pushed `readFileSync(...).trim()` unconditionally, so an
|
|
44
|
+
* empty (or whitespace-only) `.md` injected `"\n\n---\n\n\n\n---\n\n"`
|
|
45
|
+
* into the text the model reads — a horizontal rule with no section under
|
|
46
|
+
* it, mid-definition. It is now skipped, with a warning naming the file,
|
|
47
|
+
* because a file someone left empty is a mistake worth hearing about.
|
|
48
|
+
*
|
|
49
|
+
* What deliberately did NOT change: a folder without a `SKILL.md` is skipped in
|
|
50
|
+
* silence (it is a plain folder, not a failure); a non-`.md` file is ignored;
|
|
51
|
+
* an entry that throws is reported and its siblings still load; and a folder
|
|
52
|
+
* pack and a single-file skill sitting side by side are BOTH read — the folder
|
|
53
|
+
* from its `SKILL.md`, the file from itself. That last one is the precedence
|
|
54
|
+
* rule, and it is a rule rather than an accident because a guard says so.
|
|
55
|
+
*/
|
|
56
|
+
/**
|
|
57
|
+
* Where the loader's diagnostics go — a port, not a console.
|
|
58
|
+
*
|
|
59
|
+
* Shaped so a plain `console`-like object, or the `SimpleLog` the original
|
|
60
|
+
* loader took, satisfies it without a wrapper.
|
|
61
|
+
*/
|
|
62
|
+
export interface SkillsLogPort {
|
|
63
|
+
info?(message: string): void;
|
|
64
|
+
warn?(message: string): void;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Read every skill under `dir` and return them as one markdown block, or `""`
|
|
68
|
+
* when the directory is missing, is not a directory, or holds no skills.
|
|
69
|
+
*
|
|
70
|
+
* Never throws for a skill it cannot read: the failure is reported on `log` and
|
|
71
|
+
* the remaining skills still load. A definition missing one section is worth
|
|
72
|
+
* more than an agent that will not start.
|
|
73
|
+
*/
|
|
74
|
+
export declare function loadSkills(dir: string, log?: SkillsLogPort): string;
|