@frockbot/kernel-composition 0.0.0 → 0.1.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.
@@ -0,0 +1,467 @@
1
+ // The Bot isolate contribution host: mounts one Composition member's
2
+ // content-addressed artifact as a Dynamic Worker and registers the tools its
3
+ // wrapper reports.
4
+ //
5
+ // It sits beside `LocalCordisContributionHost` because it is the *other*
6
+ // execution host the constitution names — first-party Packages run in the
7
+ // kernel isolate, everything else runs in a loaded Worker with
8
+ // `globalOutbound` disabled and only Assignment-derived bindings.
9
+ //
10
+ // Two behaviours come straight from `docs/research/spike-worker-loader-from-do.md`:
11
+ // `.get()` never throws, so mount and `health()` are a single guarded phase;
12
+ // and a reused loader id silently serves the first code, so the id is nothing
13
+ // but the content address of the module set actually mounted.
14
+ import {
15
+ decodeIsolateHealthV1,
16
+ decodeIsolateToolResultV1,
17
+ isolateToolSchemaV1,
18
+ ISOLATE_MAX_DEADLINE_MS,
19
+ isolateLoaderIdV1,
20
+ type BotCapabilitiesStub,
21
+ type BotIsolateEntrypoint,
22
+ type IsolateHealthV1,
23
+ type IsolateToolDescriptorV1,
24
+ type IsolateToolInvocationV1,
25
+ type ToolDefinition,
26
+ type ToolExecutionContext,
27
+ type ToolExecutionResult,
28
+ type ToolRegistration,
29
+ type TurnTypeV1,
30
+ } from "@frockbot/kernel-contracts";
31
+ import { CompositionMountFailureError } from "./activation.ts";
32
+ import { canonicalJson, sha256 } from "./compiler.ts";
33
+ import type { CompositionMemberV1 } from "./generation.ts";
34
+ import type {
35
+ ActiveContribution,
36
+ ContributionHost,
37
+ PackageDescriptor,
38
+ PreparedContribution,
39
+ } from "./index.ts";
40
+ import { decodeFrockBotManifest, type FrockBotManifest } from "./manifest.ts";
41
+ import {
42
+ BOT_ISOLATE_MAIN_MODULE,
43
+ BOT_ISOLATE_WRAPPER_SOURCE,
44
+ BOT_ISOLATE_WRAPPER_VERSION,
45
+ botIsolateModuleMap,
46
+ } from "./isolate-wrapper.ts";
47
+
48
+ /** The `WorkerCode` a Bot isolate is loaded from. Structurally the platform's. */
49
+ export interface BotIsolateWorkerCode {
50
+ compatibilityDate: string;
51
+ mainModule: string;
52
+ modules: Record<string, { js: string }>;
53
+ globalOutbound: null;
54
+ env: { IDENTITY: unknown; CAPABILITIES: unknown };
55
+ limits: { cpuMs: number; subRequests: number };
56
+ }
57
+
58
+ export interface BotIsolateLoadedWorker {
59
+ getEntrypoint(name?: string | null): BotIsolateEntrypoint;
60
+ }
61
+
62
+ /** The `worker_loaders` binding, declared structurally so the kernel stays platform-free. */
63
+ export interface BotIsolateLoader {
64
+ get(
65
+ id: string,
66
+ callback: () => Promise<BotIsolateWorkerCode>,
67
+ ): BotIsolateLoadedWorker;
68
+ }
69
+
70
+ /** Reads an immutable, content-addressed Package artifact and verifies its hash. */
71
+ export interface BotIsolateArtifactStore {
72
+ loadPackageArtifact(contentHash: string): Promise<string>;
73
+ }
74
+
75
+ export interface BotIsolateLimits {
76
+ cpuMs: number;
77
+ subRequests: number;
78
+ }
79
+
80
+ export interface BotIsolateHostOptions {
81
+ loader: BotIsolateLoader;
82
+ artifacts: BotIsolateArtifactStore;
83
+ /** Where the isolate's tools are registered — the kernel's tool surface. */
84
+ tools: ToolRegistration;
85
+ userId: string;
86
+ botId: string;
87
+ sessionId: string;
88
+ runId: string;
89
+ turnId: string;
90
+ generationId: string;
91
+ /**
92
+ * The loopback service binding minted with
93
+ * `ctx.exports.BotCapabilities({ props })`. Opaque to the kernel: it only
94
+ * places it in the isolate's `env`.
95
+ */
96
+ capabilities: BotCapabilitiesStub;
97
+ /**
98
+ * A content address of the Assignment-derived bindings this isolate is
99
+ * loaded with. Required, and part of the loader id, because a loader id is
100
+ * 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
102
+ * isolate or the isolate would keep answering from a revoked authority.
103
+ */
104
+ bindingDigest: string;
105
+ compatibilityDate: string;
106
+ limits?: BotIsolateLimits;
107
+ /** Per-invocation deadline; `AbortSignal` cannot cross the RPC boundary. */
108
+ deadlineMs?: number;
109
+ /** Verification deadline: an isolate that never answers `health()` fails closed. */
110
+ healthDeadlineMs?: number;
111
+ }
112
+
113
+ export const BOT_ISOLATE_DEFAULT_LIMITS: BotIsolateLimits = {
114
+ cpuMs: 5_000,
115
+ subRequests: 5,
116
+ };
117
+
118
+ export const BOT_ISOLATE_DEFAULT_DEADLINE_MS = 15_000;
119
+ export const BOT_ISOLATE_DEFAULT_HEALTH_DEADLINE_MS = 10_000;
120
+
121
+ /**
122
+ * 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
124
+ * is loaded with. A change to any of the three is a new isolate.
125
+ */
126
+ export async function botIsolateModuleSetHashV1(
127
+ artifactContentHash: string,
128
+ bindingDigest: string,
129
+ ): Promise<string> {
130
+ return sha256(
131
+ canonicalJson({
132
+ wrapperVersion: BOT_ISOLATE_WRAPPER_VERSION,
133
+ wrapperHash: await sha256(BOT_ISOLATE_WRAPPER_SOURCE),
134
+ packageHash: artifactContentHash,
135
+ bindingDigest,
136
+ }),
137
+ );
138
+ }
139
+
140
+ /**
141
+ * The durable ceiling a manifest puts on the turn types a Package's tools may
142
+ * be admitted onto (manifest v4, `CapabilityDefinition.admission`). It is the
143
+ * union over the Package's tool Capabilities, because a tool descriptor names
144
+ * no Capability: a Package bounds its tools only when every tool Capability it
145
+ * declares bounds them. Absent means the manifest set no bound.
146
+ */
147
+ export function botIsolateAdmissionCeilingV1(
148
+ manifest: FrockBotManifest,
149
+ ): readonly TurnTypeV1[] | undefined {
150
+ const capabilities = (manifest.configuration?.capabilities ?? []).filter(
151
+ (capability) => capability.kind === "tool",
152
+ );
153
+ if (
154
+ capabilities.length === 0 ||
155
+ capabilities.some((capability) => capability.admission === undefined)
156
+ ) {
157
+ return undefined;
158
+ }
159
+ const turnTypes = new Set<TurnTypeV1>();
160
+ for (const capability of capabilities) {
161
+ for (const turnType of capability.admission?.turnTypes ?? []) {
162
+ turnTypes.add(turnType);
163
+ }
164
+ }
165
+ return [...turnTypes];
166
+ }
167
+
168
+ /**
169
+ * The same durable ceiling on the second dimension: the subagent roles a
170
+ * Package's tools may be offered to. Union over the tool Capabilities, and
171
+ * absent unless *every* one of them names roles — a Package bounds its tools
172
+ * only when it has bounded all of them.
173
+ */
174
+ export function botIsolateSubagentRoleCeilingV1(
175
+ manifest: FrockBotManifest,
176
+ ): readonly string[] | undefined {
177
+ const capabilities = (manifest.configuration?.capabilities ?? []).filter(
178
+ (capability) => capability.kind === "tool",
179
+ );
180
+ if (
181
+ capabilities.length === 0 ||
182
+ capabilities.some(
183
+ (capability) => capability.admission?.subagentRoles === undefined,
184
+ )
185
+ ) {
186
+ return undefined;
187
+ }
188
+ const roles = new Set<string>();
189
+ for (const capability of capabilities) {
190
+ for (const role of capability.admission?.subagentRoles ?? []) {
191
+ roles.add(role);
192
+ }
193
+ }
194
+ return [...roles];
195
+ }
196
+
197
+ /**
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.
202
+ */
203
+ export function botIsolatePackageDescriptorV1(
204
+ member: CompositionMemberV1,
205
+ ): PackageDescriptor {
206
+ return {
207
+ 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
+ }),
218
+ ...(member.artifact ? { artifact: member.artifact } : {}),
219
+ };
220
+ }
221
+
222
+ function errorMessage(error: unknown): string {
223
+ return error instanceof Error ? error.message : String(error);
224
+ }
225
+
226
+ /** Mounts an isolate Composition member and registers the tools it reports. */
227
+ export class BotIsolateContributionHost implements ContributionHost {
228
+ readonly kind = "bot-isolate" as const;
229
+ private readonly options: BotIsolateHostOptions;
230
+
231
+ constructor(options: BotIsolateHostOptions) {
232
+ this.options = options;
233
+ }
234
+
235
+ async prepare(
236
+ pkg: PackageDescriptor,
237
+ ): Promise<PreparedContribution | undefined> {
238
+ const artifact = pkg.artifact;
239
+ if (!artifact) return undefined;
240
+ const packageId = pkg.manifest.id;
241
+ const source = await this.loadSource(packageId, artifact.contentHash);
242
+ const loaderId = isolateLoaderIdV1({
243
+ userId: this.options.userId,
244
+ botId: this.options.botId,
245
+ artifactSetHash: await botIsolateModuleSetHashV1(
246
+ artifact.contentHash,
247
+ this.options.bindingDigest,
248
+ ),
249
+ });
250
+
251
+ // Mount and health-check are one guarded phase: `.get()` is lazy and never
252
+ // throws, so a broken `package.js` only surfaces on the first RPC.
253
+ let health: IsolateHealthV1;
254
+ let entrypoint: BotIsolateEntrypoint;
255
+ try {
256
+ entrypoint = this.load(loaderId, packageId, source).getEntrypoint();
257
+ health = decodeIsolateHealthV1(
258
+ await raceDeadline(
259
+ () => entrypoint.health(),
260
+ Math.min(
261
+ this.options.healthDeadlineMs ??
262
+ BOT_ISOLATE_DEFAULT_HEALTH_DEADLINE_MS,
263
+ ISOLATE_MAX_DEADLINE_MS,
264
+ ),
265
+ ),
266
+ `package "${packageId}" isolate health`,
267
+ );
268
+ } catch (error) {
269
+ // Site two: `LOADER.get` plus the first RPC. `.get()` is lazy, so a
270
+ // broken `package.js` surfaces here and nowhere earlier.
271
+ throw new CompositionMountFailureError(
272
+ "mount",
273
+ `package "${packageId}" failed to mount in its isolate: ${errorMessage(error)}`,
274
+ [`loader:${loaderId}`],
275
+ );
276
+ }
277
+ // Site three: the isolate answered, but failed its declared check.
278
+ if (!health.ok || health.tools.length === 0) {
279
+ throw new CompositionMountFailureError(
280
+ "health",
281
+ `package "${packageId}" reported an unhealthy isolate`,
282
+ [`ok:${health.ok}`, `tools:${health.tools.length}`],
283
+ );
284
+ }
285
+ if (health.packageId !== packageId) {
286
+ throw new CompositionMountFailureError(
287
+ "health",
288
+ `package "${packageId}" isolate reported a different package id`,
289
+ [`reported:${health.packageId}`],
290
+ );
291
+ }
292
+
293
+ let disposed = false;
294
+ const registered: (() => void)[] = [];
295
+ return {
296
+ kind: this.kind,
297
+ commit: (): Promise<ActiveContribution> => {
298
+ // The manifest ceiling is applied at registration, so the catalog the
299
+ // model is offered and the call the loop admits cannot disagree.
300
+ const admissionCeiling = botIsolateAdmissionCeilingV1(pkg.manifest);
301
+ const subagentRoleCeiling = botIsolateSubagentRoleCeilingV1(
302
+ pkg.manifest,
303
+ );
304
+ const options =
305
+ admissionCeiling || subagentRoleCeiling
306
+ ? {
307
+ ...(admissionCeiling ? { admissionCeiling } : {}),
308
+ ...(subagentRoleCeiling ? { subagentRoleCeiling } : {}),
309
+ }
310
+ : undefined;
311
+ for (const descriptor of health.tools) {
312
+ registered.push(
313
+ this.options.tools.register(
314
+ this.definition(packageId, entrypoint, descriptor),
315
+ options,
316
+ ),
317
+ );
318
+ }
319
+ return Promise.resolve({
320
+ dispose: () => {
321
+ if (disposed) return Promise.resolve();
322
+ disposed = true;
323
+ for (const unregister of registered.toReversed()) unregister();
324
+ return Promise.resolve();
325
+ },
326
+ });
327
+ },
328
+ rollback: () => Promise.resolve(),
329
+ };
330
+ }
331
+
332
+ private async loadSource(
333
+ packageId: string,
334
+ contentHash: string,
335
+ ): Promise<string> {
336
+ try {
337
+ return await this.options.artifacts.loadPackageArtifact(contentHash);
338
+ } catch (error) {
339
+ // Site one: the immutable artifact read. A generation whose artifact is
340
+ // gone never resolves, and that is a different repair from a broken one.
341
+ throw new CompositionMountFailureError(
342
+ "resolve",
343
+ `package "${packageId}" artifact "${contentHash}" is unavailable: ${errorMessage(error)}`,
344
+ [`contentHash:${contentHash}`],
345
+ );
346
+ }
347
+ }
348
+
349
+ private load(
350
+ loaderId: string,
351
+ packageId: string,
352
+ source: string,
353
+ ): BotIsolateLoadedWorker {
354
+ const limits = this.options.limits ?? BOT_ISOLATE_DEFAULT_LIMITS;
355
+ const identity = {
356
+ botId: this.options.botId,
357
+ generationId: this.options.generationId,
358
+ packageId,
359
+ };
360
+ return this.options.loader.get(loaderId, () =>
361
+ Promise.resolve({
362
+ compatibilityDate: this.options.compatibilityDate,
363
+ mainModule: BOT_ISOLATE_MAIN_MODULE,
364
+ modules: botIsolateModuleMap(source),
365
+ // The constitution's rule, made mechanical: no network except bindings.
366
+ globalOutbound: null,
367
+ env: { IDENTITY: identity, CAPABILITIES: this.options.capabilities },
368
+ limits,
369
+ }),
370
+ );
371
+ }
372
+
373
+ private definition(
374
+ packageId: string,
375
+ entrypoint: BotIsolateEntrypoint,
376
+ descriptor: IsolateToolDescriptorV1,
377
+ ): ToolDefinition {
378
+ const deadlineMs = Math.min(
379
+ this.options.deadlineMs ?? BOT_ISOLATE_DEFAULT_DEADLINE_MS,
380
+ ISOLATE_MAX_DEADLINE_MS,
381
+ );
382
+ const options = this.options;
383
+ return {
384
+ ...isolateToolSchemaV1(descriptor),
385
+ idempotent: descriptor.idempotent,
386
+ // A contract v1 isolate declares none, and its tools stay on every turn.
387
+ ...(descriptor.admission ? { admission: descriptor.admission } : {}),
388
+ execute: async (
389
+ input: unknown,
390
+ context: ToolExecutionContext,
391
+ ): Promise<ToolExecutionResult> => {
392
+ const invocation: IsolateToolInvocationV1 = {
393
+ schemaVersion: 1,
394
+ tool: descriptor.name,
395
+ input: input ?? null,
396
+ botId: options.botId,
397
+ sessionId: context.sessionId,
398
+ runId: options.runId,
399
+ turnId: options.turnId,
400
+ generationId: context.compositionGenerationId,
401
+ deadlineMs,
402
+ };
403
+ try {
404
+ // `AbortSignal` cannot cross the RPC boundary, so the deadline is
405
+ // carried in the invocation and raced again on this side.
406
+ const raw = await raceDeadline(
407
+ () => entrypoint.execute(invocation),
408
+ deadlineMs,
409
+ context.signal,
410
+ );
411
+ const result = decodeIsolateToolResultV1(
412
+ raw,
413
+ `package "${packageId}" isolate result`,
414
+ );
415
+ return { content: result.content, isError: result.isError };
416
+ } catch (error) {
417
+ return {
418
+ content: `Tool "${descriptor.name}" failed in its isolate: ${errorMessage(error)}`,
419
+ isError: true,
420
+ };
421
+ }
422
+ },
423
+ };
424
+ }
425
+ }
426
+
427
+ /** The Durable Object half of the deadline: a race the isolate cannot escape. */
428
+ export function raceDeadline<T>(
429
+ work: () => Promise<T>,
430
+ deadlineMs: number,
431
+ signal?: AbortSignal,
432
+ ): Promise<T> {
433
+ if (
434
+ !Number.isSafeInteger(deadlineMs) ||
435
+ deadlineMs <= 0 ||
436
+ deadlineMs > ISOLATE_MAX_DEADLINE_MS
437
+ ) {
438
+ return Promise.reject(
439
+ new Error("isolate invocation deadline is out of range"),
440
+ );
441
+ }
442
+ let timer: ReturnType<typeof setTimeout> | undefined;
443
+ let onAbort: (() => void) | undefined;
444
+ const expiry = new Promise<never>((_resolve, reject) => {
445
+ timer = setTimeout(
446
+ () =>
447
+ reject(
448
+ new Error(
449
+ `isolate invocation exceeded its deadline of ${deadlineMs}ms`,
450
+ ),
451
+ ),
452
+ deadlineMs,
453
+ );
454
+ if (signal) {
455
+ if (signal.aborted) {
456
+ reject(new Error("isolate invocation was cancelled"));
457
+ return;
458
+ }
459
+ onAbort = () => reject(new Error("isolate invocation was cancelled"));
460
+ signal.addEventListener("abort", onAbort, { once: true });
461
+ }
462
+ });
463
+ return Promise.race([Promise.resolve().then(work), expiry]).finally(() => {
464
+ clearTimeout(timer);
465
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
466
+ });
467
+ }
@@ -0,0 +1,125 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ BOT_ISOLATE_DEADLINE_SOURCE,
4
+ BOT_ISOLATE_INVOCATION_SOURCE,
5
+ BOT_ISOLATE_MAIN_MODULE,
6
+ BOT_ISOLATE_PACKAGE_MODULE,
7
+ BOT_ISOLATE_WRAPPER_SOURCE,
8
+ botIsolateModuleMap,
9
+ } from "./isolate-wrapper.ts";
10
+
11
+ type Deadline = (work: () => unknown, deadlineMs: number) => Promise<unknown>;
12
+
13
+ // The wrapper ships as generated text, so the tested function is compiled from
14
+ // exactly the source the wrapper embeds rather than from a TypeScript twin.
15
+ const withIsolateDeadline = new Function(
16
+ `${BOT_ISOLATE_DEADLINE_SOURCE}\nreturn withIsolateDeadline;`,
17
+ )() as Deadline;
18
+
19
+ type DecodeInvocation = (value: unknown) => unknown;
20
+
21
+ const decodeInvocation = new Function(
22
+ `${BOT_ISOLATE_INVOCATION_SOURCE}\nreturn decodeInvocation;`,
23
+ )() as DecodeInvocation;
24
+
25
+ function invocation(overrides: Record<string, unknown> = {}) {
26
+ return {
27
+ schemaVersion: 1,
28
+ tool: "reverse_text",
29
+ input: { text: "a" },
30
+ botId: "bot-1",
31
+ sessionId: "user-1:bot-1",
32
+ runId: "run-1",
33
+ turnId: "turn-1",
34
+ generationId: "gen-1",
35
+ deadlineMs: 1_000,
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ function never(): Promise<never> {
41
+ return new Promise(() => {});
42
+ }
43
+
44
+ describe("the generated wrapper's deadline", () => {
45
+ test("resolves work that finishes inside the deadline", async () => {
46
+ await expect(withIsolateDeadline(() => "done", 1_000)).resolves.toBe(
47
+ "done",
48
+ );
49
+ });
50
+
51
+ test("rejects work that outlives the deadline", async () => {
52
+ await expect(withIsolateDeadline(never, 10)).rejects.toThrow(
53
+ "isolate invocation exceeded its deadline of 10ms",
54
+ );
55
+ });
56
+
57
+ test("turns a synchronous throw into a rejection", async () => {
58
+ await expect(
59
+ withIsolateDeadline(() => {
60
+ throw new Error("boom");
61
+ }, 1_000),
62
+ ).rejects.toThrow("boom");
63
+ });
64
+
65
+ test("refuses a deadline outside the contract bound", async () => {
66
+ for (const deadline of [0, -1, 60_001, 1.5, Number.NaN]) {
67
+ await expect(withIsolateDeadline(() => "done", deadline)).rejects.toThrow(
68
+ "isolate invocation deadline is out of range",
69
+ );
70
+ }
71
+ });
72
+
73
+ test("does not hold the isolate open after the work settles", async () => {
74
+ const started = Date.now();
75
+ await withIsolateDeadline(() => "done", 50_000);
76
+ expect(Date.now() - started).toBeLessThan(1_000);
77
+ });
78
+ });
79
+
80
+ describe("the generated wrapper's invocation decoder", () => {
81
+ test("accepts exactly the declared invocation", () => {
82
+ expect(decodeInvocation(invocation())).toMatchObject({
83
+ tool: "reverse_text",
84
+ });
85
+ });
86
+
87
+ test("refuses an invocation carrying an undeclared field", () => {
88
+ expect(() =>
89
+ decodeInvocation(invocation({ capabilities: ["models:invoke"] })),
90
+ ).toThrow("isolate tool invocation has invalid fields");
91
+ });
92
+
93
+ test("refuses an invocation missing a declared field", () => {
94
+ const { turnId: _turnId, ...missing } = invocation();
95
+ expect(() => decodeInvocation(missing)).toThrow(
96
+ "isolate tool invocation has invalid fields",
97
+ );
98
+ });
99
+ });
100
+
101
+ describe("the generated wrapper module map", () => {
102
+ test("is exactly two entries", () => {
103
+ const modules = botIsolateModuleMap("export const tools = [];");
104
+ expect(Object.keys(modules).sort()).toEqual([
105
+ BOT_ISOLATE_MAIN_MODULE,
106
+ BOT_ISOLATE_PACKAGE_MODULE,
107
+ ]);
108
+ expect(modules[BOT_ISOLATE_MAIN_MODULE]?.js).toBe(
109
+ BOT_ISOLATE_WRAPPER_SOURCE,
110
+ );
111
+ expect(modules[BOT_ISOLATE_PACKAGE_MODULE]?.js).toBe(
112
+ "export const tools = [];",
113
+ );
114
+ });
115
+
116
+ test("exposes only the wrapper entrypoint to the loader", () => {
117
+ expect(BOT_ISOLATE_WRAPPER_SOURCE).toContain(
118
+ 'import { WorkerEntrypoint } from "cloudflare:workers";',
119
+ );
120
+ expect(BOT_ISOLATE_WRAPPER_SOURCE).toContain("async health()");
121
+ expect(BOT_ISOLATE_WRAPPER_SOURCE).toContain(
122
+ "async execute(rawInvocation)",
123
+ );
124
+ });
125
+ });