@frockbot/kernel-composition 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.
@@ -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 enabled-capability 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
- // but the content address of the mounted modules and their baked-in bindings.
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,11 +111,11 @@ export interface BotIsolateHostOptions {
95
111
  */
96
112
  capabilities: BotCapabilitiesStub;
97
113
  /**
98
- * A content address of every binding this isolate is loaded with: User, Bot,
99
- * Composition generation, and the User's enabled capability set. Required,
100
- * and part of the loader id, because the `env` first loaded under one id is
101
- * the `env` it keeps. Any changed binding must therefore produce a new
102
- * isolate (AGENTS.md Package composition; ADR 0019).
114
+ * A content address of the Bot authority bindings this isolate is
115
+ * loaded with. Required, and part of the loader id, because a loader id is
116
+ * served from cache: the `env` a Bot isolate was first loaded with is the
117
+ * `env` it keeps, so a change in the Bot's Connections must produce a new
118
+ * isolate or the isolate would keep answering from a revoked authority.
103
119
  */
104
120
  bindingDigest: string;
105
121
  compatibilityDate: 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,8 +143,8 @@ 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 every binding baked into its `env`.
124
- * A change to any of the three is a new isolate.
146
+ * the Package artifact, and the digest of the Bot authority bindings it
147
+ * is loaded with. A change to any of the three is a new isolate.
125
148
  */
126
149
  export async function botIsolateModuleSetHashV1(
127
150
  artifactContentHash: string,
@@ -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
  }
@@ -288,6 +319,38 @@ export class BotIsolateContributionHost implements ContributionHost {
288
319
  [`reported:${health.packageId}`],
289
320
  );
290
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
+ }
291
354
 
292
355
  let disposed = false;
293
356
  const registered: (() => void)[] = [];
@@ -315,6 +378,9 @@ export class BotIsolateContributionHost implements ContributionHost {
315
378
  ),
316
379
  );
317
380
  }
381
+ for (const event of health.hooks ?? []) {
382
+ registered.push(this.registerHook(packageId, entrypoint, event));
383
+ }
318
384
  return Promise.resolve({
319
385
  dispose: () => {
320
386
  if (disposed) return Promise.resolve();
@@ -369,6 +435,222 @@ export class BotIsolateContributionHost implements ContributionHost {
369
435
  );
370
436
  }
371
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
+
372
654
  private definition(
373
655
  packageId: string,
374
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
  });