@msm-core/mini 0.5.1 → 0.8.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 +25 -0
- package/dist/adapters/index.d.ts +4 -0
- package/dist/adapters/index.js +2 -0
- package/dist/adapters/memory-store.d.ts +38 -0
- package/dist/adapters/memory-store.js +73 -0
- package/dist/adapters/redis-memory.d.ts +13 -8
- package/dist/adapters/redis-memory.js +5 -0
- package/dist/brain/anthropic.js +50 -17
- package/dist/brain/gemini.js +68 -35
- package/dist/brain/ollama.js +68 -19
- package/dist/brain/openai.js +57 -23
- package/dist/brain/pricing.js +1 -0
- package/dist/brain/streaming.d.ts +315 -0
- package/dist/brain/streaming.js +439 -0
- package/dist/brain/tool-context.d.ts +50 -2
- package/dist/brain/tool-context.js +88 -0
- package/dist/core/context-builder.d.ts +11 -0
- package/dist/core/context-builder.js +11 -1
- package/dist/core/hooks.d.ts +10 -0
- package/dist/core/hooks.js +14 -0
- package/dist/core/loop.d.ts +43 -1
- package/dist/core/loop.js +749 -98
- package/dist/core/types.d.ts +223 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -0
- package/package.json +11 -11
package/dist/core/loop.js
CHANGED
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
* budget exhausted → force_respond → done
|
|
9
9
|
*/
|
|
10
10
|
import { randomBytes } from "crypto";
|
|
11
|
+
import { compactionAnchor, compactionBoundary, deriveMessages, } from "@msm-core/session";
|
|
12
|
+
import { toWireMessage } from "../brain/tool-context.js";
|
|
11
13
|
import { resolveGuards, checkGuards, hasHardBlock, hardAction, } from "./guards.js";
|
|
12
14
|
import { runGates } from "./gates.js";
|
|
13
|
-
import { buildContext } from "./context-builder.js";
|
|
14
|
-
import { fireIteration, fireToolCall, fireGuard, fireFatalError, fireSectionComplete, } from "./hooks.js";
|
|
15
|
+
import { buildContext, DEFAULT_BUDGET } from "./context-builder.js";
|
|
16
|
+
import { fireChunk, fireIteration, fireToolCall, fireGuard, fireFatalError, fireSectionComplete, } from "./hooks.js";
|
|
15
17
|
import { RedisMemory, connectRedis } from "../adapters/redis-memory.js";
|
|
16
18
|
import { RedisControlBus } from "../adapters/redis-control.js";
|
|
17
19
|
import { RedisDistributedLock } from "../adapters/redis-lock.js";
|
|
@@ -20,6 +22,289 @@ import { parseDefinition } from "../definition/parser.js";
|
|
|
20
22
|
function makeTaskId() {
|
|
21
23
|
return randomBytes(8).toString("hex");
|
|
22
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* How many past tool results ride along on a brain call. One constant, because
|
|
27
|
+
* the loop applies it and the invariant has to reproduce it — two copies of `5`
|
|
28
|
+
* would drift and the invariant would then fail on a difference that is not a
|
|
29
|
+
* difference.
|
|
30
|
+
*/
|
|
31
|
+
const TOOL_RESULT_WINDOW = 5;
|
|
32
|
+
/**
|
|
33
|
+
* The budget the derived history will ACTUALLY be trimmed to — the loop's own
|
|
34
|
+
* `historyLimit` (derived from `maxIterations`) reconciled with the context
|
|
35
|
+
* builder's `maxHistoryMessages`, because the request passes through both cuts.
|
|
36
|
+
*
|
|
37
|
+
* A compactor is told this number and not `DEFAULT_BUDGET`, because the number
|
|
38
|
+
* that matters to it is the one that decides what gets dropped. Being told 20
|
|
39
|
+
* while the real cap is 8 would have it compact eleven messages too late.
|
|
40
|
+
*/
|
|
41
|
+
function effectiveBudget(historyLimit) {
|
|
42
|
+
return {
|
|
43
|
+
maxTokens: DEFAULT_BUDGET.maxTokens,
|
|
44
|
+
maxHistoryMessages: Math.min(historyLimit, DEFAULT_BUDGET.maxHistoryMessages),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// ═══ One step, N tool calls (س٤) ══════════════════════════════════════════════
|
|
48
|
+
/** `{}` for anything that is not an object — a call still runs and still fails validation. */
|
|
49
|
+
function asParams(value) {
|
|
50
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
51
|
+
? value
|
|
52
|
+
: {};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The calls of ONE step, normalized — the single reading of a model response
|
|
56
|
+
* that both the log and the dispatcher use.
|
|
57
|
+
*
|
|
58
|
+
* Two shapes arrive here and exactly one leaves:
|
|
59
|
+
*
|
|
60
|
+
* - `tool_calls` present → every call, in the model's order.
|
|
61
|
+
* - only `tool_name` → one call. Every brain written before س٤, and
|
|
62
|
+
* every scripted brain in the suite, lands here and
|
|
63
|
+
* takes the path it always took.
|
|
64
|
+
*
|
|
65
|
+
* It is pure and it is called TWICE per step — once to write
|
|
66
|
+
* `model_response.toolCalls`, once to dispatch — because the alternative is
|
|
67
|
+
* threading a list between two places that must agree on ordinals, and a
|
|
68
|
+
* `callId` that disagrees with the call it names is a log that cannot be
|
|
69
|
+
* replayed. One function, one answer, no drift.
|
|
70
|
+
*
|
|
71
|
+
* Entries with a non-string `name` are dropped rather than carried: they are
|
|
72
|
+
* not callable by any path, and keeping them would shift the ordinals of the
|
|
73
|
+
* calls that ARE callable. An EMPTY name survives — that is a call the model
|
|
74
|
+
* genuinely made to a tool that does not exist, and it is recorded and answered
|
|
75
|
+
* as such.
|
|
76
|
+
*/
|
|
77
|
+
function stepToolCalls(orch) {
|
|
78
|
+
const declared = orch.tool_calls;
|
|
79
|
+
if (Array.isArray(declared) && declared.length > 0) {
|
|
80
|
+
return declared
|
|
81
|
+
.filter((c) => c !== null && typeof c === "object" && typeof c.name === "string")
|
|
82
|
+
.map((c) => ({ name: c.name, params: asParams(c.params) }));
|
|
83
|
+
}
|
|
84
|
+
return orch.tool_name
|
|
85
|
+
? [{ name: orch.tool_name, params: asParams(orch.tool_params) }]
|
|
86
|
+
: [];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The loop's writer for ONE turn: it mints the identifiers, keeps the sequence,
|
|
90
|
+
* and appends.
|
|
91
|
+
*
|
|
92
|
+
* Identifiers are minted here and not in `@msm-core/session` on purpose — that
|
|
93
|
+
* package is pure. They are *structured* rather than opaque: `t{seq}` for the
|
|
94
|
+
* turn, then `turn.sN` per step and `turn.sN.cM` per call. Unique within the
|
|
95
|
+
* session by construction, and readable in an audit trail, where `t41.s2.c1`
|
|
96
|
+
* says what it is and a random hex string would not.
|
|
97
|
+
*
|
|
98
|
+
* ── Why the turn id is DERIVED and no longer random (ض١ §6, س٥'s lesson) ────
|
|
99
|
+
*
|
|
100
|
+
* It used to be `t${randomBytes(8)}`. It is now `t${head + 1}` — the `seq` this
|
|
101
|
+
* turn's first event will occupy. A random id per turn kills every comparison
|
|
102
|
+
* that crosses a run: a tape fingerprint, a golden log, a diff of two sessions
|
|
103
|
+
* that should be identical. `@msm-core/replay` had to *normalise* `toolCallId`
|
|
104
|
+
* to its order of appearance precisely because the value underneath was
|
|
105
|
+
* arbitrary; deriving the id removes the arbitrariness at the source instead of
|
|
106
|
+
* papering over it downstream.
|
|
107
|
+
*
|
|
108
|
+
* It is still unique: `seq` rises by exactly one per session and never repeats,
|
|
109
|
+
* so no two turns that write anything can claim the same head. (A turn that
|
|
110
|
+
* writes NOTHING leaves the head where it was and the next turn takes the same
|
|
111
|
+
* id — harmless, because the first turn put no event in the log to collide
|
|
112
|
+
* with.) And it is still meaningful: `t41` says the turn opened at seq 41.
|
|
113
|
+
*
|
|
114
|
+
* `seq` is held in memory for the turn and starts at `head + 1`. Two writers on
|
|
115
|
+
* one session would collide — which is why this is created *after* the session
|
|
116
|
+
* lock is held, and why a collision raises `SeqGapError` instead of silently
|
|
117
|
+
* renumbering.
|
|
118
|
+
*/
|
|
119
|
+
class TurnWriter {
|
|
120
|
+
turnId;
|
|
121
|
+
#seq;
|
|
122
|
+
#log;
|
|
123
|
+
#sessionId;
|
|
124
|
+
constructor(log, sessionId, head) {
|
|
125
|
+
this.#log = log;
|
|
126
|
+
this.#sessionId = sessionId;
|
|
127
|
+
this.#seq = head;
|
|
128
|
+
this.turnId = `t${head + 1}`;
|
|
129
|
+
}
|
|
130
|
+
/** The id of step `index` (1-based) in this turn. */
|
|
131
|
+
stepId(index) {
|
|
132
|
+
return `${this.turnId}.s${index}`;
|
|
133
|
+
}
|
|
134
|
+
/** The id of the `ordinal`-th (1-based) tool call of a step. */
|
|
135
|
+
callId(stepId, ordinal) {
|
|
136
|
+
return `${stepId}.c${ordinal}`;
|
|
137
|
+
}
|
|
138
|
+
/** Append a turn-scoped event. Returns the `seq` it was written at. */
|
|
139
|
+
async writeTurn(type, data, time = Date.now()) {
|
|
140
|
+
return this.#append({ type, data, time });
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Append a step-scoped event. `stepId` is required by the signature, not by a
|
|
144
|
+
* runtime check — the seven step types cannot be written without one.
|
|
145
|
+
*/
|
|
146
|
+
async writeStep(type, stepId, data, time = Date.now()) {
|
|
147
|
+
return this.#append({ type, data, time, stepId });
|
|
148
|
+
}
|
|
149
|
+
async #append(parts) {
|
|
150
|
+
const seq = this.#seq + 1;
|
|
151
|
+
// The cast is the one place the type/data correlation is asserted rather
|
|
152
|
+
// than proved: `writeTurn` / `writeStep` above are what actually pair a
|
|
153
|
+
// type with its payload, and every caller goes through them.
|
|
154
|
+
const event = {
|
|
155
|
+
seq,
|
|
156
|
+
time: parts.time,
|
|
157
|
+
type: parts.type,
|
|
158
|
+
data: parts.data,
|
|
159
|
+
turnId: this.turnId,
|
|
160
|
+
...(parts.stepId !== undefined ? { stepId: parts.stepId } : {}),
|
|
161
|
+
};
|
|
162
|
+
await this.#log.append(this.#sessionId, event);
|
|
163
|
+
// Advance only after the store accepted it — a rejected append must not
|
|
164
|
+
// leave a hole that the next write would silently skip over.
|
|
165
|
+
this.#seq = seq;
|
|
166
|
+
return seq;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Is the invariant armed?
|
|
171
|
+
*
|
|
172
|
+
* Silent by default in production, strict under the test runner. `MSM_SESSION_STRICT`
|
|
173
|
+
* overrides in both directions: set it to turn the check on in a live service
|
|
174
|
+
* (recommended in staging), set it to `0`/`false` to turn it off in a test run.
|
|
175
|
+
*/
|
|
176
|
+
function sessionStrict() {
|
|
177
|
+
const flag = process.env["MSM_SESSION_STRICT"];
|
|
178
|
+
if (flag !== undefined && flag !== "") {
|
|
179
|
+
return flag !== "0" && flag.toLowerCase() !== "false";
|
|
180
|
+
}
|
|
181
|
+
return (process.env["VITEST"] !== undefined || process.env["NODE_ENV"] === "test");
|
|
182
|
+
}
|
|
183
|
+
/** JSON with object keys sorted, so two equal values always compare equal. */
|
|
184
|
+
function stable(value) {
|
|
185
|
+
return (JSON.stringify(value, (_key, val) => {
|
|
186
|
+
if (val === null || typeof val !== "object" || Array.isArray(val))
|
|
187
|
+
return val;
|
|
188
|
+
const source = val;
|
|
189
|
+
const sorted = {};
|
|
190
|
+
for (const key of Object.keys(source).sort())
|
|
191
|
+
sorted[key] = source[key];
|
|
192
|
+
return sorted;
|
|
193
|
+
}) ?? "undefined");
|
|
194
|
+
}
|
|
195
|
+
function invariantError(what, turnId, stepId, fromLog, sent) {
|
|
196
|
+
return new Error(`msm-mini session-log invariant: ${what} is not reconstructible from the ` +
|
|
197
|
+
`session log (turn=${turnId} step=${stepId}). ` +
|
|
198
|
+
`log=${stable(fromLog)} sent=${stable(sent)}`);
|
|
199
|
+
}
|
|
200
|
+
/** The four fields of a tool result that actually reach the model (see `foldToolResults`). */
|
|
201
|
+
function deliveredToolResult(result) {
|
|
202
|
+
return {
|
|
203
|
+
tool: result.tool,
|
|
204
|
+
status: result.status,
|
|
205
|
+
...(result.result !== undefined ? { result: result.result } : {}),
|
|
206
|
+
...(result.error !== undefined ? { error: result.error } : {}),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* **The rule this whole session exists to enforce**: everything handed to the
|
|
211
|
+
* model must be rebuildable from the log alone.
|
|
212
|
+
*
|
|
213
|
+
* `BrainRunInput` carries four things into a model call, and each has exactly
|
|
214
|
+
* one counterpart in the log:
|
|
215
|
+
*
|
|
216
|
+
* | sent | rebuilt from |
|
|
217
|
+
* |------------------|-----------------------------------------------------|
|
|
218
|
+
* | `raw` | this turn's `user_message` |
|
|
219
|
+
* | `system_context` | this step's `model_request.data.system` |
|
|
220
|
+
* | `history` | `deriveMessages(events before this turn)`, same trim |
|
|
221
|
+
* | `tool_results` | this turn's PAIRED `tool_call`/`tool_result` events |
|
|
222
|
+
*
|
|
223
|
+
* The system context needs its own comparison because no message event can
|
|
224
|
+
* produce it: the assembled persona + memories + tool catalogue never passes
|
|
225
|
+
* through the conversation, so it is recorded as audit data or it is lost. One
|
|
226
|
+
* comparison against `deriveMessages` alone would report a difference that is
|
|
227
|
+
* not a difference.
|
|
228
|
+
*
|
|
229
|
+
* Tool results are rebuilt through the **pairing** rule, not from
|
|
230
|
+
* `tool_result` events alone. That is what gives the check teeth: drop the
|
|
231
|
+
* `tool_call` and the pair vanishes from the reconstruction; drop the
|
|
232
|
+
* `tool_result` and it vanishes too. Either half missing is a log that cannot
|
|
233
|
+
* replay the conversation it claims to record.
|
|
234
|
+
*
|
|
235
|
+
* The events are re-read from the store rather than remembered, so a store that
|
|
236
|
+
* loses, reorders or mangles a write is caught here and not at replay time.
|
|
237
|
+
*
|
|
238
|
+
* Throws on any difference; the caller runs it only when `sessionStrict()`.
|
|
239
|
+
*/
|
|
240
|
+
function assertRequestReconstructible(args) {
|
|
241
|
+
const { events, turnId, stepId, historyLimit, sent } = args;
|
|
242
|
+
// The cut is this turn's USER MESSAGE, not this turn's first event.
|
|
243
|
+
//
|
|
244
|
+
// They were the same thing until ض١: the user message opened every turn. A
|
|
245
|
+
// turn that compacts now writes its `compaction` event first, and that event
|
|
246
|
+
// is precisely a statement ABOUT the past — it belongs on the `before` side of
|
|
247
|
+
// the cut, because the history handed to the model was derived with it
|
|
248
|
+
// applied. Cutting at the turn's first event would put the compaction on the
|
|
249
|
+
// `inTurn` side, reconstruct an UNcompacted conversation, and fire the
|
|
250
|
+
// invariant on a difference that is not one.
|
|
251
|
+
//
|
|
252
|
+
// On every path without compaction the two definitions pick the same index,
|
|
253
|
+
// so nothing else moves.
|
|
254
|
+
const turnStart = events.findIndex((e) => e.turnId === turnId && e.type === "user_message");
|
|
255
|
+
const before = turnStart < 0 ? events : events.slice(0, turnStart);
|
|
256
|
+
const inTurn = turnStart < 0 ? [] : events.slice(turnStart);
|
|
257
|
+
// 1 ── the user's message
|
|
258
|
+
const opening = inTurn.find((e) => e.type === "user_message");
|
|
259
|
+
if (!opening || opening.data.text !== sent.raw) {
|
|
260
|
+
throw invariantError("the user message", turnId, stepId, opening?.data.text, sent.raw);
|
|
261
|
+
}
|
|
262
|
+
// 2 ── the assembled system context, read back OUT of the log
|
|
263
|
+
//
|
|
264
|
+
// Its own comparison, because no message event can produce it: the assembled
|
|
265
|
+
// persona + memories + tool catalogue never travels through the conversation,
|
|
266
|
+
// so it is recorded as audit data or it is lost. Checking `deriveMessages`
|
|
267
|
+
// alone would report a difference that is not a difference (س٢ ruling 6).
|
|
268
|
+
let request;
|
|
269
|
+
for (const e of inTurn) {
|
|
270
|
+
if (e.type === "model_request" && e.stepId === stepId)
|
|
271
|
+
request = e;
|
|
272
|
+
}
|
|
273
|
+
if (!request) {
|
|
274
|
+
throw invariantError("the model request", turnId, stepId, null, "a model_request event for this step");
|
|
275
|
+
}
|
|
276
|
+
if (request.data.system !== sent.system_context) {
|
|
277
|
+
throw invariantError("the system context", turnId, stepId, request.data.system, sent.system_context);
|
|
278
|
+
}
|
|
279
|
+
// 3 ── the conversation, derived and trimmed exactly as the request was
|
|
280
|
+
const expectedHistory = deriveMessages(before)
|
|
281
|
+
.slice(-historyLimit)
|
|
282
|
+
.slice(-DEFAULT_BUDGET.maxHistoryMessages);
|
|
283
|
+
if (stable(expectedHistory) !== stable(sent.history)) {
|
|
284
|
+
throw invariantError("the conversation history", turnId, stepId, expectedHistory, sent.history);
|
|
285
|
+
}
|
|
286
|
+
// 4 ── the tool results, rebuilt through the pairing rule
|
|
287
|
+
const called = new Set();
|
|
288
|
+
const paired = [];
|
|
289
|
+
for (const e of inTurn) {
|
|
290
|
+
if (e.type === "tool_call") {
|
|
291
|
+
called.add(e.data.callId);
|
|
292
|
+
}
|
|
293
|
+
else if (e.type === "tool_result" && called.has(e.data.callId)) {
|
|
294
|
+
paired.push(deliveredToolResult({
|
|
295
|
+
tool: e.data.name,
|
|
296
|
+
status: e.data.status,
|
|
297
|
+
...(e.data.result !== undefined ? { result: e.data.result } : {}),
|
|
298
|
+
...(e.data.error !== undefined ? { error: e.data.error } : {}),
|
|
299
|
+
}));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const expectedResults = paired.slice(-TOOL_RESULT_WINDOW);
|
|
303
|
+
const sentResults = (sent.tool_results ?? []).map(deliveredToolResult);
|
|
304
|
+
if (stable(expectedResults) !== stable(sentResults)) {
|
|
305
|
+
throw invariantError("the tool results", turnId, stepId, expectedResults, sentResults);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
23
308
|
/**
|
|
24
309
|
* Create a stateful agent from config.
|
|
25
310
|
* Returns an Agent object — call .handle() on every incoming event.
|
|
@@ -87,19 +372,43 @@ export function createAgent(config) {
|
|
|
87
372
|
? `${prefix}:${event.tenantContext.companyId}:${event.tenantContext.agentType}`
|
|
88
373
|
: prefix;
|
|
89
374
|
}
|
|
375
|
+
/**
|
|
376
|
+
* The session store for one event — the loop's ONLY door to session memory.
|
|
377
|
+
*
|
|
378
|
+
* An injected `config.memory` wins and no Redis store is built (nor is the
|
|
379
|
+
* connection opened on its account). Otherwise the bundled `RedisMemory` is
|
|
380
|
+
* built from `config.redis` exactly as before: same tenant prefix, same
|
|
381
|
+
* history/document TTLs. Both `handle()` and `handleCore()` go through here,
|
|
382
|
+
* so the two construction sites that used to drift apart are now one.
|
|
383
|
+
*/
|
|
384
|
+
async function resolveStore(event) {
|
|
385
|
+
// Read `config.memory` ONCE — the guard for "resolved once per cycle"
|
|
386
|
+
// counts property reads through a getter, and a second read here would be
|
|
387
|
+
// indistinguishable from a second resolve.
|
|
388
|
+
const injected = config.memory;
|
|
389
|
+
if (injected)
|
|
390
|
+
return injected;
|
|
391
|
+
const redis = await getRedis();
|
|
392
|
+
return new RedisMemory(redis, {
|
|
393
|
+
prefix: tenantPrefixFor(event),
|
|
394
|
+
...(ttl.history !== undefined ? { historyTtl: ttl.history } : {}),
|
|
395
|
+
...(ttl.document !== undefined ? { documentTtl: ttl.document } : {}),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
90
398
|
/**
|
|
91
399
|
* Public entry point — runs the loop, then persists final run metadata
|
|
92
400
|
* (status / iterations / cost) for ops dashboards. Metadata is best-effort:
|
|
93
401
|
* it never blocks or fails the response.
|
|
402
|
+
*
|
|
403
|
+
* The store is resolved ONCE here and threaded into the loop. It used to be
|
|
404
|
+
* resolved twice per cycle — once inside `handleCore` and once again for this
|
|
405
|
+
* metadata write — which built two `RedisMemory` instances on the fallback
|
|
406
|
+
* path and read an injected port twice (س١ ruling 6). One turn, one store.
|
|
94
407
|
*/
|
|
95
408
|
async function handle(event) {
|
|
96
|
-
const
|
|
409
|
+
const memory = await resolveStore(event);
|
|
410
|
+
const outcome = await handleCore(event, memory);
|
|
97
411
|
try {
|
|
98
|
-
const redis = await getRedis();
|
|
99
|
-
const memory = new RedisMemory(redis, {
|
|
100
|
-
prefix: tenantPrefixFor(event),
|
|
101
|
-
...(ttl.history !== undefined ? { historyTtl: ttl.history } : {}),
|
|
102
|
-
});
|
|
103
412
|
await memory.setMetadata(event.sessionId, {
|
|
104
413
|
iterationCount: outcome.metrics.iterations,
|
|
105
414
|
startedAt: Date.now() - outcome.metrics.durationMs,
|
|
@@ -116,7 +425,7 @@ export function createAgent(config) {
|
|
|
116
425
|
}
|
|
117
426
|
return outcome;
|
|
118
427
|
}
|
|
119
|
-
async function handleCore(event) {
|
|
428
|
+
async function handleCore(event, memory) {
|
|
120
429
|
const sessionId = event.sessionId;
|
|
121
430
|
const taskId = makeTaskId();
|
|
122
431
|
const started = Date.now();
|
|
@@ -125,12 +434,9 @@ export function createAgent(config) {
|
|
|
125
434
|
const effectiveGuards = event.guardsOverride
|
|
126
435
|
? resolveGuards({ ...guards, ...event.guardsOverride })
|
|
127
436
|
: guards;
|
|
437
|
+
// Redis still backs the control bus, the session lock and tool dedup —
|
|
438
|
+
// separate ports, untouched. Session memory arrives resolved from handle().
|
|
128
439
|
const redis = await getRedis();
|
|
129
|
-
const memory = new RedisMemory(redis, {
|
|
130
|
-
prefix: tenantPrefix,
|
|
131
|
-
...(ttl.history !== undefined ? { historyTtl: ttl.history } : {}),
|
|
132
|
-
...(ttl.document !== undefined ? { documentTtl: ttl.document } : {}),
|
|
133
|
-
});
|
|
134
440
|
const controlBus = new RedisControlBus(redis, tenantPrefix);
|
|
135
441
|
const lock = new RedisDistributedLock(redis, tenantPrefix);
|
|
136
442
|
// ── Gates (zero-LLM filters) ─────────────────────────────
|
|
@@ -151,15 +457,241 @@ export function createAgent(config) {
|
|
|
151
457
|
totalToolCalls: 0,
|
|
152
458
|
consecutiveToolFailures: 0,
|
|
153
459
|
};
|
|
460
|
+
/**
|
|
461
|
+
* The streaming request, built once — or not built at all (ب١).
|
|
462
|
+
*
|
|
463
|
+
* **This `undefined` is the feature.** With no `onChunk` hook configured,
|
|
464
|
+
* `BrainRunInput` carries no `onChunk`, every brain sees the field missing
|
|
465
|
+
* and takes its ordinary non-streaming path, and nothing anywhere in this
|
|
466
|
+
* run differs from yesterday. Passing a no-op sink instead would have put
|
|
467
|
+
* every agent on earth onto the streaming code path to feed a function that
|
|
468
|
+
* throws its argument away.
|
|
469
|
+
*
|
|
470
|
+
* `state.iteration` is read when a chunk fires, not when this closure is
|
|
471
|
+
* made, so each chunk carries the step it actually belongs to — including
|
|
472
|
+
* the finalize call, which streams under the iteration it aborted on.
|
|
473
|
+
*/
|
|
474
|
+
const chunkSink = config.hooks?.onChunk
|
|
475
|
+
? (chunk) => fireChunk(config.hooks, sessionId, state.iteration, chunk.text)
|
|
476
|
+
: undefined;
|
|
154
477
|
const toolDefs = toToolDefinitions(config.tools);
|
|
155
478
|
const toolMap = new Map(config.tools.map((t) => [t.name, t]));
|
|
156
479
|
const toolResults = [];
|
|
480
|
+
const sessionLog = config.sessionLog;
|
|
481
|
+
const strict = sessionLog ? sessionStrict() : false;
|
|
482
|
+
/** The open step, if any — the error path uses it to close the step it died in. */
|
|
483
|
+
let openStep;
|
|
484
|
+
let turnLog;
|
|
157
485
|
try {
|
|
158
|
-
|
|
159
|
-
|
|
486
|
+
const historyLimit = effectiveGuards.maxIterations * 4;
|
|
487
|
+
let history;
|
|
488
|
+
if (sessionLog) {
|
|
489
|
+
// ── The log is the truth, and the conversation is derived from it ──
|
|
490
|
+
//
|
|
491
|
+
// Read BEFORE the turn's first write: these are the events of every
|
|
492
|
+
// PREVIOUS turn, and they are what the conversation is derived from.
|
|
493
|
+
// The current message is not part of it — it travels as `raw`, the way
|
|
494
|
+
// it always has, and appending it to the history too would deliver it
|
|
495
|
+
// twice.
|
|
496
|
+
//
|
|
497
|
+
// The derived conversation carries the `assistant`/`tool` pairs of past
|
|
498
|
+
// turns, which `getHistory` never held (there is not one `role: "tool"`
|
|
499
|
+
// write in this package). That difference is the whole point of the
|
|
500
|
+
// log — and it is guarded explicitly on both paths, not left to be
|
|
501
|
+
// noticed in production.
|
|
502
|
+
turnLog = new TurnWriter(sessionLog, sessionId, await sessionLog.head(sessionId));
|
|
503
|
+
let past = await sessionLog.read(sessionId);
|
|
504
|
+
// ── The compaction seat (ض١) ──────────────────────────────────────
|
|
505
|
+
//
|
|
506
|
+
// Consulted BEFORE the turn's user message is written, so the range it
|
|
507
|
+
// proposes covers only completed turns and the summary is in place for
|
|
508
|
+
// the very first model call of this turn.
|
|
509
|
+
//
|
|
510
|
+
// Everything below is skipped when no port is injected — which is the
|
|
511
|
+
// default, and which is why an agent that does not opt in cannot tell
|
|
512
|
+
// ض١ happened. The seat needs the log and says so at the type level via
|
|
513
|
+
// `sessionLog` being the branch it lives in: a compaction is an EVENT,
|
|
514
|
+
// and without a log there is nowhere to record it.
|
|
515
|
+
if (config.compaction) {
|
|
516
|
+
const decision = await config.compaction.maybeCompact(deriveMessages(past), effectiveBudget(historyLimit));
|
|
517
|
+
const summary = decision?.summary?.trim();
|
|
518
|
+
if (summary) {
|
|
519
|
+
// Two defences, and this is the first: the port asked to keep N
|
|
520
|
+
// turns, `compactionBoundary` decides what that can safely mean.
|
|
521
|
+
// It returns 0 when nothing can be compacted without severing a
|
|
522
|
+
// tool call from its result or splitting a turn — and 0 means the
|
|
523
|
+
// loop writes nothing at all rather than writing a range the
|
|
524
|
+
// derivation would then refuse.
|
|
525
|
+
const toSeq = compactionBoundary(past, decision?.keepTurns ?? 0);
|
|
526
|
+
const fromSeq = compactionAnchor(past);
|
|
527
|
+
if (toSeq >= fromSeq) {
|
|
528
|
+
await turnLog.writeTurn("compaction", {
|
|
529
|
+
fromSeq,
|
|
530
|
+
toSeq,
|
|
531
|
+
summary,
|
|
532
|
+
// Who asked, not why — the loop does not know the port's
|
|
533
|
+
// reasoning and will not invent one for an audit record.
|
|
534
|
+
reason: "compaction_port",
|
|
535
|
+
});
|
|
536
|
+
// Re-read rather than patch what is in hand: the events come back
|
|
537
|
+
// through the store, so a log that lost or mangled the write is
|
|
538
|
+
// caught here, on this turn, and not at replay time.
|
|
539
|
+
past = await sessionLog.read(sessionId);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// The same cap the store applied on the legacy path, so the budget of a
|
|
544
|
+
// long session is bounded identically even when nothing compacts.
|
|
545
|
+
history = deriveMessages(past).slice(-historyLimit);
|
|
546
|
+
await turnLog.writeTurn("user_message", { text: event.message },
|
|
547
|
+
// `started`, not "now": the user's message arrived when the turn
|
|
548
|
+
// opened. It also keeps the derived `timestamp` equal to the one
|
|
549
|
+
// `appendHistory` writes for the same message.
|
|
550
|
+
started);
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
history = await memory.getHistory(sessionId, historyLimit);
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Close the turn: the delivered reply goes to BOTH records — the event
|
|
557
|
+
* log (the truth) and the history array (transitional compatibility,
|
|
558
|
+
* §5) — and the step that produced it is closed.
|
|
559
|
+
*
|
|
560
|
+
* One helper for all three delivery paths, because three copies of the
|
|
561
|
+
* same two writes is exactly how two of them silently drift apart (س١'s
|
|
562
|
+
* lesson, paid for once already).
|
|
563
|
+
*/
|
|
564
|
+
const closeTurn = async (finalText, stepId, reason) => {
|
|
565
|
+
const now = Date.now();
|
|
566
|
+
await turnLog?.writeTurn("assistant_message", { text: finalText }, now);
|
|
567
|
+
await turnLog?.writeStep("step_ended", stepId, {
|
|
568
|
+
index: state.iteration,
|
|
569
|
+
reason,
|
|
570
|
+
});
|
|
571
|
+
openStep = undefined;
|
|
572
|
+
await memory.appendHistory(sessionId, {
|
|
573
|
+
role: "user",
|
|
574
|
+
content: event.message,
|
|
575
|
+
timestamp: started,
|
|
576
|
+
});
|
|
577
|
+
await memory.appendHistory(sessionId, {
|
|
578
|
+
role: "assistant",
|
|
579
|
+
content: finalText,
|
|
580
|
+
timestamp: now,
|
|
581
|
+
});
|
|
582
|
+
};
|
|
583
|
+
/** Close a step that produced no reply (a tool round-trip, or an abort). */
|
|
584
|
+
const endStep = async (stepId, reason) => {
|
|
585
|
+
await turnLog?.writeStep("step_ended", stepId, {
|
|
586
|
+
index: state.iteration,
|
|
587
|
+
reason,
|
|
588
|
+
});
|
|
589
|
+
openStep = undefined;
|
|
590
|
+
};
|
|
591
|
+
/** Record a guard that fired, at the step it fired in. */
|
|
592
|
+
const logGuard = async (stepId, guard, action, reason) => {
|
|
593
|
+
await turnLog?.writeStep("guard_fired", stepId, {
|
|
594
|
+
guard,
|
|
595
|
+
...(action !== undefined ? { action } : {}),
|
|
596
|
+
...(reason !== undefined ? { reason } : {}),
|
|
597
|
+
});
|
|
598
|
+
};
|
|
599
|
+
/** The output gate, plus the `guard_fired` record when it does something. */
|
|
600
|
+
const gateAndLog = async (text, stepId) => {
|
|
601
|
+
const gated = await applyOutputGate(text);
|
|
602
|
+
if (gated.validation && gated.validation.action !== "release") {
|
|
603
|
+
await logGuard(stepId, "output_gate", gated.validation.action, gated.validation.violations.join(", ") || undefined);
|
|
604
|
+
}
|
|
605
|
+
return gated;
|
|
606
|
+
};
|
|
607
|
+
/**
|
|
608
|
+
* Record what is about to be sent — then prove it is reconstructible.
|
|
609
|
+
*
|
|
610
|
+
* The invariant runs BEFORE the call goes out, not after: a request that
|
|
611
|
+
* cannot be rebuilt from the log is a request that should not be paid
|
|
612
|
+
* for, and failing after the money is spent teaches nothing the receipt
|
|
613
|
+
* would not.
|
|
614
|
+
*/
|
|
615
|
+
const logRequest = async (input, stepId) => {
|
|
616
|
+
if (!turnLog || !sessionLog)
|
|
617
|
+
return;
|
|
618
|
+
await turnLog.writeStep("model_request", stepId, {
|
|
619
|
+
model: def.brain.model,
|
|
620
|
+
system: input.system_context,
|
|
621
|
+
messageCount: input.history.length,
|
|
622
|
+
toolNames: input.tools.map((t) => t.name),
|
|
623
|
+
});
|
|
624
|
+
if (!strict)
|
|
625
|
+
return;
|
|
626
|
+
// Re-read from the store rather than reuse what was just written: a log
|
|
627
|
+
// that loses, reorders or mangles a write is caught here, not at replay.
|
|
628
|
+
assertRequestReconstructible({
|
|
629
|
+
events: await sessionLog.read(sessionId),
|
|
630
|
+
turnId: turnLog.turnId,
|
|
631
|
+
stepId,
|
|
632
|
+
historyLimit,
|
|
633
|
+
sent: input,
|
|
634
|
+
});
|
|
635
|
+
};
|
|
636
|
+
/**
|
|
637
|
+
* Record what came back, verbatim — before any output gate touches it.
|
|
638
|
+
*
|
|
639
|
+
* `toolCalls` carries ALL the calls of the step, in the model's order,
|
|
640
|
+
* numbered `stepId.c1 … stepId.cN`. The log shape was built for this from
|
|
641
|
+
* the start (س٢ §4: "a step carries as many tool calls as the model asked
|
|
642
|
+
* for") — this is the writer finally filling it.
|
|
643
|
+
*/
|
|
644
|
+
const logResponse = async (payload, stepId) => {
|
|
645
|
+
if (!turnLog)
|
|
646
|
+
return;
|
|
647
|
+
const writer = turnLog;
|
|
648
|
+
const orch = payload.orchestration;
|
|
649
|
+
const text = payload.generation?.response_text ?? payload.final_output?.text;
|
|
650
|
+
const calls = orch?.action === "use_tool" ? stepToolCalls(orch) : [];
|
|
651
|
+
await writer.writeStep("model_response", stepId, {
|
|
652
|
+
...(text !== undefined ? { text } : {}),
|
|
653
|
+
...(calls.length > 0
|
|
654
|
+
? {
|
|
655
|
+
toolCalls: calls.map((call, index) => ({
|
|
656
|
+
callId: writer.callId(stepId, index + 1),
|
|
657
|
+
name: call.name,
|
|
658
|
+
args: call.params,
|
|
659
|
+
})),
|
|
660
|
+
}
|
|
661
|
+
: {}),
|
|
662
|
+
...(orch?.action !== undefined ? { finishReason: orch.action } : {}),
|
|
663
|
+
...(payload.usage !== undefined ? { usage: payload.usage } : {}),
|
|
664
|
+
...(payload.costUsd !== undefined ? { costUsd: payload.costUsd } : {}),
|
|
665
|
+
});
|
|
666
|
+
};
|
|
667
|
+
/**
|
|
668
|
+
* One tool invocation, both halves. Written together because the pair is
|
|
669
|
+
* the unit: a call with no result replays as a question the model must
|
|
670
|
+
* answer and cannot, and a result with no call replays as an answer to
|
|
671
|
+
* nothing. Every provider rejects half a pair.
|
|
672
|
+
*/
|
|
673
|
+
const logToolPair = async (stepId, ordinal, name, args, result) => {
|
|
674
|
+
if (!turnLog)
|
|
675
|
+
return;
|
|
676
|
+
const callId = turnLog.callId(stepId, ordinal);
|
|
677
|
+
await turnLog.writeStep("tool_call", stepId, { callId, name, args });
|
|
678
|
+
await turnLog.writeStep("tool_result", stepId, {
|
|
679
|
+
callId,
|
|
680
|
+
name,
|
|
681
|
+
status: result.status,
|
|
682
|
+
...(result.result !== undefined ? { result: result.result } : {}),
|
|
683
|
+
...(result.error !== undefined ? { error: result.error } : {}),
|
|
684
|
+
});
|
|
685
|
+
};
|
|
160
686
|
// Loop
|
|
161
687
|
while (true) {
|
|
162
688
|
state.iteration++;
|
|
689
|
+
// ── Step opens ────────────────────────────────────────
|
|
690
|
+
const stepId = turnLog ? turnLog.stepId(state.iteration) : "";
|
|
691
|
+
openStep = { id: stepId, index: state.iteration };
|
|
692
|
+
await turnLog?.writeStep("step_started", stepId, {
|
|
693
|
+
index: state.iteration,
|
|
694
|
+
});
|
|
163
695
|
// ── Control bus check ─────────────────────────────────
|
|
164
696
|
const command = await controlBus.getCommand(sessionId);
|
|
165
697
|
// ── Guards ────────────────────────────────────────────
|
|
@@ -170,9 +702,13 @@ export function createAgent(config) {
|
|
|
170
702
|
const hardSignal = guardSignals.find((s) => s.hard);
|
|
171
703
|
fireGuard(config.hooks, sessionId, hardSignal, state.iteration);
|
|
172
704
|
const ha = hardAction(guardSignals);
|
|
705
|
+
// Logged at the same moment the hook fires, and only for the signal
|
|
706
|
+
// the loop acts on: a signal the loop ignores did not "fire".
|
|
707
|
+
await logGuard(stepId, hardSignal.type, ha, hardSignal.message);
|
|
173
708
|
if (process.env["MINI_DEBUG"])
|
|
174
709
|
console.error(`[mini] hardBlock type=${hardSignal.type} action=${ha} toolResults=${toolResults.length}`);
|
|
175
710
|
if (ha === "abort") {
|
|
711
|
+
await endStep(stepId, "guard");
|
|
176
712
|
await lockHandle.release();
|
|
177
713
|
return makeOutcome("error", sessionId, taskId, state, started, {
|
|
178
714
|
text: `Aborted: ${hardSignal.message}`,
|
|
@@ -193,14 +729,22 @@ export function createAgent(config) {
|
|
|
193
729
|
const finalRemainingMs = effectiveGuards.timeoutMs > 0
|
|
194
730
|
? Math.max(1, effectiveGuards.timeoutMs - (Date.now() - state.startedAt))
|
|
195
731
|
: 0;
|
|
196
|
-
const
|
|
732
|
+
const finalInput = {
|
|
197
733
|
raw: event.message,
|
|
198
734
|
system_context,
|
|
199
735
|
history: finalHistory,
|
|
200
736
|
tools: [], // no tools → the model must produce a textual answer
|
|
201
|
-
tool_results: toolResults.slice(-
|
|
737
|
+
tool_results: toolResults.slice(-TOOL_RESULT_WINDOW),
|
|
738
|
+
};
|
|
739
|
+
await logRequest(finalInput, stepId);
|
|
740
|
+
const finalPayload = await runWithTimeout(config.brain.run({
|
|
741
|
+
...finalInput,
|
|
202
742
|
signal: finalController.signal,
|
|
743
|
+
// The forced answer is the one the user actually reads —
|
|
744
|
+
// if anything in a run deserves to stream, it is this.
|
|
745
|
+
...(chunkSink ? { onChunk: chunkSink } : {}),
|
|
203
746
|
}), finalRemainingMs, finalController);
|
|
747
|
+
await logResponse(finalPayload, stepId);
|
|
204
748
|
state.totalCostUsd += finalPayload.costUsd ?? 0;
|
|
205
749
|
const forced = finalPayload.generation?.response_text ??
|
|
206
750
|
finalPayload.final_output?.text ??
|
|
@@ -208,17 +752,8 @@ export function createAgent(config) {
|
|
|
208
752
|
if (process.env["MINI_DEBUG"])
|
|
209
753
|
console.error(`[mini] finalize fired; forced.len=${forced.length} orch=${finalPayload.orchestration?.action}`);
|
|
210
754
|
if (forced) {
|
|
211
|
-
const gated = await
|
|
212
|
-
await
|
|
213
|
-
role: "user",
|
|
214
|
-
content: event.message,
|
|
215
|
-
timestamp: started,
|
|
216
|
-
});
|
|
217
|
-
await memory.appendHistory(sessionId, {
|
|
218
|
-
role: "assistant",
|
|
219
|
-
content: gated.finalText,
|
|
220
|
-
timestamp: Date.now(),
|
|
221
|
-
});
|
|
755
|
+
const gated = await gateAndLog(forced, stepId);
|
|
756
|
+
await closeTurn(gated.finalText, stepId, hardSignal.type);
|
|
222
757
|
await lockHandle.release();
|
|
223
758
|
return makeOutcome(gated.validation?.action === "block" ? "suppressed" : "response", sessionId, taskId, state, started, {
|
|
224
759
|
text: gated.finalText,
|
|
@@ -236,7 +771,7 @@ export function createAgent(config) {
|
|
|
236
771
|
const lastText = state.lastPayload?.generation?.response_text ??
|
|
237
772
|
state.lastPayload?.final_output?.text ??
|
|
238
773
|
"I was unable to complete this request within the allowed limits. Please try a more specific question.";
|
|
239
|
-
const gatedLast = await
|
|
774
|
+
const gatedLast = await gateAndLog(lastText, stepId);
|
|
240
775
|
const outcomeType = gatedLast.validation?.action === "block"
|
|
241
776
|
? "suppressed"
|
|
242
777
|
: ha === "escalate"
|
|
@@ -244,16 +779,7 @@ export function createAgent(config) {
|
|
|
244
779
|
: "response";
|
|
245
780
|
// Record BOTH sides of the turn — assistant-only history leaves
|
|
246
781
|
// orphaned turns that misalign the conversation on the next call.
|
|
247
|
-
await
|
|
248
|
-
role: "user",
|
|
249
|
-
content: event.message,
|
|
250
|
-
timestamp: started,
|
|
251
|
-
});
|
|
252
|
-
await memory.appendHistory(sessionId, {
|
|
253
|
-
role: "assistant",
|
|
254
|
-
content: gatedLast.finalText,
|
|
255
|
-
timestamp: Date.now(),
|
|
256
|
-
});
|
|
782
|
+
await closeTurn(gatedLast.finalText, stepId, hardSignal.type);
|
|
257
783
|
await lockHandle.release();
|
|
258
784
|
return makeOutcome(outcomeType, sessionId, taskId, state, started, {
|
|
259
785
|
text: gatedLast.finalText,
|
|
@@ -274,9 +800,10 @@ export function createAgent(config) {
|
|
|
274
800
|
history: trimmedHistory,
|
|
275
801
|
tools: toolDefs,
|
|
276
802
|
...(toolResults.length > 0
|
|
277
|
-
? { tool_results: toolResults.slice(-
|
|
803
|
+
? { tool_results: toolResults.slice(-TOOL_RESULT_WINDOW) }
|
|
278
804
|
: {}),
|
|
279
805
|
};
|
|
806
|
+
await logRequest(brainInput, stepId);
|
|
280
807
|
// Bound the brain call by the remaining time budget. Without this a hung
|
|
281
808
|
// LLM call blocks forever — the wall-clock guard only runs *between*
|
|
282
809
|
// iterations, never during one.
|
|
@@ -285,58 +812,111 @@ export function createAgent(config) {
|
|
|
285
812
|
const remainingMs = effectiveGuards.timeoutMs > 0
|
|
286
813
|
? Math.max(1, effectiveGuards.timeoutMs - elapsed)
|
|
287
814
|
: 0;
|
|
288
|
-
const payload = await runWithTimeout(config.brain.run({
|
|
815
|
+
const payload = await runWithTimeout(config.brain.run({
|
|
816
|
+
...brainInput,
|
|
817
|
+
signal: controller.signal,
|
|
818
|
+
...(chunkSink ? { onChunk: chunkSink } : {}),
|
|
819
|
+
}), remainingMs, controller);
|
|
820
|
+
await logResponse(payload, stepId);
|
|
289
821
|
state.lastPayload = payload;
|
|
290
822
|
state.totalCostUsd += payload.costUsd ?? 0;
|
|
291
823
|
const orch = payload.orchestration;
|
|
292
|
-
// ── Tool
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
824
|
+
// ── Tool calls ────────────────────────────────────────
|
|
825
|
+
//
|
|
826
|
+
// A step is N calls, not one. They run SEQUENTIALLY, in the model's
|
|
827
|
+
// order, and the loop returns to the model ONCE when they are all
|
|
828
|
+
// done — that single round-trip instead of N is the whole point.
|
|
829
|
+
//
|
|
830
|
+
// Sequential and not `Promise.all` on purpose: domain tools here write
|
|
831
|
+
// to shared systems, and nothing in the `Tool` contract promises they
|
|
832
|
+
// are safe to run concurrently. Parallel *requesting* is what the models
|
|
833
|
+
// do; parallel *executing* is a separate decision with a separate
|
|
834
|
+
// blast radius, and it is not made here.
|
|
835
|
+
//
|
|
836
|
+
// The gate is unchanged (`action === "use_tool"` plus a first call that
|
|
837
|
+
// names something), so a brain that fills only `tool_name` walks the
|
|
838
|
+
// path it always walked.
|
|
839
|
+
const stepCalls = orch?.action === "use_tool" ? stepToolCalls(orch) : [];
|
|
840
|
+
if (stepCalls.length > 0) {
|
|
841
|
+
// Read once for the whole step: one control-bus command governs the
|
|
842
|
+
// step it was read in, and re-reading per call would let a mid-step
|
|
843
|
+
// change split one step's siblings under two different policies.
|
|
296
844
|
const disabledTool = RedisControlBus.disabledTool(command);
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
845
|
+
/** Did ANY call of this step come back ok? — §4's counter turns on it. */
|
|
846
|
+
let anySucceeded = false;
|
|
847
|
+
for (const [index, call] of stepCalls.entries()) {
|
|
848
|
+
// 1-based within the step, and stable: `stepId.c1 … stepId.cN`,
|
|
849
|
+
// matching the ids `model_response` already announced. A blocked or
|
|
850
|
+
// unknown call keeps its ordinal — renumbering around it would make
|
|
851
|
+
// the ids in the response and the ids on the pairs disagree.
|
|
852
|
+
const ordinal = index + 1;
|
|
853
|
+
const toolName = call.name;
|
|
854
|
+
const params = call.params;
|
|
855
|
+
// Disabled via the control bus — this call only. Its siblings are
|
|
856
|
+
// not this tool and are not refused on its account.
|
|
857
|
+
if (disabledTool === toolName) {
|
|
858
|
+
const blocked = {
|
|
859
|
+
tool: toolName,
|
|
860
|
+
status: "failed",
|
|
861
|
+
error: `Tool "${toolName}" is currently disabled`,
|
|
862
|
+
};
|
|
863
|
+
toolResults.push(blocked);
|
|
864
|
+
// A refused call is still a call the model made and a result it
|
|
865
|
+
// was shown. Logging only the calls that reached a tool would
|
|
866
|
+
// leave the model reasoning about an answer the log cannot
|
|
867
|
+
// account for.
|
|
868
|
+
await logToolPair(stepId, ordinal, toolName, params, blocked);
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
const tool = toolMap.get(toolName);
|
|
872
|
+
if (!tool) {
|
|
873
|
+
const unknown = {
|
|
874
|
+
tool: toolName,
|
|
875
|
+
status: "failed",
|
|
876
|
+
error: `Unknown tool: "${toolName}"`,
|
|
877
|
+
};
|
|
878
|
+
toolResults.push(unknown);
|
|
879
|
+
await logToolPair(stepId, ordinal, toolName, params, unknown);
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
// Per CALL, not per step: the approval gate, the onBeforeTool hook
|
|
883
|
+
// and the dedup key all live inside `executeTool`, so a
|
|
884
|
+
// `requiresApproval` tool fails closed for ITS call alone while its
|
|
885
|
+
// siblings run. A step is not an approval unit.
|
|
886
|
+
const exec = await executeTool(tool, params, {
|
|
887
|
+
sessionId,
|
|
888
|
+
iteration: state.iteration,
|
|
889
|
+
agentName: def.name,
|
|
890
|
+
...(event.tenantContext ? { tenantContext: event.tenantContext } : {}),
|
|
891
|
+
}, {
|
|
892
|
+
redis,
|
|
893
|
+
redisPrefix: tenantPrefix,
|
|
894
|
+
dedupTtlSeconds: ttl.toolDedup ?? 300,
|
|
895
|
+
...(config.hooks?.onBeforeTool
|
|
896
|
+
? { hooks: { onBeforeTool: config.hooks.onBeforeTool } }
|
|
897
|
+
: {}),
|
|
312
898
|
});
|
|
313
|
-
state.
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
}, {
|
|
323
|
-
redis,
|
|
324
|
-
redisPrefix: tenantPrefix,
|
|
325
|
-
dedupTtlSeconds: ttl.toolDedup ?? 300,
|
|
326
|
-
...(config.hooks?.onBeforeTool
|
|
327
|
-
? { hooks: { onBeforeTool: config.hooks.onBeforeTool } }
|
|
328
|
-
: {}),
|
|
329
|
-
});
|
|
330
|
-
fireToolCall(config.hooks, sessionId, exec.result, state.iteration, exec.cached, exec.durationMs);
|
|
331
|
-
toolResults.push(exec.result);
|
|
332
|
-
state.totalToolCalls++;
|
|
333
|
-
if (exec.result.status === "failed") {
|
|
334
|
-
state.consecutiveToolFailures++;
|
|
899
|
+
fireToolCall(config.hooks, sessionId, exec.result, state.iteration, exec.cached, exec.durationMs);
|
|
900
|
+
toolResults.push(exec.result);
|
|
901
|
+
state.totalToolCalls++;
|
|
902
|
+
if (exec.result.status !== "failed")
|
|
903
|
+
anySucceeded = true;
|
|
904
|
+
await logToolPair(stepId, ordinal, toolName, params, exec.result);
|
|
905
|
+
// …and on to the next sibling. One tool's failure is that tool's
|
|
906
|
+
// failure: it does not abort the step, because the model asked for
|
|
907
|
+
// several answers and a partial set is still worth more than none.
|
|
335
908
|
}
|
|
336
|
-
|
|
909
|
+
// §4 — the counter counts STEPS, not calls. A step with at least one
|
|
910
|
+
// success clears it; a step where everything failed adds exactly one.
|
|
911
|
+
// Counting per call would let a single bad step of five calls burn a
|
|
912
|
+
// three-failure budget outright, which turns a broken tool into a
|
|
913
|
+
// terminated conversation.
|
|
914
|
+
if (anySucceeded)
|
|
337
915
|
state.consecutiveToolFailures = 0;
|
|
338
|
-
|
|
339
|
-
|
|
916
|
+
else
|
|
917
|
+
state.consecutiveToolFailures++;
|
|
918
|
+
await endStep(stepId, "tool_calls");
|
|
919
|
+
continue; // ONE model round-trip for the whole step
|
|
340
920
|
}
|
|
341
921
|
// ── Terminal outcome ──────────────────────────────────
|
|
342
922
|
const text = payload.generation?.response_text ?? payload.final_output?.text ?? "";
|
|
@@ -354,18 +934,10 @@ export function createAgent(config) {
|
|
|
354
934
|
}
|
|
355
935
|
}
|
|
356
936
|
// ── Optional output gate (shared with all guard exits) ─
|
|
357
|
-
const { finalText, validation } = await
|
|
358
|
-
// Record
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
content: event.message,
|
|
362
|
-
timestamp: started,
|
|
363
|
-
});
|
|
364
|
-
await memory.appendHistory(sessionId, {
|
|
365
|
-
role: "assistant",
|
|
366
|
-
content: finalText,
|
|
367
|
-
timestamp: Date.now(),
|
|
368
|
-
});
|
|
937
|
+
const { finalText, validation } = await gateAndLog(text, stepId);
|
|
938
|
+
// Record the delivered text, post-gate — to the log and to the history
|
|
939
|
+
// array both, for as long as both exist.
|
|
940
|
+
await closeTurn(finalText, stepId, "final_response");
|
|
369
941
|
const baseType = orch?.action === "clarify"
|
|
370
942
|
? "clarify"
|
|
371
943
|
: orch?.action === "escalate"
|
|
@@ -383,6 +955,21 @@ export function createAgent(config) {
|
|
|
383
955
|
catch (err) {
|
|
384
956
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
385
957
|
fireFatalError(config.hooks, error, sessionId);
|
|
958
|
+
// Close the step the run died inside — best effort, and ONLY here. The
|
|
959
|
+
// log may well be the thing that just failed, and a second failure must
|
|
960
|
+
// not replace the original error with a less informative one. A log that
|
|
961
|
+
// simply stops mid-step is itself an honest record of a run that did.
|
|
962
|
+
if (turnLog && openStep) {
|
|
963
|
+
try {
|
|
964
|
+
await turnLog.writeStep("step_ended", openStep.id, {
|
|
965
|
+
index: openStep.index,
|
|
966
|
+
reason: "error",
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
catch {
|
|
970
|
+
// unreachable log — nothing more to record it with
|
|
971
|
+
}
|
|
972
|
+
}
|
|
386
973
|
await lockHandle.release();
|
|
387
974
|
return makeOutcome("error", sessionId, taskId, state, started, {
|
|
388
975
|
text: "An unexpected error occurred. Please try again.",
|
|
@@ -410,6 +997,70 @@ export function createAgent(config) {
|
|
|
410
997
|
}
|
|
411
998
|
return { handle, kill, pause, resume };
|
|
412
999
|
}
|
|
1000
|
+
const DEFAULT_COMPACTOR_SYSTEM = "You compress conversation transcripts for an AI agent's working memory. " +
|
|
1001
|
+
"You are not talking to the user and you never answer the user's question.";
|
|
1002
|
+
const DEFAULT_COMPACTOR_INSTRUCTION = "Summarise the transcript below so that an assistant can continue the " +
|
|
1003
|
+
"conversation without it. Preserve: the user's goal, every decision and " +
|
|
1004
|
+
"commitment made, every fact, figure, name and identifier established, and " +
|
|
1005
|
+
"any question still open. Drop pleasantries and repetition. Write plain " +
|
|
1006
|
+
"prose, no preamble, no headings.";
|
|
1007
|
+
/**
|
|
1008
|
+
* A `CompactionPort` that summarises with an injected `Brain`.
|
|
1009
|
+
*
|
|
1010
|
+
* **A reference implementation, not a requirement.** The port is the contract;
|
|
1011
|
+
* this is one way to satisfy it, and a consumer with a cheaper model, a local
|
|
1012
|
+
* one, or a rule-based summariser wires up its own and the loop cannot tell.
|
|
1013
|
+
*
|
|
1014
|
+
* It opens no connection of its own. The summary is an ordinary `brain.run`
|
|
1015
|
+
* call on a brain the caller built and injected — the same covenant as every
|
|
1016
|
+
* other port in this package: nothing here knows the network.
|
|
1017
|
+
*
|
|
1018
|
+
* Two deliberate refusals:
|
|
1019
|
+
*
|
|
1020
|
+
* - It does not use the agent's own tools, history or system context. A
|
|
1021
|
+
* summariser that could call tools is a second agent with no budget and no
|
|
1022
|
+
* guards.
|
|
1023
|
+
* - It does not swallow a provider failure. An empty answer is a decision
|
|
1024
|
+
* ("nothing to compact"); a thrown error is a broken compactor, and a
|
|
1025
|
+
* compactor that has been quietly failing is a session quietly losing its
|
|
1026
|
+
* head — the failure ض١ exists to end.
|
|
1027
|
+
*
|
|
1028
|
+
* The rendering is deterministic and reuses `toWireMessage`, so the summariser
|
|
1029
|
+
* reads a tool round-trip exactly as the agent's own model reads it (س٤'s wire
|
|
1030
|
+
* contract), and the same conversation always produces the same prompt.
|
|
1031
|
+
*/
|
|
1032
|
+
export function createBrainCompactor(brain, opts = {}) {
|
|
1033
|
+
const keepTurns = opts.keepTurns ?? 2;
|
|
1034
|
+
const systemContext = opts.systemContext ?? DEFAULT_COMPACTOR_SYSTEM;
|
|
1035
|
+
const instruction = opts.instruction ?? DEFAULT_COMPACTOR_INSTRUCTION;
|
|
1036
|
+
return {
|
|
1037
|
+
async maybeCompact(messages, budget) {
|
|
1038
|
+
const threshold = opts.threshold ?? budget.maxHistoryMessages;
|
|
1039
|
+
if (messages.length <= threshold)
|
|
1040
|
+
return null;
|
|
1041
|
+
const transcript = messages
|
|
1042
|
+
.map((message) => {
|
|
1043
|
+
const wire = toWireMessage(message);
|
|
1044
|
+
return `${wire.role}: ${wire.content}`;
|
|
1045
|
+
})
|
|
1046
|
+
.join("\n");
|
|
1047
|
+
const payload = await brain.run({
|
|
1048
|
+
raw: `${instruction}\n\n<transcript>\n${transcript}\n</transcript>`,
|
|
1049
|
+
system_context: systemContext,
|
|
1050
|
+
history: [],
|
|
1051
|
+
tools: [],
|
|
1052
|
+
});
|
|
1053
|
+
const summary = (payload.generation?.response_text ??
|
|
1054
|
+
payload.final_output?.text ??
|
|
1055
|
+
"").trim();
|
|
1056
|
+
// No text is not an error, it is "I have nothing to say about this" —
|
|
1057
|
+
// and the loop then leaves the conversation exactly as it found it.
|
|
1058
|
+
if (!summary)
|
|
1059
|
+
return null;
|
|
1060
|
+
return { summary, keepTurns };
|
|
1061
|
+
},
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
413
1064
|
// ─── Helpers ──────────────────────────────────────────────────
|
|
414
1065
|
/**
|
|
415
1066
|
* Race a brain call against a time budget. On timeout, abort the controller
|