@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/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,285 @@
|
|
|
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.14.0] — 2026-09-01
|
|
16
|
+
|
|
17
|
+
Session ص٤. The Redis-mandatory era ends.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- `AgentConfig.redis` is **optional**. Present: byte-for-byte today's
|
|
22
|
+
behavior. Absent with all four ports injected (memory, controlBus, lock,
|
|
23
|
+
dedup): the loop runs with no Redis at all. Absent with any port missing:
|
|
24
|
+
`createAgent` fails immediately with an error that names the missing
|
|
25
|
+
port — and only it — and points at the `/adapters` in-memory
|
|
26
|
+
implementations. Absence is read from the VALUE (`=== undefined`), so a
|
|
27
|
+
host spreading `redis: undefined` gets the named error, not a TypeError
|
|
28
|
+
from the internals.
|
|
29
|
+
|
|
30
|
+
## [0.13.0] — 2026-09-01
|
|
31
|
+
|
|
32
|
+
Session س٦ (both rounds). The loop can now run with no Redis at all — and
|
|
33
|
+
the audit log names the model that actually answered.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- Three injected ports on the س١ pattern: `ControlBusPort`, `RunLockPort`
|
|
38
|
+
(named to avoid `@msm-core/jobs`'s `LockPort` — the reason lives in the
|
|
39
|
+
port's own comment), `DedupPort` — `AgentConfig` accepts them optionally;
|
|
40
|
+
absent means the bundled Redis trio exactly as before. In-memory adapters
|
|
41
|
+
for all three; with all four ports injected the loop measurably issues
|
|
42
|
+
ZERO Redis calls.
|
|
43
|
+
- Model truth: the four brains fill `BrainPayload.model` from the
|
|
44
|
+
provider's own reply — streamed and non-streamed both — and the loop
|
|
45
|
+
writes it to `model_response.data.model` (see `@msm-core/session` 0.3.0
|
|
46
|
+
for the event contract). `respondingModel` exported.
|
|
47
|
+
|
|
48
|
+
### Notes
|
|
49
|
+
|
|
50
|
+
- "Who injects a port injects its isolation" extends to the three new
|
|
51
|
+
ports, guarded behaviorally with two tenants.
|
|
52
|
+
|
|
53
|
+
## [0.12.0] — 2026-09-01
|
|
54
|
+
|
|
55
|
+
Session ص٣, entry with the bump (the new ledger guard enforces what
|
|
56
|
+
Circular 2 asked by hand).
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- The reset channel reaches `AgentHooks.onChunk`: `ChunkInfo.reset?: true`,
|
|
61
|
+
`fireChunk` spreads it, and a hooks consumer opts in with
|
|
62
|
+
`acceptChunkResets(hook)` (`isResetAwareHook`, `ResetAwareChunkHook`
|
|
63
|
+
exported). ص٢'s covenant one layer up: an undeclared consumer's chunks
|
|
64
|
+
are key-for-key what they always were — reset never reaches anyone who
|
|
65
|
+
did not ask for it.
|
|
66
|
+
- Ledger guards in mini/session/replay/mcp: `package.json.version` must
|
|
67
|
+
equal the CHANGELOG head — no seventh unrecorded release.
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
|
|
71
|
+
- mcp's spawned-process tests comply with Council Circular 1 (stderr in
|
|
72
|
+
every failure text, named waits, declared readiness) — diagnostics only;
|
|
73
|
+
the 98 tests and their assertions are unchanged.
|
|
74
|
+
|
|
75
|
+
## [0.11.0] — 2026-09-01
|
|
76
|
+
|
|
77
|
+
Session م١, entry by management at acceptance (Circular 2: the ledger rides
|
|
78
|
+
with the bump, not behind it).
|
|
79
|
+
|
|
80
|
+
### Added
|
|
81
|
+
|
|
82
|
+
- `loadSkills(dir, log?)` in the definition layer — Claude-style skill packs
|
|
83
|
+
(`skills/<pack>/SKILL.md` folders and single `.md` files) read into one
|
|
84
|
+
`## Skills` markdown block for the host to append to an agent definition.
|
|
85
|
+
Lifted from nisus's `runtime/skills/loader.ts` and hardened: deterministic
|
|
86
|
+
code-unit ordering (platform-independent definitions — replay-stable
|
|
87
|
+
fingerprints), an injected `SkillsLogPort` instead of console, and empty
|
|
88
|
+
skill files contribute nothing rather than a bare separator (a declared,
|
|
89
|
+
parity-tested divergence from the original). Composition stays with the
|
|
90
|
+
host: the code moved, the wiring did not — zero loop changes.
|
|
91
|
+
|
|
92
|
+
## [0.10.0] — 2026-09-01
|
|
93
|
+
|
|
94
|
+
Session ص٢. A retried stream can finally repair the display it interrupted.
|
|
95
|
+
|
|
96
|
+
### Added
|
|
97
|
+
|
|
98
|
+
- **`BrainChunk.reset` — a retried call can tell you to clear what you drew.**
|
|
99
|
+
Since 0.9.1 a retried brain call stops re-emitting text you already saw, which
|
|
100
|
+
never truncates the payload but does leave the display frozen at the cut. A
|
|
101
|
+
consumer that *can* clear what it has drawn now opts in **on its sink**:
|
|
102
|
+
`acceptResets(sink)` marks it, `isResetAware(sink)` reports it, and
|
|
103
|
+
`ResetAwareChunkSink` is the type. A marked sink gets `reset: true` on the
|
|
104
|
+
first chunk after a retry and redraws the answer whole. **An unmarked sink is
|
|
105
|
+
on 0.9.1's path byte for byte** — the opt-in is the sink's, so a consumer that
|
|
106
|
+
does nothing sees nothing new.
|
|
107
|
+
- New exports: `acceptResets`, `isResetAware`, type `ResetAwareChunkSink`.
|
|
108
|
+
|
|
109
|
+
### Changed
|
|
110
|
+
|
|
111
|
+
- Internal, no surface: every package's test tree is now type-checked by `tsc`
|
|
112
|
+
inside `npm test` (17 of 53 test files were under a compiler before; 54 of 54
|
|
113
|
+
now). This is why a type error can no longer sit green for months.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## [0.9.1] — 2026-09-01
|
|
118
|
+
|
|
119
|
+
Session ص١ — three internal debts, two of them visible from outside.
|
|
120
|
+
|
|
121
|
+
### Fixed
|
|
122
|
+
|
|
123
|
+
- **A stamped tool's `destructive` / `category` now reach the model.**
|
|
124
|
+
`toToolDefinitions` declared both fields and dropped them on the way out, so a
|
|
125
|
+
tool marked destructive by the MCP adapter or by `createDelegateTool` was
|
|
126
|
+
described to the provider like any other tool. They are forwarded now.
|
|
127
|
+
- **A retried brain call no longer streams the same words twice.** `withRetry`
|
|
128
|
+
restarted the provider call and the new attempt re-emitted text the consumer
|
|
129
|
+
had already rendered. The gate is keyed on **what was delivered**, not on the
|
|
130
|
+
attempt number: what reaches your `onChunk` is always a prefix of the final
|
|
131
|
+
payload — never longer than it, never contradicting it — and the worst case is
|
|
132
|
+
a display that stops where the cut happened rather than one that repeats or
|
|
133
|
+
truncates mid-sentence. Display only; `LoopOutcome` was always correct.
|
|
134
|
+
|
|
135
|
+
### Changed
|
|
136
|
+
|
|
137
|
+
- Internal: `mini`'s test tree is type-checked on every `npm test`. Nine
|
|
138
|
+
long-standing type errors in old tests were fixed **types only** — no test's
|
|
139
|
+
behavior and no test count changed.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## [0.9.0] — 2026-09-01
|
|
144
|
+
|
|
145
|
+
Session ر٢. Delegation stops being a dead union member and becomes a tool.
|
|
146
|
+
|
|
147
|
+
### Removed — ⚠️ a type breaks; no runtime does
|
|
148
|
+
|
|
149
|
+
- **`"delegate"` is gone from the `BrainOrchestration.action` union.** It had
|
|
150
|
+
been declared since 0.1.0 and **the loop never executed it** — a brain
|
|
151
|
+
returning it fell through to the unknown-action path. Measured before the
|
|
152
|
+
removal: zero occurrences in `mini/src`, zero in nisus, and none of the four
|
|
153
|
+
brains ever emitted it. If you have a third-party brain that returns
|
|
154
|
+
`action: "delegate"`, it no longer type-checks, and at runtime it behaves
|
|
155
|
+
exactly as it always did: a terminal `respond` that delivers text and cannot
|
|
156
|
+
smuggle a tool call past the dispatcher (now pinned by a guard).
|
|
157
|
+
|
|
158
|
+
### Added
|
|
159
|
+
|
|
160
|
+
- **`createDelegateTool(delegates, opts?)` — one agent asks another, as an
|
|
161
|
+
ordinary `Tool`.** It returns a plain `call_agent` tool over
|
|
162
|
+
`Record<string, Agent>`, with the delegate names as an `enum` parameter so the
|
|
163
|
+
model cannot invent one. The child's session id is **derived**, not minted:
|
|
164
|
+
`${sessionId}.d.${name}` — deterministic, readable in a log, identical across
|
|
165
|
+
runs. The parent's `tenantContext` is inherited by the child.
|
|
166
|
+
`opts.maxDepth` (default 1) **fails closed** with a named `failed` result
|
|
167
|
+
rather than an exception. The child's cost comes back inside the tool result,
|
|
168
|
+
visible and not swallowed — it is deliberately *not* folded into the parent's
|
|
169
|
+
total, because counting it twice is worse than not counting it.
|
|
170
|
+
`opts.requiresApproval` / `destructive` / `category` stamp the definition, so
|
|
171
|
+
fail-closed approval covers a delegation with no new code.
|
|
172
|
+
- New exports: `createDelegateTool`, `DELEGATE_TOOL_NAME`, types `DelegateTool`,
|
|
173
|
+
`DelegateToolOptions`.
|
|
174
|
+
- **Zero loop changes.** A delegation passes guards, approval, dedup and the
|
|
175
|
+
session log exactly like any other tool — which is the whole argument for a
|
|
176
|
+
tool over an action.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## [0.8.0] — 2026-09-01
|
|
181
|
+
|
|
182
|
+
Sessions ض١ and ب١. Two optional seats: one for compaction, one for streaming.
|
|
183
|
+
|
|
184
|
+
### Added
|
|
185
|
+
|
|
186
|
+
- **Streaming — `BrainRunInput.onChunk` and `AgentHooks.onChunk` (ب١).** All
|
|
187
|
+
four brains stream natively, each behind one branch; `BrainChunk` is
|
|
188
|
+
`{ text }`, and `ChunkInfo` adds `{ sessionId, iteration }` at the hook. Token
|
|
189
|
+
usage still arrives on the streamed path (`stream_options.include_usage`), so
|
|
190
|
+
`costCapPerTask` keeps working while streaming. **Streaming is display, not
|
|
191
|
+
truth**: no stream event is written to the session log, and
|
|
192
|
+
`@msm-core/replay`'s fingerprint is blind to `onChunk` — a run someone watched
|
|
193
|
+
and a run nobody watched are the same run. Without a handler the path is
|
|
194
|
+
literally the previous one, a single `if` apart.
|
|
195
|
+
- **Compaction as an injected seat — `AgentConfig.compaction?: CompactionPort`
|
|
196
|
+
(ض١).** The port decides when a long conversation is summarised;
|
|
197
|
+
`createBrainCompactor(brain, opts?)` is one reference occupant that summarises
|
|
198
|
+
with a model you provide. The summary is written to the session log as a
|
|
199
|
+
`compaction` event, so it is auditable and re-derivable rather than a silent
|
|
200
|
+
trim, and a boundary never severs a tool call from its result
|
|
201
|
+
(`CompactionRangeError` names the attempt). **Without a `compaction` port
|
|
202
|
+
nothing changes**; without a `sessionLog` the seat is inert, declared.
|
|
203
|
+
- New exports: `createBrainCompactor`; types `BrainCompactorOptions`,
|
|
204
|
+
`CompactionPort`, `CompactionDecision`, `ContextBudget`, `BrainChunk`,
|
|
205
|
+
`ChunkInfo`, `BrainToolCall`, `BrainOrchestration`.
|
|
206
|
+
|
|
207
|
+
### Changed
|
|
208
|
+
|
|
209
|
+
- Requires `@msm-core/session` `^0.2.0` — the `compaction` event is the tenth
|
|
210
|
+
member of that package's closed event union.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## [0.7.0] — 2026-09-01
|
|
215
|
+
|
|
216
|
+
Sessions س٣ and س٤. Context starts being *derived*, and a step stops meaning one
|
|
217
|
+
tool.
|
|
218
|
+
|
|
219
|
+
### Added
|
|
220
|
+
|
|
221
|
+
- **`AgentConfig.sessionLog?: SessionLogPort` — the loop writes an event log and
|
|
222
|
+
reads its context back out of it (س٣).** Injected, it inverts where
|
|
223
|
+
conversation context comes from: `deriveMessages(read())` instead of the
|
|
224
|
+
stored history array, with the same history budget applied. Events are written
|
|
225
|
+
**where they happen** — the user's message, each step, the raw pre-gate model
|
|
226
|
+
response, tool call/result pairs on every tool path (including refused and
|
|
227
|
+
unknown tools), guards that fired, the post-gate assistant message, and the
|
|
228
|
+
step's end on every exit including the `catch`. An invariant checks that every
|
|
229
|
+
field of `BrainRunInput` has exactly one counterpart in the log, and it runs
|
|
230
|
+
**before** the request goes to the model — a request that cannot be rebuilt
|
|
231
|
+
from the log should not be paid for. A rejected append fails the turn: the log
|
|
232
|
+
is the truth. **Absent, behavior is the previous version's bit for bit**;
|
|
233
|
+
`appendHistory` still runs, and the double write is transitional.
|
|
234
|
+
- **`BrainOrchestration.tool_calls?: BrainToolCall[]` — many tools in ONE model
|
|
235
|
+
round-trip (س٤).** Three tools now cost two round-trips instead of four.
|
|
236
|
+
**A pure addition**: a brain that fills only `tool_name` / `tool_params` is
|
|
237
|
+
normalised to a single-call step and takes exactly the path it always took,
|
|
238
|
+
and a brain that fills the array fills those two with `tool_calls[0]` as well.
|
|
239
|
+
The covenant is one-directional — new readers may read the array, old readers
|
|
240
|
+
never find a hole where the first call used to be. Approval fails closed **per
|
|
241
|
+
call**, `onBeforeTool` and dedup run per call, and a failing sibling does not
|
|
242
|
+
kill the others.
|
|
243
|
+
- New type `BrainToolCall`. New dependency: `@msm-core/session`.
|
|
244
|
+
|
|
245
|
+
### Changed
|
|
246
|
+
|
|
247
|
+
- **Tool turns are role-tagged deterministically across all four providers**
|
|
248
|
+
(`[tool_call:<id>]` / `[tool_result:<id>]`) instead of being flattened into a
|
|
249
|
+
bare `user` message. ⚠️ This changes the prompt text your provider receives: a
|
|
250
|
+
model reading back its own tool history can now tell which side was which.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## [0.6.0] — 2026-09-01
|
|
255
|
+
|
|
256
|
+
Session س١. Session memory becomes a port.
|
|
257
|
+
|
|
258
|
+
### Added
|
|
259
|
+
|
|
260
|
+
- **`AgentConfig.memory?: SessionStore` — inject your own session store.** The
|
|
261
|
+
loop used to build `new RedisMemory(...)` by hand in two places that had
|
|
262
|
+
already drifted apart from each other in silence. There is now one port (six
|
|
263
|
+
functions, taken at `RedisMemory`'s exact signatures) and one construction
|
|
264
|
+
site: an injected store wins, and absent one `RedisMemory` is built exactly as
|
|
265
|
+
before. `RedisMemory` implements `SessionStore`, so an existing configuration
|
|
266
|
+
needs no change.
|
|
267
|
+
- `InMemorySessionStore` in `@msm-core/mini/adapters` — a faithful RAM adapter
|
|
268
|
+
(tail limit, 500-entry cap, deep copy on both sides, so a returned reference
|
|
269
|
+
cannot be mutated into the store).
|
|
270
|
+
- New exported types: `SessionStore`; and `SessionMetadata`, moved to
|
|
271
|
+
`core/types` with a back-compatible re-export from its old home (zero measured
|
|
272
|
+
breaks).
|
|
273
|
+
|
|
274
|
+
### Note — the covenant that arrives with the port
|
|
275
|
+
|
|
276
|
+
**Whoever injects a store injects its tenant isolation with it.** The loop
|
|
277
|
+
cannot enforce the `{prefix}:{companyId}:{agentType}` rule on a port it cannot
|
|
278
|
+
see into, so a store you supply owns its own tenant separation. The built-in
|
|
279
|
+
`RedisMemory` path is unchanged and still guarded.
|
|
280
|
+
|
|
281
|
+
`redis` remains required either way: the control bus, the distributed lock and
|
|
282
|
+
the dedup cache are still Redis-backed by direct construction, so "a loop with
|
|
283
|
+
no Redis at all" is not yet possible.
|
|
284
|
+
|
|
6
285
|
---
|
|
7
286
|
|
|
8
287
|
## [0.5.2] — 2026-07-01
|
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
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory RunLockPort — a session mutex with no Redis.
|
|
3
|
+
*
|
|
4
|
+
* **This one has to actually lock.** A stub that always grants would make every
|
|
5
|
+
* test of the no-Redis loop pass while removing the guarantee the lock exists
|
|
6
|
+
* for: one turn per session at a time. Two turns running into one session
|
|
7
|
+
* interleave their history writes and their log appends, and the damage is
|
|
8
|
+
* silent — a log with two turns braided together still reads as a log. So the
|
|
9
|
+
* in-RAM lock is a real mutex, and it is guarded by a test that starts a second
|
|
10
|
+
* turn while the first is still inside the brain call and watches it wait.
|
|
11
|
+
*
|
|
12
|
+
* Fidelity to `RedisDistributedLock`, semantics first:
|
|
13
|
+
* - **held is held**: `acquire` returns `null` for a session already locked,
|
|
14
|
+
* rather than a second handle.
|
|
15
|
+
* - **TTL is honoured**: a lock past its `ttlMs` is treated as gone, exactly
|
|
16
|
+
* as `SET PX` lets Redis drop it. A RAM lock that never expired would
|
|
17
|
+
* deadlock a session that Redis would have freed — the mirror would be
|
|
18
|
+
* safer than the original, which is its own kind of lie.
|
|
19
|
+
* - **release and extend are token-checked**: only the holder's own handle
|
|
20
|
+
* can release or extend, which is what the `GET == token` Lua scripts buy
|
|
21
|
+
* on the Redis side. A late release from a previous holder whose lock has
|
|
22
|
+
* already expired must not unlock the current one.
|
|
23
|
+
* - **the retry loop is the same shape**: attempt, jittered sleep, deadline —
|
|
24
|
+
* and the same error message when the deadline passes, because that string
|
|
25
|
+
* is what surfaces from the loop when a session is genuinely stuck.
|
|
26
|
+
*
|
|
27
|
+
* Scope: ONE process. Across replicas this excludes nothing at all, and that is
|
|
28
|
+
* not a limitation to work around — a mutex for a single-process deploy is the
|
|
29
|
+
* whole of what it claims to be. Multi-replica deploys use
|
|
30
|
+
* `RedisDistributedLock`.
|
|
31
|
+
*/
|
|
32
|
+
import type { LockHandle, RunLockPort } from "../core/types.js";
|
|
33
|
+
export declare class InMemoryLock implements RunLockPort {
|
|
34
|
+
private readonly held;
|
|
35
|
+
/** The live holder of this session, or `undefined` if free or expired. */
|
|
36
|
+
private current;
|
|
37
|
+
/**
|
|
38
|
+
* Take the lock, or return `null` if someone else holds it.
|
|
39
|
+
* Not required by `RunLockPort` — the loop only ever waits — but it is the
|
|
40
|
+
* primitive `acquireWithRetry` is built from, and `RedisDistributedLock`
|
|
41
|
+
* exposes it too.
|
|
42
|
+
*/
|
|
43
|
+
acquire(sessionId: string, ttlMs: number): Promise<LockHandle | null>;
|
|
44
|
+
acquireWithRetry(sessionId: string, ttlMs: number, waitMs?: number, retryInterval?: number): Promise<LockHandle>;
|
|
45
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory RunLockPort — a session mutex with no Redis.
|
|
3
|
+
*
|
|
4
|
+
* **This one has to actually lock.** A stub that always grants would make every
|
|
5
|
+
* test of the no-Redis loop pass while removing the guarantee the lock exists
|
|
6
|
+
* for: one turn per session at a time. Two turns running into one session
|
|
7
|
+
* interleave their history writes and their log appends, and the damage is
|
|
8
|
+
* silent — a log with two turns braided together still reads as a log. So the
|
|
9
|
+
* in-RAM lock is a real mutex, and it is guarded by a test that starts a second
|
|
10
|
+
* turn while the first is still inside the brain call and watches it wait.
|
|
11
|
+
*
|
|
12
|
+
* Fidelity to `RedisDistributedLock`, semantics first:
|
|
13
|
+
* - **held is held**: `acquire` returns `null` for a session already locked,
|
|
14
|
+
* rather than a second handle.
|
|
15
|
+
* - **TTL is honoured**: a lock past its `ttlMs` is treated as gone, exactly
|
|
16
|
+
* as `SET PX` lets Redis drop it. A RAM lock that never expired would
|
|
17
|
+
* deadlock a session that Redis would have freed — the mirror would be
|
|
18
|
+
* safer than the original, which is its own kind of lie.
|
|
19
|
+
* - **release and extend are token-checked**: only the holder's own handle
|
|
20
|
+
* can release or extend, which is what the `GET == token` Lua scripts buy
|
|
21
|
+
* on the Redis side. A late release from a previous holder whose lock has
|
|
22
|
+
* already expired must not unlock the current one.
|
|
23
|
+
* - **the retry loop is the same shape**: attempt, jittered sleep, deadline —
|
|
24
|
+
* and the same error message when the deadline passes, because that string
|
|
25
|
+
* is what surfaces from the loop when a session is genuinely stuck.
|
|
26
|
+
*
|
|
27
|
+
* Scope: ONE process. Across replicas this excludes nothing at all, and that is
|
|
28
|
+
* not a limitation to work around — a mutex for a single-process deploy is the
|
|
29
|
+
* whole of what it claims to be. Multi-replica deploys use
|
|
30
|
+
* `RedisDistributedLock`.
|
|
31
|
+
*/
|
|
32
|
+
import { randomBytes } from "crypto";
|
|
33
|
+
export class InMemoryLock {
|
|
34
|
+
held = new Map();
|
|
35
|
+
/** The live holder of this session, or `undefined` if free or expired. */
|
|
36
|
+
current(sessionId) {
|
|
37
|
+
const entry = this.held.get(sessionId);
|
|
38
|
+
if (!entry)
|
|
39
|
+
return undefined;
|
|
40
|
+
if (entry.expiresAt <= Date.now()) {
|
|
41
|
+
this.held.delete(sessionId);
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return entry;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Take the lock, or return `null` if someone else holds it.
|
|
48
|
+
* Not required by `RunLockPort` — the loop only ever waits — but it is the
|
|
49
|
+
* primitive `acquireWithRetry` is built from, and `RedisDistributedLock`
|
|
50
|
+
* exposes it too.
|
|
51
|
+
*/
|
|
52
|
+
async acquire(sessionId, ttlMs) {
|
|
53
|
+
if (this.current(sessionId))
|
|
54
|
+
return null;
|
|
55
|
+
const token = randomBytes(16).toString("hex");
|
|
56
|
+
this.held.set(sessionId, { token, expiresAt: Date.now() + ttlMs });
|
|
57
|
+
const ownsIt = () => this.current(sessionId)?.token === token;
|
|
58
|
+
return {
|
|
59
|
+
release: async () => {
|
|
60
|
+
// Token-checked: a handle whose lock already expired and was retaken
|
|
61
|
+
// must not release the new holder's lock.
|
|
62
|
+
if (ownsIt())
|
|
63
|
+
this.held.delete(sessionId);
|
|
64
|
+
},
|
|
65
|
+
extend: async (newTtlMs) => {
|
|
66
|
+
if (!ownsIt())
|
|
67
|
+
return false;
|
|
68
|
+
this.held.set(sessionId, {
|
|
69
|
+
token,
|
|
70
|
+
expiresAt: Date.now() + newTtlMs,
|
|
71
|
+
});
|
|
72
|
+
return true;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
async acquireWithRetry(sessionId, ttlMs, waitMs = 5000, retryInterval = 100) {
|
|
77
|
+
const deadline = Date.now() + waitMs;
|
|
78
|
+
while (Date.now() < deadline) {
|
|
79
|
+
const handle = await this.acquire(sessionId, ttlMs);
|
|
80
|
+
if (handle)
|
|
81
|
+
return handle;
|
|
82
|
+
await sleep(retryInterval + Math.random() * 50);
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`msm-mini: could not acquire lock for session ${sessionId} within ${waitMs}ms`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function sleep(ms) {
|
|
88
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
89
|
+
}
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* Checked on every loop iteration before the brain call.
|
|
8
8
|
*/
|
|
9
9
|
import type { RedisLike } from "./redis-types.js";
|
|
10
|
-
|
|
10
|
+
import type { ControlBusPort } from "../core/types.js";
|
|
11
|
+
export declare class RedisControlBus implements ControlBusPort {
|
|
11
12
|
private readonly redis;
|
|
12
13
|
private readonly prefix;
|
|
13
14
|
constructor(redis: RedisLike, prefix?: string);
|