@frockbot/kernel-composition 0.1.3 → 0.2.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.
@@ -5,21 +5,30 @@
5
5
  // It sits beside `LocalCordisContributionHost` because it is the *other*
6
6
  // execution host the constitution names — first-party Packages run in the
7
7
  // kernel isolate, everything else runs in a loaded Worker with
8
- // `globalOutbound` disabled and only Assignment-derived bindings.
8
+ // `globalOutbound` disabled and only the Bot's authority bindings.
9
9
  //
10
10
  // Two behaviours come straight from `docs/research/spike-worker-loader-from-do.md`:
11
11
  // `.get()` never throws, so mount and `health()` are a single guarded phase;
12
12
  // and a reused loader id silently serves the first code, so the id is nothing
13
13
  // but the content address of the module set actually mounted.
14
14
  import {
15
+ decodeBotIsolateHookReplacementV1,
15
16
  decodeIsolateHealthV1,
17
+ decodeIsolateHookResultV1,
16
18
  decodeIsolateToolResultV1,
17
19
  isolateToolSchemaV1,
18
20
  ISOLATE_MAX_DEADLINE_MS,
19
21
  isolateLoaderIdV1,
20
22
  type BotCapabilitiesStub,
21
23
  type BotIsolateEntrypoint,
24
+ type BotIsolateHookEventNameV1,
22
25
  type IsolateHealthV1,
26
+ type IsolateHookInvocationV1,
27
+ type LoopAgentRuntimeV1,
28
+ type LoopEventPayloadMapV1,
29
+ type LoopEventReturnMapV1,
30
+ type LoopStepSnapshotV1,
31
+ loopToolExecutionContextSnapshotV1,
23
32
  type IsolateToolDescriptorV1,
24
33
  type IsolateToolInvocationV1,
25
34
  type ToolDefinition,
@@ -28,6 +37,7 @@ import {
28
37
  type ToolRegistration,
29
38
  type TurnTypeV1,
30
39
  } from "@frockbot/kernel-contracts";
40
+ import type { Context } from "cordis";
31
41
  import { CompositionMountFailureError } from "./activation.ts";
32
42
  import { canonicalJson, sha256 } from "./compiler.ts";
33
43
  import type { CompositionMemberV1 } from "./generation.ts";
@@ -81,13 +91,19 @@ export interface BotIsolateHostOptions {
81
91
  loader: BotIsolateLoader;
82
92
  artifacts: BotIsolateArtifactStore;
83
93
  /** Where the isolate's tools are registered — the kernel's tool surface. */
84
- tools: ToolRegistration;
94
+ tools: Pick<ToolRegistration, "register">;
95
+ /** The mounted Bot/generation root. Isolate hook listeners live only here. */
96
+ loop: Context;
85
97
  userId: string;
86
98
  botId: string;
87
99
  sessionId: string;
88
100
  runId: string;
89
101
  turnId: string;
90
102
  generationId: string;
103
+ turnType: TurnTypeV1;
104
+ subagentRole?: string;
105
+ /** Persists a visible failure without letting a broken hook wedge the loop. */
106
+ recordHookFailure(failure: IsolateHookFailureV1): Promise<void>;
91
107
  /**
92
108
  * The loopback service binding minted with
93
109
  * `ctx.exports.BotCapabilities({ props })`. Opaque to the kernel: it only
@@ -95,10 +111,10 @@ export interface BotIsolateHostOptions {
95
111
  */
96
112
  capabilities: BotCapabilitiesStub;
97
113
  /**
98
- * A content address of the Assignment-derived bindings this isolate is
114
+ * A content address of the Bot authority bindings this isolate is
99
115
  * loaded with. Required, and part of the loader id, because a loader id is
100
116
  * served from cache: the `env` a Bot isolate was first loaded with is the
101
- * `env` it keeps, so a change in the Bot's Assignments must produce a new
117
+ * `env` it keeps, so a change in the Bot's Connections must produce a new
102
118
  * isolate or the isolate would keep answering from a revoked authority.
103
119
  */
104
120
  bindingDigest: string;
@@ -110,6 +126,13 @@ export interface BotIsolateHostOptions {
110
126
  healthDeadlineMs?: number;
111
127
  }
112
128
 
129
+ export interface IsolateHookFailureV1 {
130
+ packageId: string;
131
+ event: BotIsolateHookEventNameV1;
132
+ generationId: string;
133
+ message: string;
134
+ }
135
+
113
136
  export const BOT_ISOLATE_DEFAULT_LIMITS: BotIsolateLimits = {
114
137
  cpuMs: 5_000,
115
138
  subRequests: 5,
@@ -120,7 +143,7 @@ export const BOT_ISOLATE_DEFAULT_HEALTH_DEADLINE_MS = 10_000;
120
143
 
121
144
  /**
122
145
  * The content address of what a Bot isolate mounts: the kernel wrapper text,
123
- * the Package artifact, and the digest of the Assignment-derived bindings it
146
+ * the Package artifact, and the digest of the Bot authority bindings it
124
147
  * is loaded with. A change to any of the three is a new isolate.
125
148
  */
126
149
  export async function botIsolateModuleSetHashV1(
@@ -195,26 +218,34 @@ export function botIsolateSubagentRoleCeilingV1(
195
218
  }
196
219
 
197
220
  /**
198
- * A Composition member projected onto the descriptor a contribution host
199
- * consumes. The durable record keeps the member's `manifestHash`, not its
200
- * manifest, so the projection carries only what the isolate host reads: the
201
- * Package identity, its specifier, and its immutable artifact.
221
+ * A Composition member and its stored manifest projected onto the descriptor
222
+ * a contribution host consumes. Hash and identity checks happen here so no
223
+ * mount caller can replace the durable manifest with a synthesized one.
202
224
  */
203
- export function botIsolatePackageDescriptorV1(
225
+ export async function botIsolatePackageDescriptorV1(
204
226
  member: CompositionMemberV1,
205
- ): PackageDescriptor {
227
+ storedManifest: unknown,
228
+ ): Promise<PackageDescriptor> {
229
+ const manifestHash = await sha256(canonicalJson(storedManifest));
230
+ if (manifestHash !== member.manifestHash) {
231
+ throw new Error(
232
+ `package "${member.packageId}" stored manifest failed hash verification`,
233
+ );
234
+ }
235
+ const manifest = decodeFrockBotManifest(storedManifest);
236
+ if (manifest.id !== member.packageId || manifest.version !== member.version) {
237
+ throw new Error(
238
+ `package "${member.packageId}" stored manifest does not match its Composition member`,
239
+ );
240
+ }
241
+ if (manifest.contributions.runtime?.host !== "bot-isolate") {
242
+ throw new Error(
243
+ `package "${member.packageId}" manifest declares no Bot isolate runtime`,
244
+ );
245
+ }
206
246
  return {
207
247
  specifier: member.specifier,
208
- manifest: decodeFrockBotManifest({
209
- schemaVersion: 3,
210
- id: member.packageId,
211
- displayName: member.packageId,
212
- version: member.version,
213
- compatibility: { frockbot: `^${member.version}` },
214
- dependencies: {},
215
- contributions: { runtime: { entry: "./package.js" } },
216
- permissions: [],
217
- }),
248
+ manifest,
218
249
  ...(member.artifact ? { artifact: member.artifact } : {}),
219
250
  };
220
251
  }
@@ -241,7 +272,6 @@ export class BotIsolateContributionHost implements ContributionHost {
241
272
  const source = await this.loadSource(packageId, artifact.contentHash);
242
273
  const loaderId = isolateLoaderIdV1({
243
274
  userId: this.options.userId,
244
- botId: this.options.botId,
245
275
  artifactSetHash: await botIsolateModuleSetHashV1(
246
276
  artifact.contentHash,
247
277
  this.options.bindingDigest,
@@ -289,6 +319,38 @@ export class BotIsolateContributionHost implements ContributionHost {
289
319
  [`reported:${health.packageId}`],
290
320
  );
291
321
  }
322
+ const declaredTools = (pkg.manifest.tools ?? [])
323
+ .map((tool) => tool.name)
324
+ .toSorted();
325
+ const reportedTools = health.tools.map((tool) => tool.name).toSorted();
326
+ if (
327
+ declaredTools.length !== reportedTools.length ||
328
+ declaredTools.some((name, index) => name !== reportedTools[index])
329
+ ) {
330
+ throw new CompositionMountFailureError(
331
+ "health",
332
+ `package "${packageId}" isolate tools do not match its stored manifest`,
333
+ [
334
+ `declared:${declaredTools.join(",")}`,
335
+ `reported:${reportedTools.join(",")}`,
336
+ ],
337
+ );
338
+ }
339
+ const declaredHooks = (pkg.manifest.hooks ?? []).toSorted();
340
+ const reportedHooks = (health.hooks ?? []).toSorted();
341
+ if (
342
+ declaredHooks.length !== reportedHooks.length ||
343
+ declaredHooks.some((name, index) => name !== reportedHooks[index])
344
+ ) {
345
+ throw new CompositionMountFailureError(
346
+ "health",
347
+ `package "${packageId}" isolate hooks do not match its stored manifest`,
348
+ [
349
+ `declared:${declaredHooks.join(",")}`,
350
+ `reported:${reportedHooks.join(",")}`,
351
+ ],
352
+ );
353
+ }
292
354
 
293
355
  let disposed = false;
294
356
  const registered: (() => void)[] = [];
@@ -316,6 +378,9 @@ export class BotIsolateContributionHost implements ContributionHost {
316
378
  ),
317
379
  );
318
380
  }
381
+ for (const event of health.hooks ?? []) {
382
+ registered.push(this.registerHook(packageId, entrypoint, event));
383
+ }
319
384
  return Promise.resolve({
320
385
  dispose: () => {
321
386
  if (disposed) return Promise.resolve();
@@ -370,6 +435,222 @@ export class BotIsolateContributionHost implements ContributionHost {
370
435
  );
371
436
  }
372
437
 
438
+ private agentSnapshot(agent: LoopAgentRuntimeV1) {
439
+ return {
440
+ botId: agent.botId,
441
+ agentId: agent.id,
442
+ sessionId: agent.session.id,
443
+ status: agent.status,
444
+ } as const;
445
+ }
446
+
447
+ private stepSnapshot(
448
+ agent: LoopAgentRuntimeV1,
449
+ turn: number,
450
+ step: number,
451
+ ): LoopStepSnapshotV1 {
452
+ return {
453
+ ...this.agentSnapshot(agent),
454
+ compositionGenerationId: this.options.generationId,
455
+ turn,
456
+ step,
457
+ turnType: this.options.turnType,
458
+ ...(this.options.subagentRole === undefined
459
+ ? {}
460
+ : { subagentRole: this.options.subagentRole }),
461
+ };
462
+ }
463
+
464
+ private registerHook(
465
+ packageId: string,
466
+ entrypoint: BotIsolateEntrypoint,
467
+ event: BotIsolateHookEventNameV1,
468
+ ): () => void {
469
+ const root = this.options.loop;
470
+ switch (event) {
471
+ case "agent/pre-step":
472
+ return root.on(event, async (agent, _inputs, turn, step, next) => {
473
+ const current = await next();
474
+ if (agent.botId !== this.options.botId) return current;
475
+ return this.invokeHook(
476
+ packageId,
477
+ entrypoint,
478
+ event,
479
+ {
480
+ step: this.stepSnapshot(agent, turn, step),
481
+ inputs: current.kind === "enter" ? current.inputs : _inputs,
482
+ decision: current,
483
+ },
484
+ current,
485
+ );
486
+ });
487
+ case "system-prompt/assemble":
488
+ return root.on(event, async (context, next) => {
489
+ const current = await next();
490
+ return this.invokeHook(
491
+ packageId,
492
+ entrypoint,
493
+ event,
494
+ { context: structuredClone(context), assembly: current },
495
+ current,
496
+ );
497
+ });
498
+ case "agent/message-window":
499
+ return root.on(
500
+ event,
501
+ async (agent, _messages, turn, step, signal, next) => {
502
+ const current = await next();
503
+ if (agent.botId !== this.options.botId) return current;
504
+ return this.invokeHook(
505
+ packageId,
506
+ entrypoint,
507
+ event,
508
+ {
509
+ step: this.stepSnapshot(agent, turn, step),
510
+ messages: current,
511
+ },
512
+ current,
513
+ signal,
514
+ );
515
+ },
516
+ );
517
+ case "agent/tool-exposure":
518
+ return root.on(
519
+ event,
520
+ async (agent, _tools, turn, step, signal, next) => {
521
+ const current = await next();
522
+ if (agent.botId !== this.options.botId) return current;
523
+ return this.invokeHook(
524
+ packageId,
525
+ entrypoint,
526
+ event,
527
+ { step: this.stepSnapshot(agent, turn, step), tools: current },
528
+ current,
529
+ signal,
530
+ );
531
+ },
532
+ );
533
+ case "tools/pre-execute":
534
+ return root.on(event, async (call, context, next) => {
535
+ const current = await next();
536
+ if (
537
+ context.botId !== this.options.botId ||
538
+ context.compositionGenerationId !== this.options.generationId
539
+ ) {
540
+ return current;
541
+ }
542
+ return this.invokeHook(
543
+ packageId,
544
+ entrypoint,
545
+ event,
546
+ {
547
+ call,
548
+ context: loopToolExecutionContextSnapshotV1(context),
549
+ preparation: current,
550
+ },
551
+ current,
552
+ context.signal,
553
+ );
554
+ });
555
+ case "tools/post-execute":
556
+ return root.on(event, async (call, _result, context, next) => {
557
+ const current = await next();
558
+ if (
559
+ context.botId !== this.options.botId ||
560
+ context.compositionGenerationId !== this.options.generationId
561
+ ) {
562
+ return current;
563
+ }
564
+ return this.invokeHook(
565
+ packageId,
566
+ entrypoint,
567
+ event,
568
+ {
569
+ call,
570
+ context: loopToolExecutionContextSnapshotV1(context),
571
+ result: current,
572
+ },
573
+ current,
574
+ context.signal,
575
+ );
576
+ });
577
+ case "agent/step-continuation":
578
+ return root.on(
579
+ event,
580
+ async (agent, _decision, turn, step, signal, next) => {
581
+ const current = await next();
582
+ if (agent.botId !== this.options.botId) return current;
583
+ return this.invokeHook(
584
+ packageId,
585
+ entrypoint,
586
+ event,
587
+ {
588
+ step: this.stepSnapshot(agent, turn, step),
589
+ decision: current,
590
+ },
591
+ current,
592
+ signal,
593
+ );
594
+ },
595
+ );
596
+ }
597
+ }
598
+
599
+ private async invokeHook<Event extends BotIsolateHookEventNameV1>(
600
+ packageId: string,
601
+ entrypoint: BotIsolateEntrypoint,
602
+ event: Event,
603
+ payload: LoopEventPayloadMapV1[Event],
604
+ original: LoopEventReturnMapV1[Event],
605
+ signal?: AbortSignal,
606
+ ): Promise<LoopEventReturnMapV1[Event]> {
607
+ const deadlineMs = Math.min(
608
+ this.options.deadlineMs ?? BOT_ISOLATE_DEFAULT_DEADLINE_MS,
609
+ ISOLATE_MAX_DEADLINE_MS,
610
+ );
611
+ try {
612
+ const invocation: IsolateHookInvocationV1<Event> = {
613
+ schemaVersion: 1,
614
+ event,
615
+ payload: structuredClone(payload),
616
+ botId: this.options.botId,
617
+ sessionId: this.options.sessionId,
618
+ runId: this.options.runId,
619
+ turnId: this.options.turnId,
620
+ generationId: this.options.generationId,
621
+ deadlineMs,
622
+ };
623
+ const result = decodeIsolateHookResultV1(
624
+ await raceDeadline(
625
+ () => entrypoint.hook(invocation),
626
+ deadlineMs,
627
+ signal,
628
+ ),
629
+ `package "${packageId}" isolate hook result`,
630
+ );
631
+ if (result.status === "unchanged") return original;
632
+ return decodeBotIsolateHookReplacementV1(
633
+ event,
634
+ result.replacement,
635
+ original,
636
+ );
637
+ } catch (error) {
638
+ const message = errorMessage(error).slice(0, 2_048);
639
+ try {
640
+ await this.options.recordHookFailure({
641
+ packageId,
642
+ event,
643
+ generationId: this.options.generationId,
644
+ message,
645
+ });
646
+ } catch {
647
+ // Failure recording is itself an external durability boundary. A
648
+ // broken hook still cannot wedge the loop if that boundary is down.
649
+ }
650
+ return original;
651
+ }
652
+ }
653
+
373
654
  private definition(
374
655
  packageId: string,
375
656
  entrypoint: BotIsolateEntrypoint,
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { BOT_ISOLATE_CONTEXT_KEYS_V1 } from "@frockbot/kernel-contracts";
2
3
  import {
3
4
  BOT_ISOLATE_DEADLINE_SOURCE,
4
5
  BOT_ISOLATE_INVOCATION_SOURCE,
5
6
  BOT_ISOLATE_MAIN_MODULE,
7
+ BOT_ISOLATE_NARROW_CONTEXT_KEYS_V1,
6
8
  BOT_ISOLATE_PACKAGE_MODULE,
7
9
  BOT_ISOLATE_WRAPPER_SOURCE,
8
10
  botIsolateModuleMap,
@@ -21,6 +23,9 @@ type DecodeInvocation = (value: unknown) => unknown;
21
23
  const decodeInvocation = new Function(
22
24
  `${BOT_ISOLATE_INVOCATION_SOURCE}\nreturn decodeInvocation;`,
23
25
  )() as DecodeInvocation;
26
+ const decodeHookInvocation = new Function(
27
+ `${BOT_ISOLATE_INVOCATION_SOURCE}\nreturn decodeHookInvocation;`,
28
+ )() as DecodeInvocation;
24
29
 
25
30
  function invocation(overrides: Record<string, unknown> = {}) {
26
31
  return {
@@ -84,6 +89,25 @@ describe("the generated wrapper's invocation decoder", () => {
84
89
  });
85
90
  });
86
91
 
92
+ test("accepts only a public waterfall hook invocation", () => {
93
+ const hook = {
94
+ ...invocation(),
95
+ event: "agent/tool-exposure",
96
+ payload: { tools: [] },
97
+ } as Record<string, unknown>;
98
+ delete hook.tool;
99
+ delete hook.input;
100
+ expect(decodeHookInvocation(hook)).toMatchObject({
101
+ event: "agent/tool-exposure",
102
+ });
103
+ expect(() =>
104
+ decodeHookInvocation({ ...hook, event: "agent/request" }),
105
+ ).toThrow(/unsupported/);
106
+ expect(() =>
107
+ decodeHookInvocation({ ...hook, signal: "live AbortSignal" }),
108
+ ).toThrow(/invalid fields/);
109
+ });
110
+
87
111
  test("refuses an invocation carrying an undeclared field", () => {
88
112
  expect(() =>
89
113
  decodeInvocation(invocation({ capabilities: ["models:invoke"] })),
@@ -99,6 +123,12 @@ describe("the generated wrapper's invocation decoder", () => {
99
123
  });
100
124
 
101
125
  describe("the generated wrapper module map", () => {
126
+ test("the wrapper context keys equal the generated contract catalog", () => {
127
+ expect(BOT_ISOLATE_NARROW_CONTEXT_KEYS_V1).toEqual([
128
+ ...BOT_ISOLATE_CONTEXT_KEYS_V1,
129
+ ]);
130
+ });
131
+
102
132
  test("is exactly two entries", () => {
103
133
  const modules = botIsolateModuleMap("export const tools = [];");
104
134
  expect(Object.keys(modules).sort()).toEqual([
@@ -121,5 +151,10 @@ describe("the generated wrapper module map", () => {
121
151
  expect(BOT_ISOLATE_WRAPPER_SOURCE).toContain(
122
152
  "async execute(rawInvocation)",
123
153
  );
154
+ expect(BOT_ISOLATE_WRAPPER_SOURCE).toContain("async hook(rawInvocation)");
155
+ expect(BOT_ISOLATE_WRAPPER_SOURCE).toContain("hooks: declaredHooks()");
156
+ expect(BOT_ISOLATE_WRAPPER_SOURCE).toContain(
157
+ "return capabilities.schedule(request);",
158
+ );
124
159
  });
125
160
  });