@frockbot/kernel-do 0.1.4 → 0.2.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/kernel-do",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
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.1.4",
16
- "@frockbot/kernel-contracts": "0.1.4",
15
+ "@frockbot/kernel-composition": "0.2.1",
16
+ "@frockbot/kernel-contracts": "0.2.1",
17
17
  "cordis": "4.0.0-rc.8"
18
18
  },
19
19
  "devDependencies": {
package/src/authority.ts CHANGED
@@ -322,6 +322,7 @@ export class BotDurableAuthority<Snapshot> {
322
322
  ? { subagentRole: storedRunSubagentRoleV1(run) }
323
323
  : {}),
324
324
  ...(run.admission?.origin ? { origin: run.admission.origin } : {}),
325
+ ...(run.directTool ? { directTool: run.directTool } : {}),
325
326
  },
326
327
  previousEvents: latest,
327
328
  configurationSnapshot: settings,
@@ -717,6 +718,9 @@ export class BotDurableAuthority<Snapshot> {
717
718
  command.origin,
718
719
  command.subagentRole,
719
720
  ),
721
+ ...(command.directTool
722
+ ? { directTool: structuredClone(command.directTool) }
723
+ : {}),
720
724
  } satisfies StoredRunV1<Snapshot>);
721
725
  await transaction.put({
722
726
  [key]: admittedRun,
@@ -980,6 +984,9 @@ export class BotDurableAuthority<Snapshot> {
980
984
  ...(recovery.run.admission?.origin
981
985
  ? { origin: recovery.run.admission.origin }
982
986
  : {}),
987
+ ...(recovery.run.directTool
988
+ ? { directTool: recovery.run.directTool }
989
+ : {}),
983
990
  },
984
991
  recovery.previous,
985
992
  recovery.settings,
@@ -161,6 +161,125 @@ describe("Bot Durable Object Composition records", () => {
161
161
  await expect(store.propose(next)).rejects.toThrow("already exists");
162
162
  });
163
163
 
164
+ test("refuses proposals that remove or replace the first-party bootstrap core", async () => {
165
+ const storage = new MemoryStorage();
166
+ const store = createStore(storage);
167
+ const parent = await store.current();
168
+ const createdAt = "2026-09-01T00:00:00.000Z";
169
+
170
+ const omittedHash = await compositionArtifactSetHashV1([]);
171
+ await expect(
172
+ store.propose({
173
+ schemaVersion: 1,
174
+ generationId: compositionGenerationIdV1(createdAt, omittedHash),
175
+ artifactSetHash: omittedHash,
176
+ parentGenerationId: parent.generationId,
177
+ createdAt,
178
+ origin: {
179
+ kind: "bot-authored",
180
+ runId: "run-1",
181
+ sessionId: "s",
182
+ turnId: "t",
183
+ },
184
+ members: [],
185
+ status: "pending",
186
+ }),
187
+ ).rejects.toThrow(/omits required first-party Package "shell"/);
188
+
189
+ const shadow: CompositionMemberV1 = {
190
+ ...authoredMember,
191
+ packageId: "shell",
192
+ specifier: "bot-authored:shell",
193
+ provenance: {
194
+ ...authoredMember.provenance,
195
+ packageId: "shell",
196
+ },
197
+ };
198
+ const replacedHash = await compositionArtifactSetHashV1([shadow]);
199
+ await expect(
200
+ store.propose({
201
+ schemaVersion: 1,
202
+ generationId: compositionGenerationIdV1(createdAt, replacedHash),
203
+ artifactSetHash: replacedHash,
204
+ parentGenerationId: parent.generationId,
205
+ createdAt,
206
+ origin: {
207
+ kind: "bot-authored",
208
+ runId: "run-1",
209
+ sessionId: "s",
210
+ turnId: "t",
211
+ },
212
+ members: [shadow],
213
+ status: "pending",
214
+ }),
215
+ ).rejects.toThrow(
216
+ /replaces required first-party Package "shell" with bot provenance/,
217
+ );
218
+
219
+ const catalogShadow: CompositionMemberV1 = {
220
+ packageId: "shell",
221
+ specifier: "catalog:shell",
222
+ version: "0.0.1",
223
+ manifestHash: authoredMember.manifestHash,
224
+ provenance: {
225
+ kind: "catalog",
226
+ packageId: "shell",
227
+ version: "0.0.1",
228
+ catalogId: "shell",
229
+ catalogGeneration: "catalog-1",
230
+ contentHash: authoredMember.artifact!.contentHash,
231
+ },
232
+ artifact: authoredMember.artifact,
233
+ };
234
+ const catalogHash = await compositionArtifactSetHashV1([catalogShadow]);
235
+ await expect(
236
+ store.propose({
237
+ schemaVersion: 1,
238
+ generationId: compositionGenerationIdV1(createdAt, catalogHash),
239
+ artifactSetHash: catalogHash,
240
+ parentGenerationId: parent.generationId,
241
+ createdAt,
242
+ origin: {
243
+ kind: "bot-catalog",
244
+ action: "install",
245
+ packageId: "shell",
246
+ catalogId: "shell",
247
+ botId: "bot-1",
248
+ runId: "run-1",
249
+ sessionId: "s",
250
+ turnId: "t",
251
+ },
252
+ summary: "Added shell",
253
+ members: [catalogShadow],
254
+ status: "pending",
255
+ }),
256
+ ).rejects.toThrow(
257
+ /replaces required first-party Package "shell" with catalog provenance/,
258
+ );
259
+
260
+ const changedCore = {
261
+ ...parent.members[0]!,
262
+ version: "9.9.9",
263
+ provenance: {
264
+ ...parent.members[0]!.provenance,
265
+ version: "9.9.9",
266
+ },
267
+ };
268
+ const changedHash = await compositionArtifactSetHashV1([changedCore]);
269
+ await expect(
270
+ store.propose({
271
+ schemaVersion: 1,
272
+ generationId: compositionGenerationIdV1(createdAt, changedHash),
273
+ artifactSetHash: changedHash,
274
+ parentGenerationId: parent.generationId,
275
+ createdAt,
276
+ origin: { kind: "user-install", userId: "user-1" },
277
+ members: [changedCore],
278
+ status: "pending",
279
+ }),
280
+ ).resolves.toBeUndefined();
281
+ });
282
+
164
283
  test("reverting records a new pending generation with the target's members", async () => {
165
284
  const storage = new MemoryStorage();
166
285
  const store = createStore(
@@ -244,6 +363,41 @@ describe("Bot Durable Object Composition records", () => {
244
363
  ]);
245
364
  });
246
365
 
366
+ test("a Bot-origin revert is idempotent and cannot mark its generation last-known-good", async () => {
367
+ const storage = new MemoryStorage();
368
+ const store = createStore(storage);
369
+ const bootstrapped = await store.current();
370
+ const authored = await grownSuccessor(
371
+ bootstrapped,
372
+ "2026-09-01T00:00:00.000Z",
373
+ );
374
+ await store.propose(authored);
375
+ await store.commit(authored.generationId);
376
+ const origin = {
377
+ kind: "revert" as const,
378
+ revertsTo: bootstrapped.generationId,
379
+ botId: "bot-1",
380
+ runId: "run-undo",
381
+ turnId: "turn-undo",
382
+ };
383
+
384
+ const reverted = await store.revert(bootstrapped.generationId, origin, {
385
+ createdAt: "2026-09-02T00:00:00.000Z",
386
+ });
387
+ const replay = await store.revert(bootstrapped.generationId, origin, {
388
+ createdAt: "2026-09-02T00:00:00.000Z",
389
+ });
390
+
391
+ expect(replay).toEqual(reverted);
392
+ expect((await store.lastKnownGood()).generationId).toBe(
393
+ authored.generationId,
394
+ );
395
+ await store.fail(reverted.generationId, { quarantined: false });
396
+ expect((await store.lastKnownGood()).generationId).toBe(
397
+ authored.generationId,
398
+ );
399
+ });
400
+
247
401
  test("refuses an unknown target and the generation that is already current", async () => {
248
402
  const storage = new MemoryStorage();
249
403
  const store = createStore(
@@ -162,6 +162,8 @@ export class DurableCompositionStore implements CompositionStore {
162
162
  await assertCompositionArtifactSetHashV1(proposed);
163
163
  await this.materialize();
164
164
  await this.ctx.storage.transaction(async (transaction) => {
165
+ const bootstrap = await this.bootstrapGeneration(transaction);
166
+ this.assertRequiredCoreSet(bootstrap, proposed);
165
167
  const key = compositionGenerationKey(proposed.generationId);
166
168
  if ((await transaction.get<unknown>(key)) !== undefined) {
167
169
  throw new Error(
@@ -342,6 +344,7 @@ export class DurableCompositionStore implements CompositionStore {
342
344
  async revert(
343
345
  toGenerationId: string,
344
346
  origin: Extract<CompositionOriginV1, { kind: "revert" }>,
347
+ options: { createdAt?: string } = {},
345
348
  ): Promise<CompositionGenerationV1> {
346
349
  if (origin.kind !== "revert" || origin.revertsTo !== toGenerationId) {
347
350
  throw new Error("composition revert origin does not name its target");
@@ -356,7 +359,7 @@ export class DurableCompositionStore implements CompositionStore {
356
359
  if (!target) {
357
360
  throw new Error(`composition generation "${toGenerationId}" is unknown`);
358
361
  }
359
- const createdAt = this.now().toISOString();
362
+ const createdAt = options.createdAt ?? this.now().toISOString();
360
363
  const generation = decodeCompositionGenerationV1({
361
364
  schemaVersion: 1,
362
365
  generationId: compositionGenerationIdV1(
@@ -370,6 +373,18 @@ export class DurableCompositionStore implements CompositionStore {
370
373
  members: target.members,
371
374
  status: "pending",
372
375
  });
376
+ const existing = await this.read(generation.generationId);
377
+ if (existing) {
378
+ if (
379
+ existing.artifactSetHash === generation.artifactSetHash &&
380
+ JSON.stringify(existing.origin) === JSON.stringify(origin)
381
+ ) {
382
+ return existing;
383
+ }
384
+ throw new Error(
385
+ `composition generation "${generation.generationId}" already exists for another operation`,
386
+ );
387
+ }
373
388
  await this.propose(generation, { pin: true });
374
389
  return generation;
375
390
  }
@@ -431,6 +446,34 @@ export class DurableCompositionStore implements CompositionStore {
431
446
  return decodeCompositionGenerationV1(stored);
432
447
  }
433
448
 
449
+ /**
450
+ * Every first-party bootstrap member is required core. No proposal path may
451
+ * remove it or replace its provenance: callers can update reviewed
452
+ * first-party members, but cannot turn them into User- or Bot-authored code.
453
+ */
454
+ private assertRequiredCoreSet(
455
+ bootstrap: CompositionGenerationV1,
456
+ proposed: CompositionGenerationV1,
457
+ ): void {
458
+ for (const required of bootstrap.members.filter(
459
+ (member) => member.provenance.kind === "first-party",
460
+ )) {
461
+ const candidate = proposed.members.find(
462
+ (member) => member.packageId === required.packageId,
463
+ );
464
+ if (!candidate) {
465
+ throw new Error(
466
+ `composition generation "${proposed.generationId}" omits required first-party Package "${required.packageId}"`,
467
+ );
468
+ }
469
+ if (candidate.provenance.kind !== "first-party") {
470
+ throw new Error(
471
+ `composition generation "${proposed.generationId}" replaces required first-party Package "${required.packageId}" with ${candidate.provenance.kind} provenance`,
472
+ );
473
+ }
474
+ }
475
+ }
476
+
434
477
  /**
435
478
  * The durable, visible record that quarantine had no last known good to fail
436
479
  * into. Written against the generation that was named last known good, whose
@@ -0,0 +1,81 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ botTurnCommandFingerprintV1,
4
+ createStoredRunCodecV1,
5
+ } from "./run-records.js";
6
+ import { planBotRunRecovery } from "./run-recovery.js";
7
+
8
+ describe("durable direct tool commands", () => {
9
+ const directTool = {
10
+ generationId: "generation-1",
11
+ packageId: "weather-page",
12
+ name: "weather_lookup",
13
+ input: { city: "Sydney" },
14
+ };
15
+
16
+ test("survives the exact stored-run codec for eviction recovery", () => {
17
+ const codec = createStoredRunCodecV1({
18
+ decodeRunId: (value) => {
19
+ if (typeof value !== "string") throw new Error("invalid");
20
+ return value;
21
+ },
22
+ decodeConfigurationSnapshot: (value) => value,
23
+ });
24
+ const run = codec.require({
25
+ runId: "command-1",
26
+ commandFingerprint: "fingerprint",
27
+ sessionId: "user:bot",
28
+ acceptedAt: "2026-09-02T00:00:00.000Z",
29
+ input: "Weather page · weather_lookup",
30
+ events: [],
31
+ effectAdmissions: [],
32
+ status: "running",
33
+ phase: "admitted",
34
+ compositionGenerationId: "generation-1",
35
+ configurationSnapshot: {},
36
+ previousEventCount: 0,
37
+ directTool,
38
+ });
39
+ expect(run.directTool).toEqual(directTool);
40
+ });
41
+
42
+ test("is part of the admitted command idempotency fingerprint", () => {
43
+ const base = {
44
+ userId: "user",
45
+ botId: "bot",
46
+ runId: "command-1",
47
+ sessionId: "user:bot",
48
+ acceptedAt: "2026-09-02T00:00:00.000Z",
49
+ text: "Weather page · weather_lookup",
50
+ };
51
+ expect(botTurnCommandFingerprintV1({ ...base, directTool })).not.toBe(
52
+ botTurnCommandFingerprintV1(base),
53
+ );
54
+ });
55
+
56
+ test("resumes after eviction without requiring a synthetic model request", () => {
57
+ const codec = createStoredRunCodecV1({
58
+ decodeRunId: (value) => {
59
+ if (typeof value !== "string") throw new Error("invalid");
60
+ return value;
61
+ },
62
+ decodeConfigurationSnapshot: (value) => value,
63
+ });
64
+ const run = codec.require({
65
+ runId: "command-1",
66
+ commandFingerprint: "fingerprint",
67
+ sessionId: "user:bot",
68
+ acceptedAt: "2026-09-02T00:00:00.000Z",
69
+ input: "Weather page · weather_lookup",
70
+ events: [],
71
+ effectAdmissions: [],
72
+ status: "running",
73
+ phase: "executing",
74
+ compositionGenerationId: "generation-1",
75
+ configurationSnapshot: {},
76
+ previousEventCount: 0,
77
+ directTool,
78
+ });
79
+ expect(planBotRunRecovery(run, [], codec)).toEqual({ kind: "resume" });
80
+ });
81
+ });
@@ -11,9 +11,9 @@ import {
11
11
  * The kernel records the Composition/configuration snapshot a Turn was admitted
12
12
  * under, but never interprets it: the owning Package supplies the decoder.
13
13
  */
14
- export interface StoredRunCodecOptionsV1 {
14
+ export interface StoredRunCodecOptionsV1<Snapshot> {
15
15
  decodeRunId(value: unknown): string;
16
- decodeConfigurationSnapshot(value: unknown): unknown;
16
+ decodeConfigurationSnapshot(value: unknown): Snapshot;
17
17
  }
18
18
 
19
19
  export interface StoredRunCodecV1<Snapshot> {
@@ -112,6 +112,15 @@ export interface StoredRunV1<Snapshot = unknown> {
112
112
  previousEventCount: number;
113
113
  /** Absent ⇒ the run was admitted as a `chat` Turn. */
114
114
  admission?: StoredRunAdmissionV1;
115
+ /** An ordinary admitted Turn whose only action is one caller-selected tool. */
116
+ directTool?: DirectToolCommandV1;
117
+ }
118
+
119
+ export interface DirectToolCommandV1 {
120
+ generationId: string;
121
+ packageId: string;
122
+ name: string;
123
+ input: unknown;
115
124
  }
116
125
 
117
126
  /** The subagent role a stored run re-mounts under, if any. */
@@ -185,6 +194,7 @@ const STORED_RUN_OPTIONAL_KEYS = [
185
194
  "failure",
186
195
  "stopRequestedAt",
187
196
  "admission",
197
+ "directTool",
188
198
  ] as const;
189
199
  const UTF8_ENCODER = new TextEncoder();
190
200
 
@@ -200,6 +210,44 @@ function boundedString(
200
210
  );
201
211
  }
202
212
 
213
+ function decodeDirectToolCommandV1(value: unknown): DirectToolCommandV1 {
214
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
215
+ throw new Error("stored run has invalid direct tool command");
216
+ }
217
+ const candidate = value as Record<PropertyKey, unknown>;
218
+ const fields = ["generationId", "packageId", "name", "input"];
219
+ if (
220
+ Reflect.ownKeys(candidate).length !== fields.length ||
221
+ Object.keys(candidate).length !== fields.length ||
222
+ !fields.every((field) => Object.hasOwn(candidate, field)) ||
223
+ !boundedString(candidate.generationId, 256) ||
224
+ !boundedString(candidate.packageId, 64) ||
225
+ !boundedString(candidate.name, 64) ||
226
+ !/^[a-z][a-z0-9_]{0,63}$/.test(candidate.name)
227
+ ) {
228
+ throw new Error("stored run has invalid direct tool command fields");
229
+ }
230
+ let input: unknown;
231
+ try {
232
+ const encoded = JSON.stringify(candidate.input);
233
+ if (
234
+ encoded === undefined ||
235
+ UTF8_ENCODER.encode(encoded).byteLength > 64_000
236
+ ) {
237
+ throw new Error("invalid input");
238
+ }
239
+ input = JSON.parse(encoded) as unknown;
240
+ } catch {
241
+ throw new Error("stored run has invalid direct tool input");
242
+ }
243
+ return {
244
+ generationId: candidate.generationId,
245
+ packageId: candidate.packageId,
246
+ name: candidate.name,
247
+ input,
248
+ };
249
+ }
250
+
203
251
  /**
204
252
  * Exact fields, per origin kind. Each kind gets its own branch rather than a
205
253
  * union of optional fields: a `routine` origin carrying a `taskId` is not a
@@ -374,7 +422,7 @@ function decodeStoredEffectAdmissions(value: unknown): StoredEffectAdmission[] {
374
422
  }
375
423
 
376
424
  export function createStoredRunCodecV1<Snapshot>(
377
- options: StoredRunCodecOptionsV1,
425
+ options: StoredRunCodecOptionsV1<Snapshot>,
378
426
  ): StoredRunCodecV1<Snapshot> {
379
427
  const require = (input: unknown): StoredRunV1<Snapshot> =>
380
428
  requireStoredRunRecordV1(input, options);
@@ -386,7 +434,7 @@ export function createStoredRunCodecV1<Snapshot>(
386
434
 
387
435
  function requireStoredRunRecordV1<Snapshot>(
388
436
  input: unknown,
389
- options: StoredRunCodecOptionsV1,
437
+ options: StoredRunCodecOptionsV1<Snapshot>,
390
438
  ): StoredRunV1<Snapshot> {
391
439
  if (!input || typeof input !== "object" || Array.isArray(input)) {
392
440
  throw new Error("stored run is invalid");
@@ -451,7 +499,9 @@ function requireStoredRunRecordV1<Snapshot>(
451
499
  ) {
452
500
  throw new Error(`run "${runId}" has no valid previous event count`);
453
501
  }
454
- options.decodeConfigurationSnapshot(candidate.configurationSnapshot);
502
+ const configurationSnapshot = options.decodeConfigurationSnapshot(
503
+ candidate.configurationSnapshot,
504
+ );
455
505
  if (
456
506
  candidate.responseText !== undefined &&
457
507
  !boundedString(candidate.responseText, 64_000, true)
@@ -505,7 +555,7 @@ function requireStoredRunRecordV1<Snapshot>(
505
555
  status,
506
556
  phase,
507
557
  compositionGenerationId: candidate.compositionGenerationId,
508
- configurationSnapshot: candidate.configurationSnapshot as Snapshot,
558
+ configurationSnapshot,
509
559
  previousEventCount: candidate.previousEventCount as number,
510
560
  ...(candidate.responseText === undefined
511
561
  ? {}
@@ -519,6 +569,9 @@ function requireStoredRunRecordV1<Snapshot>(
519
569
  ...(candidate.admission === undefined
520
570
  ? {}
521
571
  : { admission: decodeStoredRunAdmission(candidate.admission, runId) }),
572
+ ...(candidate.directTool === undefined
573
+ ? {}
574
+ : { directTool: decodeDirectToolCommandV1(candidate.directTool) }),
522
575
  };
523
576
  }
524
577
 
@@ -548,6 +601,7 @@ export interface BotTurnCommand {
548
601
  * command, so it must not collide on an idempotency record.
549
602
  */
550
603
  skills?: SkillRefV1[];
604
+ directTool?: DirectToolCommandV1;
551
605
  }
552
606
 
553
607
  /**
@@ -566,7 +620,8 @@ export function botTurnCommandFingerprintV1(
566
620
  turnType !== "chat" ||
567
621
  command.origin !== undefined ||
568
622
  command.subagentRole !== undefined ||
569
- skills.length > 0
623
+ skills.length > 0 ||
624
+ command.directTool !== undefined
570
625
  ) {
571
626
  return `bot-turn-command-v2:${JSON.stringify({
572
627
  userId: command.userId,
@@ -577,6 +632,7 @@ export function botTurnCommandFingerprintV1(
577
632
  ...(command.subagentRole ? { subagentRole: command.subagentRole } : {}),
578
633
  ...(command.origin ? { origin: command.origin } : {}),
579
634
  ...(skills.length > 0 ? { skills: skills.map(formatSkillRefV1) } : {}),
635
+ ...(command.directTool ? { directTool: command.directTool } : {}),
580
636
  })}`;
581
637
  }
582
638
  return `bot-turn-command-v1:${JSON.stringify({
@@ -97,6 +97,11 @@ export function planBotRunRecovery<Snapshot>(
97
97
  lastAssistant?.type === "assistant/message" ? lastAssistant.text : "",
98
98
  };
99
99
  }
100
+ // A direct tool Turn has no model request by construction. Its single
101
+ // synthetic assistant/tool occurrence is nevertheless resumable at every
102
+ // durable boundary: before intent it can start, after intent it reconciles,
103
+ // and after result it only needs its terminal events appended.
104
+ if (run.directTool) return { kind: "resume" };
100
105
  const modelState = latestModelRequestJournalState(run.events);
101
106
  if (modelState.status === "no-effect") {
102
107
  try {