@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/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,370 @@
|
|
|
3
3
|
All notable changes are documented here.
|
|
4
4
|
Follows [Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
> **Note on the gap (recorded 2026-09-01, session م١).** This ledger stopped at
|
|
7
|
+
> `0.5.2` while six versions shipped — and `0.8.0` and `0.9.0` reached npm with
|
|
8
|
+
> no entry to read. The entries from `0.6.0` down were written afterwards from
|
|
9
|
+
> the session rulings in `docs/SESSIONS.md` and the commits that carried each
|
|
10
|
+
> bump, not from memory; each is dated by that commit. The rule going forward:
|
|
11
|
+
> whoever publishes keeps the ledger complete.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## [0.15.0] — 2026-09-13
|
|
16
|
+
|
|
17
|
+
Session ت١ (tadween chapter, item 1). Thinking is separated from the answer,
|
|
18
|
+
and text parts are joined — on both paths, for Gemini and Anthropic alike.
|
|
19
|
+
Everything here is additive; a composition that passes no new option gets
|
|
20
|
+
yesterday's behavior, byte for byte.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **Gemini: the first text part was the whole answer.** The brain read its
|
|
25
|
+
reply with `parts.find(text)`, so a candidate of several text parts returned
|
|
26
|
+
the first and dropped the rest — and with thinking enabled the first part
|
|
27
|
+
IS the reasoning, so the user read the model's notes instead of its reply.
|
|
28
|
+
Now every text part NOT tagged `thought: true` is joined in emitted order
|
|
29
|
+
with no separator; tagged parts are joined separately and never enter the
|
|
30
|
+
answer. Untagged prose is kept as written: the loop drops what the provider
|
|
31
|
+
tagged and keeps what it did not.
|
|
32
|
+
- **Gemini streamed: thinking parts leaked into the merged answer.**
|
|
33
|
+
`accumulateGemini` folded every text part — tagged or not — into one slot.
|
|
34
|
+
It now keeps two (answer and thinking, each tagged as it arrived), and
|
|
35
|
+
`geminiDelta` skips tagged parts, so what an `onChunk` watcher hears stays a
|
|
36
|
+
prefix of the answer the payload finally carries (the ب١ covenant). The
|
|
37
|
+
brain decides text-versus-thinking on the folded shape with the same lines
|
|
38
|
+
it uses unstreamed.
|
|
39
|
+
- **Anthropic: the first text block was the whole answer** (measured under
|
|
40
|
+
the same rule, item 8). Thinking blocks were already excluded by their
|
|
41
|
+
type; every `text` block is now joined in order, and a `thinking` block's
|
|
42
|
+
body goes to `thoughts` (unstreamed — the streamed fold does not carry it,
|
|
43
|
+
and this brain does not request thinking).
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- `createGeminiBrain({ thinking?: { budget?, includeThoughts? } })` →
|
|
48
|
+
`generationConfig.thinkingConfig = { thinkingBudget, includeThoughts }`,
|
|
49
|
+
each field only when given, and the whole key only when the option is
|
|
50
|
+
present with a value (`thinking: undefined` from a JavaScript host is
|
|
51
|
+
absence). `budget: 0` is a value and switches thinking off. Without the
|
|
52
|
+
option the request carries no `generationConfig` key at all — guarded by a
|
|
53
|
+
before/after snapshot of the request. The option type is exported as
|
|
54
|
+
`GeminiBrainOptions`. Note: `@google/generative-ai` 0.24.1 (installed, and
|
|
55
|
+
the registry's newest as of 2026-09-13) does not declare `thinkingConfig`;
|
|
56
|
+
it serializes the request verbatim, so the field reaches the wire.
|
|
57
|
+
- `BrainPayload.thoughts?: string` — the model's reasoning for that call,
|
|
58
|
+
joined, absent when there was none (no key, never `""`). Filled by the
|
|
59
|
+
Gemini and Anthropic brains; a custom brain may fill it the same way.
|
|
60
|
+
- `LoopOutcome.thoughts?: string` — the LAST step's `thoughts`, at every exit
|
|
61
|
+
that takes its `text` from a payload (final answer, forced finalize,
|
|
62
|
+
force-respond/escalate). Absent otherwise. **Not written to the session
|
|
63
|
+
log**: the model never sees its own thinking again, so the invariant "what
|
|
64
|
+
the model sees is recorded" does not reach it — auditing it as an event is
|
|
65
|
+
raised to management, not decided here.
|
|
66
|
+
- **`thoughts` is ungated audit material — do not hand it to an end user
|
|
67
|
+
without the host's own filter.** The output gate validates `text` and
|
|
68
|
+
never reads `thoughts`. When the gate BLOCKS the answer (`type:
|
|
69
|
+
"suppressed"`) the outcome carries no `thoughts` either — what could not
|
|
70
|
+
go out as an answer does not go out as notes. On every other verdict
|
|
71
|
+
(`release`, `review`, no validator, or a gate that never ran because the
|
|
72
|
+
text was empty) `thoughts` passes exactly as the model wrote it.
|
|
73
|
+
- **Only with text the model wrote.** On a guard exit that falls back to
|
|
74
|
+
the canned "I was unable to complete…" line, `thoughts` is absent — the
|
|
75
|
+
thinking accompanies the answer it produced, never a sentence the model
|
|
76
|
+
did not write.
|
|
77
|
+
- **Reaches the `replay` tape.** `createRecordingBrain` records the whole
|
|
78
|
+
payload, `thoughts` included, like any other field; the `redact` option
|
|
79
|
+
of `createRecordingBrain` is where a host hides it (and replays with the
|
|
80
|
+
same redactor).
|
|
81
|
+
- `## Brain` reads `thinking.budget` and `thinking.includeThoughts` (flat
|
|
82
|
+
dotted keys) into `AgentDefinition.brain.thinking?: { budget?, includeThoughts? }`
|
|
83
|
+
— now an explicit field — and `buildBrain` hands it to `createGeminiBrain`
|
|
84
|
+
**after checking its shape**: an object; `budget` a finite number ≥ 0 when
|
|
85
|
+
given; `includeThoughts` a boolean when given. A value that does not fit is
|
|
86
|
+
a **named error** (from the parser for text, from `buildBrain` for a
|
|
87
|
+
hand-built definition), never a silent default. Neither key written → no
|
|
88
|
+
`thinking` on the definition and no `generationConfig` on the wire. Only the
|
|
89
|
+
Gemini brain consumes the key in this release. (`## Brain` alone grew the
|
|
90
|
+
dotted grammar; other sections parse exactly as before.)
|
|
91
|
+
- `thinking: {}` — and an object whose fields are all `undefined` — is
|
|
92
|
+
absence: no `generationConfig` on the wire, no `thinkingConfig: {}`.
|
|
93
|
+
- Guards: `tests/gemini-thoughts.test.ts` (36 tests) and
|
|
94
|
+
`tests/brain-thinking-definition.test.ts` (14 tests) — each rule above, each
|
|
95
|
+
control named as such; the rule guards were shown red by deliberate breaks
|
|
96
|
+
(ten breaks over the two rounds, 21 of the 26 new tests reddened by name; the
|
|
97
|
+
rest are controls) and restored by fingerprint; the loop's three exit sites
|
|
98
|
+
guarded one by one.
|
|
99
|
+
|
|
100
|
+
## [0.14.0] — 2026-09-01
|
|
101
|
+
|
|
102
|
+
Session ص٤. The Redis-mandatory era ends.
|
|
103
|
+
|
|
104
|
+
### Changed
|
|
105
|
+
|
|
106
|
+
- `AgentConfig.redis` is **optional**. Present: byte-for-byte today's
|
|
107
|
+
behavior. Absent with all four ports injected (memory, controlBus, lock,
|
|
108
|
+
dedup): the loop runs with no Redis at all. Absent with any port missing:
|
|
109
|
+
`createAgent` fails immediately with an error that names the missing
|
|
110
|
+
port — and only it — and points at the `/adapters` in-memory
|
|
111
|
+
implementations. Absence is read from the VALUE (`=== undefined`), so a
|
|
112
|
+
host spreading `redis: undefined` gets the named error, not a TypeError
|
|
113
|
+
from the internals.
|
|
114
|
+
|
|
115
|
+
## [0.13.0] — 2026-09-01
|
|
116
|
+
|
|
117
|
+
Session س٦ (both rounds). The loop can now run with no Redis at all — and
|
|
118
|
+
the audit log names the model that actually answered.
|
|
119
|
+
|
|
120
|
+
### Added
|
|
121
|
+
|
|
122
|
+
- Three injected ports on the س١ pattern: `ControlBusPort`, `RunLockPort`
|
|
123
|
+
(named to avoid `@msm-core/jobs`'s `LockPort` — the reason lives in the
|
|
124
|
+
port's own comment), `DedupPort` — `AgentConfig` accepts them optionally;
|
|
125
|
+
absent means the bundled Redis trio exactly as before. In-memory adapters
|
|
126
|
+
for all three; with all four ports injected the loop measurably issues
|
|
127
|
+
ZERO Redis calls.
|
|
128
|
+
- Model truth: the four brains fill `BrainPayload.model` from the
|
|
129
|
+
provider's own reply — streamed and non-streamed both — and the loop
|
|
130
|
+
writes it to `model_response.data.model` (see `@msm-core/session` 0.3.0
|
|
131
|
+
for the event contract). `respondingModel` exported.
|
|
132
|
+
|
|
133
|
+
### Notes
|
|
134
|
+
|
|
135
|
+
- "Who injects a port injects its isolation" extends to the three new
|
|
136
|
+
ports, guarded behaviorally with two tenants.
|
|
137
|
+
|
|
138
|
+
## [0.12.0] — 2026-09-01
|
|
139
|
+
|
|
140
|
+
Session ص٣, entry with the bump (the new ledger guard enforces what
|
|
141
|
+
Circular 2 asked by hand).
|
|
142
|
+
|
|
143
|
+
### Added
|
|
144
|
+
|
|
145
|
+
- The reset channel reaches `AgentHooks.onChunk`: `ChunkInfo.reset?: true`,
|
|
146
|
+
`fireChunk` spreads it, and a hooks consumer opts in with
|
|
147
|
+
`acceptChunkResets(hook)` (`isResetAwareHook`, `ResetAwareChunkHook`
|
|
148
|
+
exported). ص٢'s covenant one layer up: an undeclared consumer's chunks
|
|
149
|
+
are key-for-key what they always were — reset never reaches anyone who
|
|
150
|
+
did not ask for it.
|
|
151
|
+
- Ledger guards in mini/session/replay/mcp: `package.json.version` must
|
|
152
|
+
equal the CHANGELOG head — no seventh unrecorded release.
|
|
153
|
+
|
|
154
|
+
### Changed
|
|
155
|
+
|
|
156
|
+
- mcp's spawned-process tests comply with Council Circular 1 (stderr in
|
|
157
|
+
every failure text, named waits, declared readiness) — diagnostics only;
|
|
158
|
+
the 98 tests and their assertions are unchanged.
|
|
159
|
+
|
|
160
|
+
## [0.11.0] — 2026-09-01
|
|
161
|
+
|
|
162
|
+
Session م١, entry by management at acceptance (Circular 2: the ledger rides
|
|
163
|
+
with the bump, not behind it).
|
|
164
|
+
|
|
165
|
+
### Added
|
|
166
|
+
|
|
167
|
+
- `loadSkills(dir, log?)` in the definition layer — Claude-style skill packs
|
|
168
|
+
(`skills/<pack>/SKILL.md` folders and single `.md` files) read into one
|
|
169
|
+
`## Skills` markdown block for the host to append to an agent definition.
|
|
170
|
+
Lifted from nisus's `runtime/skills/loader.ts` and hardened: deterministic
|
|
171
|
+
code-unit ordering (platform-independent definitions — replay-stable
|
|
172
|
+
fingerprints), an injected `SkillsLogPort` instead of console, and empty
|
|
173
|
+
skill files contribute nothing rather than a bare separator (a declared,
|
|
174
|
+
parity-tested divergence from the original). Composition stays with the
|
|
175
|
+
host: the code moved, the wiring did not — zero loop changes.
|
|
176
|
+
|
|
177
|
+
## [0.10.0] — 2026-09-01
|
|
178
|
+
|
|
179
|
+
Session ص٢. A retried stream can finally repair the display it interrupted.
|
|
180
|
+
|
|
181
|
+
### Added
|
|
182
|
+
|
|
183
|
+
- **`BrainChunk.reset` — a retried call can tell you to clear what you drew.**
|
|
184
|
+
Since 0.9.1 a retried brain call stops re-emitting text you already saw, which
|
|
185
|
+
never truncates the payload but does leave the display frozen at the cut. A
|
|
186
|
+
consumer that *can* clear what it has drawn now opts in **on its sink**:
|
|
187
|
+
`acceptResets(sink)` marks it, `isResetAware(sink)` reports it, and
|
|
188
|
+
`ResetAwareChunkSink` is the type. A marked sink gets `reset: true` on the
|
|
189
|
+
first chunk after a retry and redraws the answer whole. **An unmarked sink is
|
|
190
|
+
on 0.9.1's path byte for byte** — the opt-in is the sink's, so a consumer that
|
|
191
|
+
does nothing sees nothing new.
|
|
192
|
+
- New exports: `acceptResets`, `isResetAware`, type `ResetAwareChunkSink`.
|
|
193
|
+
|
|
194
|
+
### Changed
|
|
195
|
+
|
|
196
|
+
- Internal, no surface: every package's test tree is now type-checked by `tsc`
|
|
197
|
+
inside `npm test` (17 of 53 test files were under a compiler before; 54 of 54
|
|
198
|
+
now). This is why a type error can no longer sit green for months.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## [0.9.1] — 2026-09-01
|
|
203
|
+
|
|
204
|
+
Session ص١ — three internal debts, two of them visible from outside.
|
|
205
|
+
|
|
206
|
+
### Fixed
|
|
207
|
+
|
|
208
|
+
- **A stamped tool's `destructive` / `category` now reach the model.**
|
|
209
|
+
`toToolDefinitions` declared both fields and dropped them on the way out, so a
|
|
210
|
+
tool marked destructive by the MCP adapter or by `createDelegateTool` was
|
|
211
|
+
described to the provider like any other tool. They are forwarded now.
|
|
212
|
+
- **A retried brain call no longer streams the same words twice.** `withRetry`
|
|
213
|
+
restarted the provider call and the new attempt re-emitted text the consumer
|
|
214
|
+
had already rendered. The gate is keyed on **what was delivered**, not on the
|
|
215
|
+
attempt number: what reaches your `onChunk` is always a prefix of the final
|
|
216
|
+
payload — never longer than it, never contradicting it — and the worst case is
|
|
217
|
+
a display that stops where the cut happened rather than one that repeats or
|
|
218
|
+
truncates mid-sentence. Display only; `LoopOutcome` was always correct.
|
|
219
|
+
|
|
220
|
+
### Changed
|
|
221
|
+
|
|
222
|
+
- Internal: `mini`'s test tree is type-checked on every `npm test`. Nine
|
|
223
|
+
long-standing type errors in old tests were fixed **types only** — no test's
|
|
224
|
+
behavior and no test count changed.
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## [0.9.0] — 2026-09-01
|
|
229
|
+
|
|
230
|
+
Session ر٢. Delegation stops being a dead union member and becomes a tool.
|
|
231
|
+
|
|
232
|
+
### Removed — ⚠️ a type breaks; no runtime does
|
|
233
|
+
|
|
234
|
+
- **`"delegate"` is gone from the `BrainOrchestration.action` union.** It had
|
|
235
|
+
been declared since 0.1.0 and **the loop never executed it** — a brain
|
|
236
|
+
returning it fell through to the unknown-action path. Measured before the
|
|
237
|
+
removal: zero occurrences in `mini/src`, zero in nisus, and none of the four
|
|
238
|
+
brains ever emitted it. If you have a third-party brain that returns
|
|
239
|
+
`action: "delegate"`, it no longer type-checks, and at runtime it behaves
|
|
240
|
+
exactly as it always did: a terminal `respond` that delivers text and cannot
|
|
241
|
+
smuggle a tool call past the dispatcher (now pinned by a guard).
|
|
242
|
+
|
|
243
|
+
### Added
|
|
244
|
+
|
|
245
|
+
- **`createDelegateTool(delegates, opts?)` — one agent asks another, as an
|
|
246
|
+
ordinary `Tool`.** It returns a plain `call_agent` tool over
|
|
247
|
+
`Record<string, Agent>`, with the delegate names as an `enum` parameter so the
|
|
248
|
+
model cannot invent one. The child's session id is **derived**, not minted:
|
|
249
|
+
`${sessionId}.d.${name}` — deterministic, readable in a log, identical across
|
|
250
|
+
runs. The parent's `tenantContext` is inherited by the child.
|
|
251
|
+
`opts.maxDepth` (default 1) **fails closed** with a named `failed` result
|
|
252
|
+
rather than an exception. The child's cost comes back inside the tool result,
|
|
253
|
+
visible and not swallowed — it is deliberately *not* folded into the parent's
|
|
254
|
+
total, because counting it twice is worse than not counting it.
|
|
255
|
+
`opts.requiresApproval` / `destructive` / `category` stamp the definition, so
|
|
256
|
+
fail-closed approval covers a delegation with no new code.
|
|
257
|
+
- New exports: `createDelegateTool`, `DELEGATE_TOOL_NAME`, types `DelegateTool`,
|
|
258
|
+
`DelegateToolOptions`.
|
|
259
|
+
- **Zero loop changes.** A delegation passes guards, approval, dedup and the
|
|
260
|
+
session log exactly like any other tool — which is the whole argument for a
|
|
261
|
+
tool over an action.
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## [0.8.0] — 2026-09-01
|
|
266
|
+
|
|
267
|
+
Sessions ض١ and ب١. Two optional seats: one for compaction, one for streaming.
|
|
268
|
+
|
|
269
|
+
### Added
|
|
270
|
+
|
|
271
|
+
- **Streaming — `BrainRunInput.onChunk` and `AgentHooks.onChunk` (ب١).** All
|
|
272
|
+
four brains stream natively, each behind one branch; `BrainChunk` is
|
|
273
|
+
`{ text }`, and `ChunkInfo` adds `{ sessionId, iteration }` at the hook. Token
|
|
274
|
+
usage still arrives on the streamed path (`stream_options.include_usage`), so
|
|
275
|
+
`costCapPerTask` keeps working while streaming. **Streaming is display, not
|
|
276
|
+
truth**: no stream event is written to the session log, and
|
|
277
|
+
`@msm-core/replay`'s fingerprint is blind to `onChunk` — a run someone watched
|
|
278
|
+
and a run nobody watched are the same run. Without a handler the path is
|
|
279
|
+
literally the previous one, a single `if` apart.
|
|
280
|
+
- **Compaction as an injected seat — `AgentConfig.compaction?: CompactionPort`
|
|
281
|
+
(ض١).** The port decides when a long conversation is summarised;
|
|
282
|
+
`createBrainCompactor(brain, opts?)` is one reference occupant that summarises
|
|
283
|
+
with a model you provide. The summary is written to the session log as a
|
|
284
|
+
`compaction` event, so it is auditable and re-derivable rather than a silent
|
|
285
|
+
trim, and a boundary never severs a tool call from its result
|
|
286
|
+
(`CompactionRangeError` names the attempt). **Without a `compaction` port
|
|
287
|
+
nothing changes**; without a `sessionLog` the seat is inert, declared.
|
|
288
|
+
- New exports: `createBrainCompactor`; types `BrainCompactorOptions`,
|
|
289
|
+
`CompactionPort`, `CompactionDecision`, `ContextBudget`, `BrainChunk`,
|
|
290
|
+
`ChunkInfo`, `BrainToolCall`, `BrainOrchestration`.
|
|
291
|
+
|
|
292
|
+
### Changed
|
|
293
|
+
|
|
294
|
+
- Requires `@msm-core/session` `^0.2.0` — the `compaction` event is the tenth
|
|
295
|
+
member of that package's closed event union.
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## [0.7.0] — 2026-09-01
|
|
300
|
+
|
|
301
|
+
Sessions س٣ and س٤. Context starts being *derived*, and a step stops meaning one
|
|
302
|
+
tool.
|
|
303
|
+
|
|
304
|
+
### Added
|
|
305
|
+
|
|
306
|
+
- **`AgentConfig.sessionLog?: SessionLogPort` — the loop writes an event log and
|
|
307
|
+
reads its context back out of it (س٣).** Injected, it inverts where
|
|
308
|
+
conversation context comes from: `deriveMessages(read())` instead of the
|
|
309
|
+
stored history array, with the same history budget applied. Events are written
|
|
310
|
+
**where they happen** — the user's message, each step, the raw pre-gate model
|
|
311
|
+
response, tool call/result pairs on every tool path (including refused and
|
|
312
|
+
unknown tools), guards that fired, the post-gate assistant message, and the
|
|
313
|
+
step's end on every exit including the `catch`. An invariant checks that every
|
|
314
|
+
field of `BrainRunInput` has exactly one counterpart in the log, and it runs
|
|
315
|
+
**before** the request goes to the model — a request that cannot be rebuilt
|
|
316
|
+
from the log should not be paid for. A rejected append fails the turn: the log
|
|
317
|
+
is the truth. **Absent, behavior is the previous version's bit for bit**;
|
|
318
|
+
`appendHistory` still runs, and the double write is transitional.
|
|
319
|
+
- **`BrainOrchestration.tool_calls?: BrainToolCall[]` — many tools in ONE model
|
|
320
|
+
round-trip (س٤).** Three tools now cost two round-trips instead of four.
|
|
321
|
+
**A pure addition**: a brain that fills only `tool_name` / `tool_params` is
|
|
322
|
+
normalised to a single-call step and takes exactly the path it always took,
|
|
323
|
+
and a brain that fills the array fills those two with `tool_calls[0]` as well.
|
|
324
|
+
The covenant is one-directional — new readers may read the array, old readers
|
|
325
|
+
never find a hole where the first call used to be. Approval fails closed **per
|
|
326
|
+
call**, `onBeforeTool` and dedup run per call, and a failing sibling does not
|
|
327
|
+
kill the others.
|
|
328
|
+
- New type `BrainToolCall`. New dependency: `@msm-core/session`.
|
|
329
|
+
|
|
330
|
+
### Changed
|
|
331
|
+
|
|
332
|
+
- **Tool turns are role-tagged deterministically across all four providers**
|
|
333
|
+
(`[tool_call:<id>]` / `[tool_result:<id>]`) instead of being flattened into a
|
|
334
|
+
bare `user` message. ⚠️ This changes the prompt text your provider receives: a
|
|
335
|
+
model reading back its own tool history can now tell which side was which.
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
## [0.6.0] — 2026-09-01
|
|
340
|
+
|
|
341
|
+
Session س١. Session memory becomes a port.
|
|
342
|
+
|
|
343
|
+
### Added
|
|
344
|
+
|
|
345
|
+
- **`AgentConfig.memory?: SessionStore` — inject your own session store.** The
|
|
346
|
+
loop used to build `new RedisMemory(...)` by hand in two places that had
|
|
347
|
+
already drifted apart from each other in silence. There is now one port (six
|
|
348
|
+
functions, taken at `RedisMemory`'s exact signatures) and one construction
|
|
349
|
+
site: an injected store wins, and absent one `RedisMemory` is built exactly as
|
|
350
|
+
before. `RedisMemory` implements `SessionStore`, so an existing configuration
|
|
351
|
+
needs no change.
|
|
352
|
+
- `InMemorySessionStore` in `@msm-core/mini/adapters` — a faithful RAM adapter
|
|
353
|
+
(tail limit, 500-entry cap, deep copy on both sides, so a returned reference
|
|
354
|
+
cannot be mutated into the store).
|
|
355
|
+
- New exported types: `SessionStore`; and `SessionMetadata`, moved to
|
|
356
|
+
`core/types` with a back-compatible re-export from its old home (zero measured
|
|
357
|
+
breaks).
|
|
358
|
+
|
|
359
|
+
### Note — the covenant that arrives with the port
|
|
360
|
+
|
|
361
|
+
**Whoever injects a store injects its tenant isolation with it.** The loop
|
|
362
|
+
cannot enforce the `{prefix}:{companyId}:{agentType}` rule on a port it cannot
|
|
363
|
+
see into, so a store you supply owns its own tenant separation. The built-in
|
|
364
|
+
`RedisMemory` path is unchanged and still guarded.
|
|
365
|
+
|
|
366
|
+
`redis` remains required either way: the control bus, the distributed lock and
|
|
367
|
+
the dedup cache are still Redis-backed by direct construction, so "a loop with
|
|
368
|
+
no Redis at all" is not yet possible.
|
|
369
|
+
|
|
6
370
|
---
|
|
7
371
|
|
|
8
372
|
## [0.5.2] — 2026-07-01
|
package/README.md
CHANGED
|
@@ -150,6 +150,60 @@ const outcome = await agent.handle({
|
|
|
150
150
|
|
|
151
151
|
`buildBrain(definition)` reads the `## Brain` section of an `AgentDefinition` and returns the correct brain automatically.
|
|
152
152
|
|
|
153
|
+
### Thinking (Gemini) — `thinking`
|
|
154
|
+
|
|
155
|
+
Gemini 2.5+ models reason before they answer. Opt in per brain, and the
|
|
156
|
+
reasoning comes back **separated** from the reply rather than inside it:
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
const brain = createGeminiBrain({
|
|
160
|
+
model: "gemini-2.5-flash",
|
|
161
|
+
thinking: { budget: 1024, includeThoughts: false },
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Or from the definition file — the same two fields, written flat under
|
|
166
|
+
`## Brain`, reach the brain through `buildBrain`:
|
|
167
|
+
|
|
168
|
+
```markdown
|
|
169
|
+
## Brain
|
|
170
|
+
provider: gemini
|
|
171
|
+
model: gemini-2.5-flash
|
|
172
|
+
thinking.budget: 1024
|
|
173
|
+
thinking.includeThoughts: false
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
- `budget` → `generationConfig.thinkingConfig.thinkingBudget`; `0` switches
|
|
177
|
+
thinking off on models that think by default.
|
|
178
|
+
- `includeThoughts: true` asks for the reasoning back. Gemini tags those parts
|
|
179
|
+
`thought: true`; the brain joins them into `BrainPayload.thoughts` and the
|
|
180
|
+
loop copies the **last step's** value to `LoopOutcome.thoughts`. They never
|
|
181
|
+
enter `response_text`, are never streamed to `onChunk`, and are not written
|
|
182
|
+
to the session log.
|
|
183
|
+
- **Without the option nothing changes**: the request carries no
|
|
184
|
+
`generationConfig` key at all, and the answer is built exactly as before.
|
|
185
|
+
An empty `thinking: {}` is the same as no option. A value that does not fit
|
|
186
|
+
(`thinking.budget: abc`, a negative budget, a non-boolean `includeThoughts`)
|
|
187
|
+
is a **named error** from the parser or from `buildBrain` — never a silent
|
|
188
|
+
default. Only the Gemini brain reads the key today.
|
|
189
|
+
- **`thoughts` is ungated audit material — not for an end user without your
|
|
190
|
+
own filter.** The output gate (`validator`) checks `text` and never reads
|
|
191
|
+
`thoughts`. When it blocks the answer, `thoughts` is dropped with it; on
|
|
192
|
+
every other verdict — and when the gate never ran because the text was
|
|
193
|
+
empty — the reasoning passes exactly as the model wrote it. It rides only
|
|
194
|
+
with text the model wrote: a guard exit that falls back to the canned
|
|
195
|
+
"unable to complete" line carries no `thoughts`.
|
|
196
|
+
- **It reaches the replay tape.** `@msm-core/replay`'s `createRecordingBrain`
|
|
197
|
+
records the whole payload, `thoughts` included; its `redact` option is where
|
|
198
|
+
to hide it (replay with the same redactor).
|
|
199
|
+
|
|
200
|
+
Two rules hold regardless of the option, for Gemini and Anthropic alike
|
|
201
|
+
(`0.15.0`): every text part/block is **joined in emitted order** (the first is
|
|
202
|
+
no longer taken as the whole answer), and a part the provider **tagged** as
|
|
203
|
+
thinking is kept out of the answer. Prose the provider did *not* tag is kept
|
|
204
|
+
as written — the loop does not guess. A model that narrates its reasoning
|
|
205
|
+
into the answer untagged is a prompt (or budget) matter, not a parsing one.
|
|
206
|
+
|
|
153
207
|
---
|
|
154
208
|
|
|
155
209
|
## HTTP Server
|
package/dist/adapters/index.d.ts
CHANGED
|
@@ -10,7 +10,17 @@ export type { LockHandle } from "./redis-lock.js";
|
|
|
10
10
|
export { InMemoryRedis, createInMemoryRedis } from "./memory-redis.js";
|
|
11
11
|
/** In-RAM SessionStore — the memory port with no Redis and no network. */
|
|
12
12
|
export { InMemorySessionStore } from "./memory-store.js";
|
|
13
|
+
/**
|
|
14
|
+
* The in-RAM trio (س٦) — with `InMemorySessionStore` above, these are the four
|
|
15
|
+
* ports a loop needs, so an agent can run start to finish with no Redis at all.
|
|
16
|
+
* `InMemoryLock` is a real mutex, not a stub; see its file.
|
|
17
|
+
*/
|
|
18
|
+
export { InMemoryControlBus } from "./memory-control.js";
|
|
19
|
+
export { InMemoryLock } from "./memory-lock.js";
|
|
20
|
+
export { InMemoryToolDedup } from "./memory-dedup.js";
|
|
21
|
+
/** The Redis-backed dedup, behind the port — what the loop builds when `dedup` is omitted. */
|
|
22
|
+
export { RedisToolDedup } from "../tools/dedup.js";
|
|
13
23
|
/** The client shape RedisConfig.client expects — for wiring a custom client. */
|
|
14
24
|
export type { RedisLike } from "./redis-types.js";
|
|
15
|
-
/** The
|
|
16
|
-
export type { SessionStore } from "../core/types.js";
|
|
25
|
+
/** The ports `AgentConfig` accepts — for writing custom implementations. */
|
|
26
|
+
export type { ControlBusPort, DedupPort, RunLockPort, SessionStore, } from "../core/types.js";
|
package/dist/adapters/index.js
CHANGED
|
@@ -8,3 +8,13 @@ export { RedisDistributedLock } from "./redis-lock.js";
|
|
|
8
8
|
export { InMemoryRedis, createInMemoryRedis } from "./memory-redis.js";
|
|
9
9
|
/** In-RAM SessionStore — the memory port with no Redis and no network. */
|
|
10
10
|
export { InMemorySessionStore } from "./memory-store.js";
|
|
11
|
+
/**
|
|
12
|
+
* The in-RAM trio (س٦) — with `InMemorySessionStore` above, these are the four
|
|
13
|
+
* ports a loop needs, so an agent can run start to finish with no Redis at all.
|
|
14
|
+
* `InMemoryLock` is a real mutex, not a stub; see its file.
|
|
15
|
+
*/
|
|
16
|
+
export { InMemoryControlBus } from "./memory-control.js";
|
|
17
|
+
export { InMemoryLock } from "./memory-lock.js";
|
|
18
|
+
export { InMemoryToolDedup } from "./memory-dedup.js";
|
|
19
|
+
/** The Redis-backed dedup, behind the port — what the loop builds when `dedup` is omitted. */
|
|
20
|
+
export { RedisToolDedup } from "../tools/dedup.js";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory ControlBusPort — kill / pause / disable-tool with no Redis.
|
|
3
|
+
*
|
|
4
|
+
* The bundled counterpart to `RedisControlBus`, held in process memory. It is
|
|
5
|
+
* what makes a single-process deploy (a desktop app, an air-gapped install) or
|
|
6
|
+
* a test able to steer a running agent at all: before س٦ the only bus was a
|
|
7
|
+
* Redis key, so "kill this session" required infrastructure.
|
|
8
|
+
*
|
|
9
|
+
* Fidelity to `RedisControlBus` is the point, and it is fidelity to the
|
|
10
|
+
* *semantics*, not just to the method names — the loop reads a command string
|
|
11
|
+
* and acts on its verb, so a mirror that stored a different vocabulary would be
|
|
12
|
+
* a mirror of nothing:
|
|
13
|
+
* - `kill(id, reason)` stores `kill:{reason}`, default reason `"killed"` —
|
|
14
|
+
* the loop's guard reports the part after the colon.
|
|
15
|
+
* - `pause` stores `"pause"`; `disableTool` stores `disabled:{name}`.
|
|
16
|
+
* - `resume` clears ONLY a pause. A killed session stays killed — the Redis
|
|
17
|
+
* adapter reads before it deletes for exactly this reason, and a mirror
|
|
18
|
+
* that "resumed" a kill would quietly revive an aborted run.
|
|
19
|
+
* - one command per session: a later write replaces an earlier one.
|
|
20
|
+
*
|
|
21
|
+
* Scope: ONE process, and no expiry. The Redis bus gives a kill a 7-day TTL so
|
|
22
|
+
* the record outlives the worker that wrote it; nothing here outlives the
|
|
23
|
+
* process, so there is nothing to expire. For multi-replica deploys — where the
|
|
24
|
+
* whole point is that the killer and the killed are different processes — use
|
|
25
|
+
* `RedisControlBus`.
|
|
26
|
+
*/
|
|
27
|
+
import type { ControlBusPort } from "../core/types.js";
|
|
28
|
+
export declare class InMemoryControlBus implements ControlBusPort {
|
|
29
|
+
private readonly commands;
|
|
30
|
+
getCommand(sessionId: string): Promise<string | null>;
|
|
31
|
+
kill(sessionId: string, reason?: string): Promise<void>;
|
|
32
|
+
pause(sessionId: string): Promise<void>;
|
|
33
|
+
/** Clears a pause and nothing else — a kill survives a resume. */
|
|
34
|
+
resume(sessionId: string): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Refuse one tool for this session. Not part of `ControlBusPort` — the loop
|
|
37
|
+
* never writes it, it only reads the command — but present because the Redis
|
|
38
|
+
* bus has it and an operator on a no-Redis deploy needs the same lever.
|
|
39
|
+
*/
|
|
40
|
+
disableTool(sessionId: string, toolName: string): Promise<void>;
|
|
41
|
+
/** Drop any command, whatever it is. Mirrors `RedisControlBus.clear`. */
|
|
42
|
+
clear(sessionId: string): Promise<void>;
|
|
43
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory ControlBusPort — kill / pause / disable-tool with no Redis.
|
|
3
|
+
*
|
|
4
|
+
* The bundled counterpart to `RedisControlBus`, held in process memory. It is
|
|
5
|
+
* what makes a single-process deploy (a desktop app, an air-gapped install) or
|
|
6
|
+
* a test able to steer a running agent at all: before س٦ the only bus was a
|
|
7
|
+
* Redis key, so "kill this session" required infrastructure.
|
|
8
|
+
*
|
|
9
|
+
* Fidelity to `RedisControlBus` is the point, and it is fidelity to the
|
|
10
|
+
* *semantics*, not just to the method names — the loop reads a command string
|
|
11
|
+
* and acts on its verb, so a mirror that stored a different vocabulary would be
|
|
12
|
+
* a mirror of nothing:
|
|
13
|
+
* - `kill(id, reason)` stores `kill:{reason}`, default reason `"killed"` —
|
|
14
|
+
* the loop's guard reports the part after the colon.
|
|
15
|
+
* - `pause` stores `"pause"`; `disableTool` stores `disabled:{name}`.
|
|
16
|
+
* - `resume` clears ONLY a pause. A killed session stays killed — the Redis
|
|
17
|
+
* adapter reads before it deletes for exactly this reason, and a mirror
|
|
18
|
+
* that "resumed" a kill would quietly revive an aborted run.
|
|
19
|
+
* - one command per session: a later write replaces an earlier one.
|
|
20
|
+
*
|
|
21
|
+
* Scope: ONE process, and no expiry. The Redis bus gives a kill a 7-day TTL so
|
|
22
|
+
* the record outlives the worker that wrote it; nothing here outlives the
|
|
23
|
+
* process, so there is nothing to expire. For multi-replica deploys — where the
|
|
24
|
+
* whole point is that the killer and the killed are different processes — use
|
|
25
|
+
* `RedisControlBus`.
|
|
26
|
+
*/
|
|
27
|
+
export class InMemoryControlBus {
|
|
28
|
+
commands = new Map();
|
|
29
|
+
async getCommand(sessionId) {
|
|
30
|
+
return this.commands.get(sessionId) ?? null;
|
|
31
|
+
}
|
|
32
|
+
async kill(sessionId, reason = "killed") {
|
|
33
|
+
this.commands.set(sessionId, `kill:${reason}`);
|
|
34
|
+
}
|
|
35
|
+
async pause(sessionId) {
|
|
36
|
+
this.commands.set(sessionId, "pause");
|
|
37
|
+
}
|
|
38
|
+
/** Clears a pause and nothing else — a kill survives a resume. */
|
|
39
|
+
async resume(sessionId) {
|
|
40
|
+
if (this.commands.get(sessionId) === "pause") {
|
|
41
|
+
this.commands.delete(sessionId);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Refuse one tool for this session. Not part of `ControlBusPort` — the loop
|
|
46
|
+
* never writes it, it only reads the command — but present because the Redis
|
|
47
|
+
* bus has it and an operator on a no-Redis deploy needs the same lever.
|
|
48
|
+
*/
|
|
49
|
+
async disableTool(sessionId, toolName) {
|
|
50
|
+
this.commands.set(sessionId, `disabled:${toolName}`);
|
|
51
|
+
}
|
|
52
|
+
/** Drop any command, whatever it is. Mirrors `RedisControlBus.clear`. */
|
|
53
|
+
async clear(sessionId) {
|
|
54
|
+
this.commands.delete(sessionId);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory DedupPort — tool-call idempotency with no Redis.
|
|
3
|
+
*
|
|
4
|
+
* The bundled counterpart to the Redis-backed dedup (`RedisToolDedup`), held in
|
|
5
|
+
* process memory. It serves cached results **by the caller's own hash**, which
|
|
6
|
+
* is the only thing that makes it a mirror rather than a different feature: the
|
|
7
|
+
* hash is `hashToolCall(name, sortedArgs)` in both cases, so a call that Redis
|
|
8
|
+
* would have served from cache is served from cache here, and one it would have
|
|
9
|
+
* executed is executed here.
|
|
10
|
+
*
|
|
11
|
+
* Fidelity to the Redis semantics, in the two places they are not obvious:
|
|
12
|
+
*
|
|
13
|
+
* - **The TTL is per session, and it slides.** The Redis adapter keeps ONE
|
|
14
|
+
* hash per session and calls `EXPIRE` on that whole key at every store, so
|
|
15
|
+
* one busy tool keeps the entire session's cache alive and the whole thing
|
|
16
|
+
* drops together. This does the same: one `expiresAt` per session,
|
|
17
|
+
* refreshed on every store, and an expired session's cache reads as empty.
|
|
18
|
+
* A per-entry TTL would be the tidier design and would answer differently
|
|
19
|
+
* from production on exactly the runs that matter — long ones.
|
|
20
|
+
* - **Stored and returned values are deep-copied.** Redis stores JSON text,
|
|
21
|
+
* so a cached result handed back is always a fresh object. Holding the
|
|
22
|
+
* caller's reference instead would let a consumer that mutates a returned
|
|
23
|
+
* `ToolResult` rewrite the cache under itself, and the difference would only
|
|
24
|
+
* ever show up in production.
|
|
25
|
+
*
|
|
26
|
+
* Scope: ONE process. Two replicas each keep their own cache and will each run
|
|
27
|
+
* a call the other has already run — for shared dedup, use the Redis adapter.
|
|
28
|
+
*/
|
|
29
|
+
import type { DedupPort, ToolResult } from "../core/types.js";
|
|
30
|
+
export declare class InMemoryToolDedup implements DedupPort {
|
|
31
|
+
private readonly sessions;
|
|
32
|
+
/** The live cache for a session, dropping it whole if its TTL has passed. */
|
|
33
|
+
private live;
|
|
34
|
+
check(sessionId: string, hash: string): Promise<ToolResult | null>;
|
|
35
|
+
store(sessionId: string, hash: string, result: ToolResult, ttlSeconds: number): Promise<void>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory DedupPort — tool-call idempotency with no Redis.
|
|
3
|
+
*
|
|
4
|
+
* The bundled counterpart to the Redis-backed dedup (`RedisToolDedup`), held in
|
|
5
|
+
* process memory. It serves cached results **by the caller's own hash**, which
|
|
6
|
+
* is the only thing that makes it a mirror rather than a different feature: the
|
|
7
|
+
* hash is `hashToolCall(name, sortedArgs)` in both cases, so a call that Redis
|
|
8
|
+
* would have served from cache is served from cache here, and one it would have
|
|
9
|
+
* executed is executed here.
|
|
10
|
+
*
|
|
11
|
+
* Fidelity to the Redis semantics, in the two places they are not obvious:
|
|
12
|
+
*
|
|
13
|
+
* - **The TTL is per session, and it slides.** The Redis adapter keeps ONE
|
|
14
|
+
* hash per session and calls `EXPIRE` on that whole key at every store, so
|
|
15
|
+
* one busy tool keeps the entire session's cache alive and the whole thing
|
|
16
|
+
* drops together. This does the same: one `expiresAt` per session,
|
|
17
|
+
* refreshed on every store, and an expired session's cache reads as empty.
|
|
18
|
+
* A per-entry TTL would be the tidier design and would answer differently
|
|
19
|
+
* from production on exactly the runs that matter — long ones.
|
|
20
|
+
* - **Stored and returned values are deep-copied.** Redis stores JSON text,
|
|
21
|
+
* so a cached result handed back is always a fresh object. Holding the
|
|
22
|
+
* caller's reference instead would let a consumer that mutates a returned
|
|
23
|
+
* `ToolResult` rewrite the cache under itself, and the difference would only
|
|
24
|
+
* ever show up in production.
|
|
25
|
+
*
|
|
26
|
+
* Scope: ONE process. Two replicas each keep their own cache and will each run
|
|
27
|
+
* a call the other has already run — for shared dedup, use the Redis adapter.
|
|
28
|
+
*/
|
|
29
|
+
/** Deep copy through JSON — the same serialization boundary Redis imposes. */
|
|
30
|
+
function clone(value) {
|
|
31
|
+
return JSON.parse(JSON.stringify(value));
|
|
32
|
+
}
|
|
33
|
+
export class InMemoryToolDedup {
|
|
34
|
+
sessions = new Map();
|
|
35
|
+
/** The live cache for a session, dropping it whole if its TTL has passed. */
|
|
36
|
+
live(sessionId) {
|
|
37
|
+
const cache = this.sessions.get(sessionId);
|
|
38
|
+
if (!cache)
|
|
39
|
+
return undefined;
|
|
40
|
+
if (cache.expiresAt <= Date.now()) {
|
|
41
|
+
this.sessions.delete(sessionId);
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return cache;
|
|
45
|
+
}
|
|
46
|
+
async check(sessionId, hash) {
|
|
47
|
+
const found = this.live(sessionId)?.entries.get(hash);
|
|
48
|
+
return found ? clone(found) : null;
|
|
49
|
+
}
|
|
50
|
+
async store(sessionId, hash, result, ttlSeconds) {
|
|
51
|
+
const cache = this.live(sessionId) ?? {
|
|
52
|
+
entries: new Map(),
|
|
53
|
+
expiresAt: 0,
|
|
54
|
+
};
|
|
55
|
+
cache.entries.set(hash, clone(result));
|
|
56
|
+
cache.expiresAt = Date.now() + ttlSeconds * 1000;
|
|
57
|
+
this.sessions.set(sessionId, cache);
|
|
58
|
+
}
|
|
59
|
+
}
|