@frockbot/kernel-do 0.3.21 → 0.3.23
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/package.json +3 -3
- package/src/authority.ts +48 -8
- package/src/session-event-log.test.ts +132 -0
- package/src/session-event-log.ts +172 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/kernel-do",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.23",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@frockbot/kernel-composition": "0.3.
|
|
16
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
15
|
+
"@frockbot/kernel-composition": "0.3.23",
|
|
16
|
+
"@frockbot/kernel-contracts": "0.3.23",
|
|
17
17
|
"cordis": "4.0.0-rc.8"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
package/src/authority.ts
CHANGED
|
@@ -1177,19 +1177,58 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1177
1177
|
return this.readRunFrom(this.ctx.storage, runId);
|
|
1178
1178
|
}
|
|
1179
1179
|
|
|
1180
|
+
/**
|
|
1181
|
+
* The run record alone, with no journal behind it.
|
|
1182
|
+
*
|
|
1183
|
+
* Deciding whether a Turn belongs to the conversation being read, and
|
|
1184
|
+
* whether a person is meant to see it, needs the record and nothing else. A
|
|
1185
|
+
* transcript page scans many more candidates than it keeps, and hydrating
|
|
1186
|
+
* every candidate's events to discard it was the whole cost of that scan.
|
|
1187
|
+
* The returned record carries an empty `events` array and its `eventRange`;
|
|
1188
|
+
* anything that reads the journal calls {@link readStoredRun} or
|
|
1189
|
+
* {@link readStoredRunForDisplay}.
|
|
1190
|
+
*/
|
|
1191
|
+
async readRunHeader(
|
|
1192
|
+
runId: string,
|
|
1193
|
+
): Promise<StoredRunV1<Snapshot> | undefined> {
|
|
1194
|
+
return this.codec.optional(
|
|
1195
|
+
await this.ctx.storage.get<unknown>(`${RUN_PREFIX}${runId}`),
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* A run hydrated for the conversation surface: exact for everything the
|
|
1201
|
+
* transcript renders, and each normalized model request left as its durable
|
|
1202
|
+
* projection. See `SessionEventLog.readDisplayRange`.
|
|
1203
|
+
*/
|
|
1204
|
+
async readStoredRunForDisplay(
|
|
1205
|
+
runId: string,
|
|
1206
|
+
): Promise<StoredRunV1<Snapshot> | undefined> {
|
|
1207
|
+
return this.readRunFrom(this.ctx.storage, runId, "display");
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1180
1210
|
private async readRunFrom(
|
|
1181
1211
|
storage: SessionEventLogStorage,
|
|
1182
1212
|
runId: string,
|
|
1213
|
+
fidelity: "exact" | "display" = "exact",
|
|
1183
1214
|
): Promise<StoredRunV1<Snapshot> | undefined> {
|
|
1184
1215
|
const run = this.codec.optional(
|
|
1185
1216
|
await storage.get<unknown>(`${RUN_PREFIX}${runId}`),
|
|
1186
1217
|
);
|
|
1187
1218
|
if (!run?.eventRange) return run;
|
|
1188
|
-
const
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1219
|
+
const log = new SessionEventLog(storage);
|
|
1220
|
+
const events =
|
|
1221
|
+
fidelity === "display"
|
|
1222
|
+
? await log.readDisplayRange(
|
|
1223
|
+
run.sessionId,
|
|
1224
|
+
run.eventRange.startSeq,
|
|
1225
|
+
run.eventRange.endSeq,
|
|
1226
|
+
)
|
|
1227
|
+
: await log.readRange(
|
|
1228
|
+
run.sessionId,
|
|
1229
|
+
run.eventRange.startSeq,
|
|
1230
|
+
run.eventRange.endSeq,
|
|
1231
|
+
);
|
|
1193
1232
|
if (events.length !== run.eventRange.endSeq - run.eventRange.startSeq) {
|
|
1194
1233
|
throw new Error(`run "${run.runId}" has an incomplete event range`);
|
|
1195
1234
|
}
|
|
@@ -1203,9 +1242,10 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1203
1242
|
|
|
1204
1243
|
/**
|
|
1205
1244
|
* The bounded durable event projections for a run. This is the inspection
|
|
1206
|
-
* path: recovery and
|
|
1207
|
-
*
|
|
1208
|
-
* multi-megabyte prompt merely to cut it
|
|
1245
|
+
* path: recovery, compaction and audit use `readStoredRun` and therefore
|
|
1246
|
+
* receive exact events, the transcript uses `readStoredRunForDisplay`, and a
|
|
1247
|
+
* debug snapshot never hydrates a multi-megabyte prompt merely to cut it
|
|
1248
|
+
* again (ADR 0038).
|
|
1209
1249
|
*/
|
|
1210
1250
|
async readRunEventProjections(runId: string): Promise<
|
|
1211
1251
|
| {
|
|
@@ -250,6 +250,138 @@ describe("the paged Session event log", () => {
|
|
|
250
250
|
expect(await log.readRange(SESSION_ID, 2, 3)).toEqual([repaired[2]]);
|
|
251
251
|
});
|
|
252
252
|
|
|
253
|
+
test("reads a range without hydrating unrelated model requests", async () => {
|
|
254
|
+
const storage = new MemoryStorage();
|
|
255
|
+
const session = new Session(SESSION_ID, () => {});
|
|
256
|
+
for (let turn = 1; turn <= 12; turn += 1) {
|
|
257
|
+
session.appendBatch([
|
|
258
|
+
{ type: "turn/start", turn },
|
|
259
|
+
{
|
|
260
|
+
type: "model/request",
|
|
261
|
+
turn,
|
|
262
|
+
step: 1,
|
|
263
|
+
request: {
|
|
264
|
+
requestId: `request-${turn}`,
|
|
265
|
+
provider: "fake",
|
|
266
|
+
model: "large-context",
|
|
267
|
+
system: "s".repeat(80_000),
|
|
268
|
+
messages: [],
|
|
269
|
+
tools: [],
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
{ type: "turn/end", turn, outcome: "completed" },
|
|
273
|
+
]);
|
|
274
|
+
}
|
|
275
|
+
const log = new SessionEventLog(storage);
|
|
276
|
+
await log.rewrite(SESSION_ID, [...session.events]);
|
|
277
|
+
|
|
278
|
+
const reads: string[] = [];
|
|
279
|
+
const get = storage.get.bind(storage);
|
|
280
|
+
storage.get = <T>(key: string): Promise<T | undefined> => {
|
|
281
|
+
reads.push(key);
|
|
282
|
+
return get<T>(key);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const range = await log.readRange(SESSION_ID, 0, 1);
|
|
286
|
+
|
|
287
|
+
expect(range).toEqual([session.events[0]!]);
|
|
288
|
+
expect(
|
|
289
|
+
reads.filter((key) =>
|
|
290
|
+
key.startsWith(sessionEventPayloadPrefixV1(SESSION_ID)),
|
|
291
|
+
),
|
|
292
|
+
).toEqual([]);
|
|
293
|
+
// The index, the bisected pages, and nothing else: the cost of a range is
|
|
294
|
+
// the range, not every request the conversation has ever retained.
|
|
295
|
+
expect(reads.length).toBeLessThanOrEqual(4);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("hydrates only the payloads its own range references", async () => {
|
|
299
|
+
const storage = new MemoryStorage();
|
|
300
|
+
const session = new Session(SESSION_ID, () => {});
|
|
301
|
+
for (let turn = 1; turn <= 12; turn += 1) {
|
|
302
|
+
session.appendBatch([
|
|
303
|
+
{ type: "turn/start", turn },
|
|
304
|
+
{
|
|
305
|
+
type: "model/request",
|
|
306
|
+
turn,
|
|
307
|
+
step: 1,
|
|
308
|
+
request: {
|
|
309
|
+
requestId: `request-${turn}`,
|
|
310
|
+
provider: "fake",
|
|
311
|
+
model: "large-context",
|
|
312
|
+
system: "s".repeat(80_000),
|
|
313
|
+
messages: [],
|
|
314
|
+
tools: [],
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
{ type: "turn/end", turn, outcome: "completed" },
|
|
318
|
+
]);
|
|
319
|
+
}
|
|
320
|
+
const log = new SessionEventLog(storage);
|
|
321
|
+
await log.rewrite(SESSION_ID, [...session.events]);
|
|
322
|
+
|
|
323
|
+
const reads: string[] = [];
|
|
324
|
+
const get = storage.get.bind(storage);
|
|
325
|
+
storage.get = <T>(key: string): Promise<T | undefined> => {
|
|
326
|
+
reads.push(key);
|
|
327
|
+
return get<T>(key);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const range = await log.readRange(SESSION_ID, 30, 33);
|
|
331
|
+
|
|
332
|
+
expect(range).toEqual(session.events.slice(30, 33));
|
|
333
|
+
const payloads = new Set(
|
|
334
|
+
reads
|
|
335
|
+
.filter((key) =>
|
|
336
|
+
key.startsWith(sessionEventPayloadPrefixV1(SESSION_ID)),
|
|
337
|
+
)
|
|
338
|
+
.map((key) => key.slice(0, key.lastIndexOf(":"))),
|
|
339
|
+
);
|
|
340
|
+
const requestSeq = session.events
|
|
341
|
+
.slice(30, 33)
|
|
342
|
+
.find((event) => event.type === "model/request")!.seq;
|
|
343
|
+
expect([...payloads]).toEqual([
|
|
344
|
+
`${sessionEventPayloadPrefixV1(SESSION_ID)}${String(requestSeq).padStart(12, "0")}`,
|
|
345
|
+
]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("leaves exact model requests off the display range", async () => {
|
|
349
|
+
const storage = new MemoryStorage();
|
|
350
|
+
const log = new SessionEventLog(storage);
|
|
351
|
+
const events = journal();
|
|
352
|
+
await log.rewrite(SESSION_ID, events);
|
|
353
|
+
|
|
354
|
+
const reads: string[] = [];
|
|
355
|
+
const get = storage.get.bind(storage);
|
|
356
|
+
storage.get = <T>(key: string): Promise<T | undefined> => {
|
|
357
|
+
reads.push(key);
|
|
358
|
+
return get<T>(key);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const display = await log.readDisplayRange(SESSION_ID, 0, events.length);
|
|
362
|
+
|
|
363
|
+
expect(display.map((event) => event.type)).toEqual(
|
|
364
|
+
events.map((event) => event.type),
|
|
365
|
+
);
|
|
366
|
+
expect(display.map((event) => event.seq)).toEqual(
|
|
367
|
+
events.map((event) => event.seq),
|
|
368
|
+
);
|
|
369
|
+
expect(
|
|
370
|
+
reads.filter((key) =>
|
|
371
|
+
key.startsWith(sessionEventPayloadPrefixV1(SESSION_ID)),
|
|
372
|
+
),
|
|
373
|
+
).toEqual([]);
|
|
374
|
+
const request = display.find((event) => event.type === "model/request");
|
|
375
|
+
expect(request?.type === "model/request" && request.request.requestId).toBe(
|
|
376
|
+
"request-1",
|
|
377
|
+
);
|
|
378
|
+
// The excerpt says what it is; the exact request stays on the audit path.
|
|
379
|
+
expect(
|
|
380
|
+
request?.type === "model/request" && request.request.system.length,
|
|
381
|
+
).toBeLessThan(80_000);
|
|
382
|
+
expect(await log.readRange(SESSION_ID, 0, events.length)).toEqual(events);
|
|
383
|
+
});
|
|
384
|
+
|
|
253
385
|
test("migrates the legacy single value on demand", async () => {
|
|
254
386
|
const storage = new MemoryStorage();
|
|
255
387
|
const events = journal(1_900_000);
|
package/src/session-event-log.ts
CHANGED
|
@@ -338,10 +338,47 @@ export class SessionEventLog {
|
|
|
338
338
|
return events;
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
/**
|
|
342
|
+
* The exact events of one half-open range.
|
|
343
|
+
*
|
|
344
|
+
* A range read seeks to the pages that cover it and hydrates only the
|
|
345
|
+
* payloads those pages reference. Reading the whole conversation to slice
|
|
346
|
+
* three events out of it made a transcript read cost one storage operation
|
|
347
|
+
* per retained model request — a Bot with a hundred 80 KiB requests paid for
|
|
348
|
+
* all of them to draw one Turn.
|
|
349
|
+
*/
|
|
341
350
|
async readRange(
|
|
342
351
|
sessionId: string,
|
|
343
352
|
startSeq: number,
|
|
344
353
|
endSeq: number,
|
|
354
|
+
): Promise<SessionEvent[]> {
|
|
355
|
+
return this.rangeEvents(sessionId, startSeq, endSeq, "exact");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The same range at display fidelity: every event the conversation shows,
|
|
360
|
+
* with each exact model request left on the audit path.
|
|
361
|
+
*
|
|
362
|
+
* The transcript projection reads tool calls, tool results, sends and
|
|
363
|
+
* assistant chunks; it has never rendered a normalized model request. ADR
|
|
364
|
+
* 0033 keeps a bounded projection of that request beside its chunked exact
|
|
365
|
+
* payload precisely so a reader that does not need the exact bytes need not
|
|
366
|
+
* pay for them, and this is that reader. Recovery, compaction, audit, and
|
|
367
|
+
* model-history derivation stay on {@link readRange}.
|
|
368
|
+
*/
|
|
369
|
+
async readDisplayRange(
|
|
370
|
+
sessionId: string,
|
|
371
|
+
startSeq: number,
|
|
372
|
+
endSeq: number,
|
|
373
|
+
): Promise<SessionEvent[]> {
|
|
374
|
+
return this.rangeEvents(sessionId, startSeq, endSeq, "display");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private async rangeEvents(
|
|
378
|
+
sessionId: string,
|
|
379
|
+
startSeq: number,
|
|
380
|
+
endSeq: number,
|
|
381
|
+
fidelity: "exact" | "display",
|
|
345
382
|
): Promise<SessionEvent[]> {
|
|
346
383
|
if (
|
|
347
384
|
!Number.isSafeInteger(startSeq) ||
|
|
@@ -351,7 +388,88 @@ export class SessionEventLog {
|
|
|
351
388
|
) {
|
|
352
389
|
throw new Error("Session event range is invalid");
|
|
353
390
|
}
|
|
354
|
-
|
|
391
|
+
const index = requireIndex(
|
|
392
|
+
await this.storage.get<SessionEventLogIndexV1>(
|
|
393
|
+
sessionEventLogIndexKeyV1(sessionId),
|
|
394
|
+
),
|
|
395
|
+
sessionId,
|
|
396
|
+
);
|
|
397
|
+
if (!index) return (await this.read(sessionId)).slice(startSeq, endSeq);
|
|
398
|
+
const last = Math.min(endSeq, index.eventCount);
|
|
399
|
+
if (startSeq >= last) return [];
|
|
400
|
+
const located = await this.pageContaining(sessionId, index, startSeq);
|
|
401
|
+
if (!located) {
|
|
402
|
+
throw new Error(
|
|
403
|
+
`Session event pages for "${sessionId}" are not contiguous`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
const events: SessionEvent[] = [];
|
|
407
|
+
for (let page = located.page; page < index.pageCount; page += 1) {
|
|
408
|
+
const stored =
|
|
409
|
+
page === located.page
|
|
410
|
+
? located.stored
|
|
411
|
+
: requirePage(
|
|
412
|
+
await this.storage.get<StoredSessionEventPageV1>(
|
|
413
|
+
sessionEventLogPageKey(sessionId, page),
|
|
414
|
+
),
|
|
415
|
+
sessionId,
|
|
416
|
+
page,
|
|
417
|
+
);
|
|
418
|
+
for (const entry of stored.entries) {
|
|
419
|
+
const seq = storedEventSeq(entry);
|
|
420
|
+
if (seq < startSeq) continue;
|
|
421
|
+
if (seq >= last) return events;
|
|
422
|
+
if (seq !== startSeq + events.length) {
|
|
423
|
+
throw new Error(
|
|
424
|
+
`Session event log for "${sessionId}" is not contiguous`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
events.push(
|
|
428
|
+
fidelity === "display"
|
|
429
|
+
? await this.displayEvent(sessionId, entry)
|
|
430
|
+
: await this.exactEvent(sessionId, entry),
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (events.length === last - startSeq) break;
|
|
434
|
+
}
|
|
435
|
+
if (events.length !== last - startSeq) {
|
|
436
|
+
throw new Error(`Session event log for "${sessionId}" is not contiguous`);
|
|
437
|
+
}
|
|
438
|
+
return events;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The page holding `seq`, found by bisecting the page keys.
|
|
443
|
+
*
|
|
444
|
+
* Pages carry their own `startSeq` and are written in sequence order, so the
|
|
445
|
+
* accessor can seek without an extra durable page directory to keep
|
|
446
|
+
* consistent with the pages themselves.
|
|
447
|
+
*/
|
|
448
|
+
private async pageContaining(
|
|
449
|
+
sessionId: string,
|
|
450
|
+
index: SessionEventLogIndexV1,
|
|
451
|
+
seq: number,
|
|
452
|
+
): Promise<{ page: number; stored: StoredSessionEventPageV1 } | undefined> {
|
|
453
|
+
let low = 0;
|
|
454
|
+
let high = index.pageCount - 1;
|
|
455
|
+
let found: { page: number; stored: StoredSessionEventPageV1 } | undefined;
|
|
456
|
+
while (low <= high) {
|
|
457
|
+
const middle = Math.floor((low + high) / 2);
|
|
458
|
+
const stored = requirePage(
|
|
459
|
+
await this.storage.get<StoredSessionEventPageV1>(
|
|
460
|
+
sessionEventLogPageKey(sessionId, middle),
|
|
461
|
+
),
|
|
462
|
+
sessionId,
|
|
463
|
+
middle,
|
|
464
|
+
);
|
|
465
|
+
if (stored.startSeq <= seq) {
|
|
466
|
+
found = { page: middle, stored };
|
|
467
|
+
low = middle + 1;
|
|
468
|
+
} else {
|
|
469
|
+
high = middle - 1;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return found;
|
|
355
473
|
}
|
|
356
474
|
|
|
357
475
|
async readProjections(
|
|
@@ -522,6 +640,23 @@ export class SessionEventLog {
|
|
|
522
640
|
};
|
|
523
641
|
}
|
|
524
642
|
|
|
643
|
+
/**
|
|
644
|
+
* One stored entry at display fidelity.
|
|
645
|
+
*
|
|
646
|
+
* Only a cut `model/request` is answered from its durable projection: every
|
|
647
|
+
* other cut event carries content the conversation renders, so it is
|
|
648
|
+
* hydrated exactly and costs storage in proportion to what is shown.
|
|
649
|
+
*/
|
|
650
|
+
private async displayEvent(
|
|
651
|
+
sessionId: string,
|
|
652
|
+
entry: StoredSessionEventV1,
|
|
653
|
+
): Promise<SessionEvent> {
|
|
654
|
+
if (entry.storage === "cut" && entry.projection.type === "model/request") {
|
|
655
|
+
return decodeSessionEvent(displayModelRequest(entry.projection));
|
|
656
|
+
}
|
|
657
|
+
return this.exactEvent(sessionId, entry);
|
|
658
|
+
}
|
|
659
|
+
|
|
525
660
|
private async exactEvent(
|
|
526
661
|
sessionId: string,
|
|
527
662
|
entry: StoredSessionEventV1,
|
|
@@ -717,6 +852,42 @@ export class SessionEventLog {
|
|
|
717
852
|
}
|
|
718
853
|
}
|
|
719
854
|
|
|
855
|
+
function storedEventSeq(entry: StoredSessionEventV1): number {
|
|
856
|
+
return entry.storage === "inline" ? entry.event.seq : entry.projection.seq;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* A `model/request` event rebuilt from its durable projection.
|
|
861
|
+
*
|
|
862
|
+
* The system prompt is the projection's excerpt and carries its own cut
|
|
863
|
+
* marker; messages and tools are empty because their counts, not their
|
|
864
|
+
* contents, are what the projection retained. This is deliberately not the
|
|
865
|
+
* request the model ran on, and it never reaches recovery, compaction, or
|
|
866
|
+
* audit — only the transcript, which does not render model requests at all.
|
|
867
|
+
*/
|
|
868
|
+
function displayModelRequest(
|
|
869
|
+
projection: StoredSessionEventCutV1["projection"],
|
|
870
|
+
): Record<string, unknown> {
|
|
871
|
+
const request = (projection.request ?? {}) as Record<string, unknown>;
|
|
872
|
+
const excerpt = (request.excerpt ?? {}) as Record<string, unknown>;
|
|
873
|
+
return {
|
|
874
|
+
type: "model/request",
|
|
875
|
+
seq: projection.seq,
|
|
876
|
+
timestamp: projection.timestamp,
|
|
877
|
+
turn: typeof projection.turn === "number" ? projection.turn : 0,
|
|
878
|
+
step: typeof projection.step === "number" ? projection.step : 0,
|
|
879
|
+
request: {
|
|
880
|
+
requestId: typeof request.requestId === "string" ? request.requestId : "",
|
|
881
|
+
provider: typeof request.provider === "string" ? request.provider : "",
|
|
882
|
+
model: typeof request.model === "string" ? request.model : "",
|
|
883
|
+
system: typeof excerpt.system === "string" ? excerpt.system : "",
|
|
884
|
+
messages: [],
|
|
885
|
+
tools: [],
|
|
886
|
+
...(request.modelBinding ? { modelBinding: request.modelBinding } : {}),
|
|
887
|
+
},
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
|
|
720
891
|
function isEventRange(
|
|
721
892
|
value: unknown,
|
|
722
893
|
): value is { startSeq: number; endSeq: number } {
|