@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.
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { Context } from "cordis";
3
+ import { BOT_ISOLATE_HOOK_EVENTS_V1 } from "@frockbot/kernel-contracts";
2
4
  import type {
3
5
  BotCapabilitiesStub,
6
+ BotIsolateHookEventNameV1,
7
+ IsolateHookInvocationV1,
4
8
  TurnTypeV1,
5
9
  BotIsolateEntrypoint,
6
10
  IsolateToolInvocationV1,
@@ -21,7 +25,7 @@ import { decodeFrockBotManifest } from "./manifest.ts";
21
25
 
22
26
  const CONTENT_HASH = "a".repeat(64);
23
27
 
24
- function manifest() {
28
+ function manifest(hooks: BotIsolateHookEventNameV1[] = []) {
25
29
  return decodeFrockBotManifest({
26
30
  schemaVersion: 3,
27
31
  id: "bot-authored",
@@ -29,15 +33,27 @@ function manifest() {
29
33
  version: "0.0.1",
30
34
  compatibility: { frockbot: "^0.0.1" },
31
35
  dependencies: {},
32
- contributions: { runtime: { entry: "./runtime.js" } },
36
+ contributions: {
37
+ runtime: { entry: "./package.js", host: "bot-isolate" },
38
+ },
39
+ tools: [
40
+ {
41
+ name: "reverse_text",
42
+ description: "Reverses text",
43
+ inputSchema: { type: "object" },
44
+ },
45
+ ],
46
+ ...(hooks.length === 0 ? {} : { hooks }),
33
47
  permissions: [],
34
48
  });
35
49
  }
36
50
 
37
- function descriptor(): PackageDescriptor {
51
+ function descriptor(
52
+ hooks: BotIsolateHookEventNameV1[] = [],
53
+ ): PackageDescriptor {
38
54
  return {
39
55
  specifier: "@bot/authored",
40
- manifest: manifest(),
56
+ manifest: manifest(hooks),
41
57
  artifact: {
42
58
  contentHash: CONTENT_HASH,
43
59
  size: 12,
@@ -67,6 +83,7 @@ function fakeIsolate(
67
83
  ({
68
84
  health: () => Promise.reject(new Error("health was not stubbed")),
69
85
  execute: () => Promise.reject(new Error("execute was not stubbed")),
86
+ hook: () => Promise.reject(new Error("hook was not stubbed")),
70
87
  ...entrypoint,
71
88
  }) as BotIsolateEntrypoint,
72
89
  };
@@ -122,12 +139,15 @@ function host(
122
139
  };
123
140
  },
124
141
  },
142
+ loop: new Context(),
125
143
  userId: "user-1",
126
144
  botId: "bot-1",
127
145
  sessionId: "session-1",
128
146
  runId: "run-1",
129
147
  turnId: "turn-1",
130
148
  generationId: "gen-1",
149
+ turnType: "chat",
150
+ recordHookFailure: () => Promise.resolve(),
131
151
  capabilities: {} as BotCapabilitiesStub,
132
152
  bindingDigest: BINDING_DIGEST,
133
153
  compatibilityDate: "2026-08-27",
@@ -178,7 +198,7 @@ describe("Bot isolate contribution host", () => {
178
198
  test("a caller that omits the binding digest does not compile", () => {
179
199
  // @ts-expect-error the binding digest is required: an isolate loaded with
180
200
  // no digest of its granted bindings would share a loader id across
181
- // enabled bindings and generations.
201
+ // Connection authority and generations.
182
202
  void botIsolateModuleSetHashV1(CONTENT_HASH);
183
203
  expect(true).toBe(true);
184
204
  });
@@ -191,60 +211,14 @@ describe("Bot isolate contribution host", () => {
191
211
  expect(loads[0]!.loaderId).not.toBe(other.loads[0]!.loaderId);
192
212
  });
193
213
 
194
- test("reuses the loader id only for the same Bot, generation, and enabled set", async () => {
214
+ test("keys the loader id on the User and identity-bound module set", async () => {
195
215
  const { host: subject, loads } = host();
196
216
  await subject.prepare(descriptor());
197
- const same = host();
198
- await same.host.prepare(descriptor());
199
217
  const expected = await botIsolateModuleSetHashV1(
200
218
  CONTENT_HASH,
201
219
  BINDING_DIGEST,
202
220
  );
203
221
  expect(loads[0]!.loaderId).toBe(`bot-package:user-1:${expected}`);
204
- expect(same.loads[0]!.loaderId).toBe(loads[0]!.loaderId);
205
- });
206
-
207
- test("changes the loader id with the Bot, generation, or enabled set", async () => {
208
- const first = host();
209
- const otherBot = host({ botId: "bot-2", bindingDigest: "d".repeat(64) });
210
- const otherGeneration = host({
211
- generationId: "gen-2",
212
- bindingDigest: "e".repeat(64),
213
- });
214
- const otherEnabledSet = host({ bindingDigest: "f".repeat(64) });
215
-
216
- await Promise.all([
217
- first.host.prepare(descriptor()),
218
- otherBot.host.prepare(descriptor()),
219
- otherGeneration.host.prepare(descriptor()),
220
- otherEnabledSet.host.prepare(descriptor()),
221
- ]);
222
-
223
- const loaderIds = [
224
- first.loads[0]!.loaderId,
225
- otherBot.loads[0]!.loaderId,
226
- otherGeneration.loads[0]!.loaderId,
227
- otherEnabledSet.loads[0]!.loaderId,
228
- ];
229
- expect(new Set(loaderIds).size).toBe(loaderIds.length);
230
- });
231
-
232
- test("never shares a loader id across Users", async () => {
233
- const first = host();
234
- const otherUser = host({ userId: "user-2" });
235
-
236
- await Promise.all([
237
- first.host.prepare(descriptor()),
238
- otherUser.host.prepare(descriptor()),
239
- ]);
240
-
241
- expect(first.loads[0]!.loaderId).toMatch(
242
- /^bot-package:user-1:[0-9a-f]{64}$/,
243
- );
244
- expect(otherUser.loads[0]!.loaderId).toMatch(
245
- /^bot-package:user-2:[0-9a-f]{64}$/,
246
- );
247
- expect(otherUser.loads[0]!.loaderId).not.toBe(first.loads[0]!.loaderId);
248
222
  });
249
223
 
250
224
  test("health failure is a prepare failure with a diagnostic", async () => {
@@ -281,6 +255,311 @@ describe("Bot isolate contribution host", () => {
281
255
  await expect(subject.prepare(descriptor())).rejects.toThrow(/unhealthy/);
282
256
  });
283
257
 
258
+ test("rejects isolate tool names that differ from the stored manifest", async () => {
259
+ const { host: subject } = host({
260
+ entrypoint: {
261
+ health: () =>
262
+ Promise.resolve(
263
+ healthy([
264
+ {
265
+ name: "undeclared_tool",
266
+ description: "Not in the manifest",
267
+ inputSchema: { type: "object" },
268
+ idempotent: false,
269
+ },
270
+ ]),
271
+ ),
272
+ },
273
+ });
274
+ await expect(subject.prepare(descriptor())).rejects.toThrow(
275
+ /tools do not match its stored manifest/,
276
+ );
277
+ });
278
+
279
+ test("rejects isolate hooks that differ from the stored manifest", async () => {
280
+ const { host: subject } = host({
281
+ entrypoint: {
282
+ health: () =>
283
+ Promise.resolve({
284
+ ...healthy(),
285
+ contractVersion: 3 as const,
286
+ hooks: ["tools/post-execute" as const],
287
+ }),
288
+ },
289
+ });
290
+ await expect(
291
+ subject.prepare(descriptor(["agent/tool-exposure"])),
292
+ ).rejects.toThrow(/hooks do not match its stored manifest/);
293
+ });
294
+
295
+ test("runs a declared hook after first-party policy with a snapshot", async () => {
296
+ const loop = new Context();
297
+ const order: string[] = [];
298
+ let seen: IsolateHookInvocationV1 | undefined;
299
+ loop.on(
300
+ "agent/tool-exposure",
301
+ async (_agent, tools, _turn, _step, _signal, next) => {
302
+ order.push(`first-party:${tools[0]?.name}`);
303
+ return next();
304
+ },
305
+ );
306
+ const { host: subject } = host({
307
+ loop,
308
+ entrypoint: {
309
+ health: () =>
310
+ Promise.resolve({
311
+ ...healthy(),
312
+ contractVersion: 3 as const,
313
+ hooks: ["agent/tool-exposure" as const],
314
+ }),
315
+ hook: (invocation) => {
316
+ seen = invocation;
317
+ order.push(`hook:${invocation.event}`);
318
+ return Promise.resolve({
319
+ schemaVersion: 1 as const,
320
+ status: "replaced" as const,
321
+ replacement: [
322
+ {
323
+ name: "hook_visible",
324
+ description: "Visible only for this step.",
325
+ inputSchema: { type: "object" },
326
+ },
327
+ ],
328
+ });
329
+ },
330
+ },
331
+ });
332
+ const prepared = await subject.prepare(descriptor(["agent/tool-exposure"]));
333
+ const active = await prepared!.commit();
334
+ const original = [
335
+ {
336
+ name: "reverse_text",
337
+ description: "Reverses text",
338
+ inputSchema: { type: "object" },
339
+ },
340
+ ];
341
+ const agent = {
342
+ id: "bot-1",
343
+ botId: "bot-1",
344
+ status: "running" as const,
345
+ session: { id: "session-1" },
346
+ } as never;
347
+ const result = await loop.waterfall(
348
+ "agent/tool-exposure",
349
+ agent,
350
+ original,
351
+ 1,
352
+ 2,
353
+ new AbortController().signal,
354
+ () => Promise.resolve(original),
355
+ );
356
+ const otherBotResult = await loop.waterfall(
357
+ "agent/tool-exposure",
358
+ {
359
+ id: "bot-2",
360
+ botId: "bot-2",
361
+ status: "running" as const,
362
+ session: { id: "session-2" },
363
+ } as never,
364
+ original,
365
+ 1,
366
+ 2,
367
+ new AbortController().signal,
368
+ () => Promise.resolve(original),
369
+ );
370
+
371
+ expect(order).toEqual([
372
+ "first-party:reverse_text",
373
+ "hook:agent/tool-exposure",
374
+ "first-party:reverse_text",
375
+ ]);
376
+ expect(result.map((tool) => tool.name)).toEqual(["hook_visible"]);
377
+ expect(otherBotResult).toEqual(original);
378
+ expect(seen?.payload).toMatchObject({
379
+ step: {
380
+ botId: "bot-1",
381
+ sessionId: "session-1",
382
+ compositionGenerationId: "gen-1",
383
+ turn: 1,
384
+ step: 2,
385
+ },
386
+ tools: original,
387
+ });
388
+ expect(seen?.payload).not.toHaveProperty("agent");
389
+ expect(seen?.payload).not.toHaveProperty("signal");
390
+
391
+ await active.dispose();
392
+ });
393
+
394
+ test("bridges every declared isolate waterfall", async () => {
395
+ const loop = new Context();
396
+ const seen: string[] = [];
397
+ const replacement: Record<string, unknown> = {
398
+ "agent/pre-step": { kind: "reject", reason: "hook rejected" },
399
+ "system-prompt/assemble": {
400
+ text: "hook prompt",
401
+ sections: [{ id: "hook", text: "hook prompt" }],
402
+ },
403
+ "agent/message-window": [{ role: "user", content: "hook window" }],
404
+ "agent/tool-exposure": [],
405
+ "tools/pre-execute": {
406
+ kind: "denied",
407
+ call: { id: "call-1", name: "reverse_text", input: {} },
408
+ result: { content: "hook denied", isError: true },
409
+ },
410
+ "tools/post-execute": { content: "hook result", isError: false },
411
+ "agent/step-continuation": { kind: "stop" },
412
+ };
413
+ const { host: subject } = host({
414
+ loop,
415
+ entrypoint: {
416
+ health: () =>
417
+ Promise.resolve({
418
+ ...healthy(),
419
+ contractVersion: 3 as const,
420
+ hooks: [...BOT_ISOLATE_HOOK_EVENTS_V1],
421
+ }),
422
+ hook: (invocation) => {
423
+ seen.push(invocation.event);
424
+ return Promise.resolve({
425
+ schemaVersion: 1,
426
+ status: "replaced",
427
+ replacement: replacement[invocation.event],
428
+ });
429
+ },
430
+ },
431
+ });
432
+ const prepared = await subject.prepare(
433
+ descriptor([...BOT_ISOLATE_HOOK_EVENTS_V1]),
434
+ );
435
+ const active = await prepared!.commit();
436
+ const agent = {
437
+ id: "bot-1",
438
+ botId: "bot-1",
439
+ status: "running" as const,
440
+ session: { id: "session-1" },
441
+ } as never;
442
+ const signal = new AbortController().signal;
443
+ const call = { id: "call-1", name: "reverse_text", input: {} };
444
+ const toolContext = executionContext();
445
+
446
+ expect(
447
+ await loop.waterfall("agent/pre-step", agent, [], 1, 1, () =>
448
+ Promise.resolve({ kind: "enter", inputs: [] }),
449
+ ),
450
+ ).toMatchObject({ kind: "reject" });
451
+ expect(
452
+ await loop.waterfall(
453
+ "system-prompt/assemble",
454
+ {
455
+ sessionId: "session-1",
456
+ provider: "scripted",
457
+ model: "scripted-v1",
458
+ turnType: "chat",
459
+ },
460
+ () => Promise.resolve({ text: "core", sections: [] }),
461
+ ),
462
+ ).toMatchObject({ text: "hook prompt" });
463
+ expect(
464
+ await loop.waterfall(
465
+ "agent/message-window",
466
+ agent,
467
+ [],
468
+ 1,
469
+ 1,
470
+ signal,
471
+ () => Promise.resolve([]),
472
+ ),
473
+ ).toEqual([{ role: "user", content: "hook window" }]);
474
+ expect(
475
+ await loop.waterfall("agent/tool-exposure", agent, [], 1, 1, signal, () =>
476
+ Promise.resolve([]),
477
+ ),
478
+ ).toEqual([]);
479
+ expect(
480
+ await loop.waterfall("tools/pre-execute", call, toolContext, () =>
481
+ Promise.resolve({ kind: "ready", call, idempotent: true }),
482
+ ),
483
+ ).toMatchObject({ kind: "denied" });
484
+ expect(
485
+ await loop.waterfall(
486
+ "tools/post-execute",
487
+ call,
488
+ { content: "core", isError: false },
489
+ toolContext,
490
+ () => Promise.resolve({ content: "core", isError: false }),
491
+ ),
492
+ ).toMatchObject({ content: "hook result" });
493
+ expect(
494
+ await loop.waterfall(
495
+ "agent/step-continuation",
496
+ agent,
497
+ { kind: "continue" },
498
+ 1,
499
+ 1,
500
+ signal,
501
+ () => Promise.resolve({ kind: "continue" }),
502
+ ),
503
+ ).toEqual({ kind: "stop" });
504
+ expect(seen.toSorted()).toEqual([...BOT_ISOLATE_HOOK_EVENTS_V1].toSorted());
505
+
506
+ await active.dispose();
507
+ });
508
+
509
+ test("a throwing or timed-out hook passes through and records failure", async () => {
510
+ for (const hook of [
511
+ () => Promise.reject(new Error("hook exploded")),
512
+ () => new Promise<never>(() => {}),
513
+ ]) {
514
+ const failures: string[] = [];
515
+ const loop = new Context();
516
+ const { host: subject } = host({
517
+ loop,
518
+ deadlineMs: 5,
519
+ recordHookFailure: (failure) => {
520
+ failures.push(failure.message);
521
+ return Promise.resolve();
522
+ },
523
+ entrypoint: {
524
+ health: () =>
525
+ Promise.resolve({
526
+ ...healthy(),
527
+ contractVersion: 3 as const,
528
+ hooks: ["agent/tool-exposure" as const],
529
+ }),
530
+ hook,
531
+ },
532
+ });
533
+ const prepared = await subject.prepare(
534
+ descriptor(["agent/tool-exposure"]),
535
+ );
536
+ await prepared!.commit();
537
+ const original = [
538
+ {
539
+ name: "reverse_text",
540
+ description: "Reverses text",
541
+ inputSchema: { type: "object" },
542
+ },
543
+ ];
544
+ const result = await loop.waterfall(
545
+ "agent/tool-exposure",
546
+ {
547
+ id: "bot-1",
548
+ botId: "bot-1",
549
+ status: "running",
550
+ session: { id: "session-1" },
551
+ } as never,
552
+ original,
553
+ 1,
554
+ 1,
555
+ new AbortController().signal,
556
+ () => Promise.resolve(original),
557
+ );
558
+ expect(result).toEqual(original);
559
+ expect(failures).toHaveLength(1);
560
+ }
561
+ });
562
+
284
563
  test("registers one tool per health entry and executes it over RPC", async () => {
285
564
  let seen: IsolateToolInvocationV1 | undefined;
286
565
  const { host: subject, registered } = host({
@@ -388,7 +667,16 @@ describe("the manifest bounds the turn types an isolate's tools reach", () => {
388
667
  version: "0.0.1",
389
668
  compatibility: { frockbot: "^0.0.1" },
390
669
  dependencies: {},
391
- contributions: { runtime: { entry: "./runtime.js" } },
670
+ contributions: {
671
+ runtime: { entry: "./package.js", host: "bot-isolate" },
672
+ },
673
+ tools: [
674
+ {
675
+ name: "reverse_text",
676
+ description: "Reverses text",
677
+ inputSchema: { type: "object" },
678
+ },
679
+ ],
392
680
  permissions: [],
393
681
  configuration: { capabilities },
394
682
  });