@frockbot/kernel-composition 0.0.0 → 0.1.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 +31 -6
- package/src/activation.ts +417 -0
- package/src/compiler.test.ts +230 -0
- package/src/compiler.ts +288 -0
- package/src/generation.test.ts +203 -0
- package/src/generation.ts +423 -0
- package/src/index.test.ts +1072 -0
- package/src/index.ts +328 -0
- package/src/isolate-host.test.ts +423 -0
- package/src/isolate-host.ts +467 -0
- package/src/isolate-wrapper.test.ts +125 -0
- package/src/isolate-wrapper.ts +247 -0
- package/src/manifest.ts +1233 -0
- package/src/runtime.ts +18 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
BotCapabilitiesStub,
|
|
4
|
+
TurnTypeV1,
|
|
5
|
+
BotIsolateEntrypoint,
|
|
6
|
+
IsolateToolInvocationV1,
|
|
7
|
+
ToolDefinition,
|
|
8
|
+
ToolExecutionContext,
|
|
9
|
+
} from "@frockbot/kernel-contracts";
|
|
10
|
+
import type { PackageDescriptor } from "./index.ts";
|
|
11
|
+
import {
|
|
12
|
+
botIsolateAdmissionCeilingV1,
|
|
13
|
+
BotIsolateContributionHost,
|
|
14
|
+
botIsolateModuleSetHashV1,
|
|
15
|
+
raceDeadline,
|
|
16
|
+
type BotIsolateHostOptions,
|
|
17
|
+
type BotIsolateLoadedWorker,
|
|
18
|
+
type BotIsolateWorkerCode,
|
|
19
|
+
} from "./isolate-host.ts";
|
|
20
|
+
import { decodeFrockBotManifest } from "./manifest.ts";
|
|
21
|
+
|
|
22
|
+
const CONTENT_HASH = "a".repeat(64);
|
|
23
|
+
|
|
24
|
+
function manifest() {
|
|
25
|
+
return decodeFrockBotManifest({
|
|
26
|
+
schemaVersion: 3,
|
|
27
|
+
id: "bot-authored",
|
|
28
|
+
displayName: "Bot authored",
|
|
29
|
+
version: "0.0.1",
|
|
30
|
+
compatibility: { frockbot: "^0.0.1" },
|
|
31
|
+
dependencies: {},
|
|
32
|
+
contributions: { runtime: { entry: "./runtime.js" } },
|
|
33
|
+
permissions: [],
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function descriptor(): PackageDescriptor {
|
|
38
|
+
return {
|
|
39
|
+
specifier: "@bot/authored",
|
|
40
|
+
manifest: manifest(),
|
|
41
|
+
artifact: {
|
|
42
|
+
contentHash: CONTENT_HASH,
|
|
43
|
+
size: 12,
|
|
44
|
+
mediaType: "application/javascript",
|
|
45
|
+
bundlerVersion: "0.2.3",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface RecordedLoad {
|
|
51
|
+
loaderId: string;
|
|
52
|
+
code: BotIsolateWorkerCode;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fakeIsolate(
|
|
56
|
+
entrypoint: Partial<BotIsolateEntrypoint>,
|
|
57
|
+
loads: RecordedLoad[],
|
|
58
|
+
) {
|
|
59
|
+
return {
|
|
60
|
+
get(
|
|
61
|
+
loaderId: string,
|
|
62
|
+
callback: () => Promise<BotIsolateWorkerCode>,
|
|
63
|
+
): BotIsolateLoadedWorker {
|
|
64
|
+
void callback().then((code) => loads.push({ loaderId, code }));
|
|
65
|
+
return {
|
|
66
|
+
getEntrypoint: () =>
|
|
67
|
+
({
|
|
68
|
+
health: () => Promise.reject(new Error("health was not stubbed")),
|
|
69
|
+
execute: () => Promise.reject(new Error("execute was not stubbed")),
|
|
70
|
+
...entrypoint,
|
|
71
|
+
}) as BotIsolateEntrypoint,
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function healthy(
|
|
78
|
+
tools = [
|
|
79
|
+
{
|
|
80
|
+
name: "reverse_text",
|
|
81
|
+
description: "Reverses text",
|
|
82
|
+
inputSchema: { type: "object" },
|
|
83
|
+
idempotent: true,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
) {
|
|
87
|
+
return {
|
|
88
|
+
schemaVersion: 1 as const,
|
|
89
|
+
ok: true,
|
|
90
|
+
packageId: "bot-authored",
|
|
91
|
+
contractVersion: 1 as const,
|
|
92
|
+
tools,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const BINDING_DIGEST = "c".repeat(64);
|
|
97
|
+
|
|
98
|
+
function host(
|
|
99
|
+
overrides: Partial<BotIsolateHostOptions> & {
|
|
100
|
+
entrypoint?: Partial<BotIsolateEntrypoint>;
|
|
101
|
+
} = {},
|
|
102
|
+
) {
|
|
103
|
+
const loads: RecordedLoad[] = [];
|
|
104
|
+
const registered: ToolDefinition[] = [];
|
|
105
|
+
const ceilings: (readonly TurnTypeV1[] | undefined)[] = [];
|
|
106
|
+
const { entrypoint, ...rest } = overrides;
|
|
107
|
+
const options: BotIsolateHostOptions = {
|
|
108
|
+
loader: fakeIsolate(
|
|
109
|
+
entrypoint ?? { health: () => Promise.resolve(healthy()) },
|
|
110
|
+
loads,
|
|
111
|
+
),
|
|
112
|
+
artifacts: {
|
|
113
|
+
loadPackageArtifact: () => Promise.resolve("export const tools = [];"),
|
|
114
|
+
},
|
|
115
|
+
tools: {
|
|
116
|
+
register: (definition, registration) => {
|
|
117
|
+
registered.push(definition);
|
|
118
|
+
ceilings.push(registration?.admissionCeiling);
|
|
119
|
+
return () => {
|
|
120
|
+
const index = registered.indexOf(definition);
|
|
121
|
+
if (index >= 0) registered.splice(index, 1);
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
userId: "user-1",
|
|
126
|
+
botId: "bot-1",
|
|
127
|
+
sessionId: "session-1",
|
|
128
|
+
runId: "run-1",
|
|
129
|
+
turnId: "turn-1",
|
|
130
|
+
generationId: "gen-1",
|
|
131
|
+
capabilities: {} as BotCapabilitiesStub,
|
|
132
|
+
bindingDigest: BINDING_DIGEST,
|
|
133
|
+
compatibilityDate: "2026-08-27",
|
|
134
|
+
...rest,
|
|
135
|
+
};
|
|
136
|
+
return {
|
|
137
|
+
host: new BotIsolateContributionHost(options),
|
|
138
|
+
loads,
|
|
139
|
+
registered,
|
|
140
|
+
ceilings,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function executionContext(): ToolExecutionContext {
|
|
145
|
+
return {
|
|
146
|
+
botId: "bot-1",
|
|
147
|
+
agentId: "bot-1",
|
|
148
|
+
sessionId: "session-1",
|
|
149
|
+
compositionGenerationId: "gen-1",
|
|
150
|
+
turnType: "chat" as const,
|
|
151
|
+
effectId: "tool:1:1:0",
|
|
152
|
+
signal: new AbortController().signal,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
describe("Bot isolate contribution host", () => {
|
|
157
|
+
test("refuses a member with no artifact", async () => {
|
|
158
|
+
const { host: subject } = host();
|
|
159
|
+
const { artifact: _artifact, ...firstParty } = descriptor();
|
|
160
|
+
expect(await subject.prepare(firstParty)).toBeUndefined();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("loads with egress disabled and exactly two modules", async () => {
|
|
164
|
+
const { host: subject, loads } = host();
|
|
165
|
+
await subject.prepare(descriptor());
|
|
166
|
+
expect(loads).toHaveLength(1);
|
|
167
|
+
const code = loads[0]!.code;
|
|
168
|
+
expect(code.globalOutbound).toBeNull();
|
|
169
|
+
expect(Object.keys(code.modules).sort()).toEqual([
|
|
170
|
+
"index.js",
|
|
171
|
+
"package.js",
|
|
172
|
+
]);
|
|
173
|
+
expect(Object.keys(code.env).sort()).toEqual(["CAPABILITIES", "IDENTITY"]);
|
|
174
|
+
expect(code.mainModule).toBe("index.js");
|
|
175
|
+
expect(code.limits).toEqual({ cpuMs: 5_000, subRequests: 5 });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("a caller that omits the binding digest does not compile", () => {
|
|
179
|
+
// @ts-expect-error the binding digest is required: an isolate loaded with
|
|
180
|
+
// no digest of its granted bindings would share a loader id across
|
|
181
|
+
// Assignments and generations.
|
|
182
|
+
void botIsolateModuleSetHashV1(CONTENT_HASH);
|
|
183
|
+
expect(true).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a different binding digest is a different loader id", async () => {
|
|
187
|
+
const { host: subject, loads } = host();
|
|
188
|
+
await subject.prepare(descriptor());
|
|
189
|
+
const other = host({ bindingDigest: "d".repeat(64) });
|
|
190
|
+
await other.host.prepare(descriptor());
|
|
191
|
+
expect(loads[0]!.loaderId).not.toBe(other.loads[0]!.loaderId);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("keys the loader id on the User, the Bot and the module set", async () => {
|
|
195
|
+
const { host: subject, loads } = host();
|
|
196
|
+
await subject.prepare(descriptor());
|
|
197
|
+
const other = host({ botId: "bot-2" });
|
|
198
|
+
await other.host.prepare(descriptor());
|
|
199
|
+
const expected = await botIsolateModuleSetHashV1(
|
|
200
|
+
CONTENT_HASH,
|
|
201
|
+
BINDING_DIGEST,
|
|
202
|
+
);
|
|
203
|
+
expect(loads[0]!.loaderId).toBe(`bot-package:user-1:bot-1:${expected}`);
|
|
204
|
+
expect(other.loads[0]!.loaderId).toBe(
|
|
205
|
+
`bot-package:user-1:bot-2:${expected}`,
|
|
206
|
+
);
|
|
207
|
+
expect(loads[0]!.loaderId).not.toBe(other.loads[0]!.loaderId);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("health failure is a prepare failure with a diagnostic", async () => {
|
|
211
|
+
const { host: subject } = host({
|
|
212
|
+
entrypoint: {
|
|
213
|
+
health: () =>
|
|
214
|
+
Promise.reject(
|
|
215
|
+
new Error(
|
|
216
|
+
"Failed to start Worker:\nUncaught SyntaxError: Unexpected end of input\n at package.js:4",
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
await expect(subject.prepare(descriptor())).rejects.toThrow(
|
|
222
|
+
/failed to mount in its isolate.*package\.js:4/s,
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("rejects an isolate claiming another package's identity", async () => {
|
|
227
|
+
const { host: subject } = host({
|
|
228
|
+
entrypoint: {
|
|
229
|
+
health: () => Promise.resolve({ ...healthy(), packageId: "other" }),
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
await expect(subject.prepare(descriptor())).rejects.toThrow(
|
|
233
|
+
/different package id/,
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("rejects an isolate that declares no tools", async () => {
|
|
238
|
+
const { host: subject } = host({
|
|
239
|
+
entrypoint: { health: () => Promise.resolve(healthy([])) },
|
|
240
|
+
});
|
|
241
|
+
await expect(subject.prepare(descriptor())).rejects.toThrow(/unhealthy/);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("registers one tool per health entry and executes it over RPC", async () => {
|
|
245
|
+
let seen: IsolateToolInvocationV1 | undefined;
|
|
246
|
+
const { host: subject, registered } = host({
|
|
247
|
+
entrypoint: {
|
|
248
|
+
health: () => Promise.resolve(healthy()),
|
|
249
|
+
execute: (invocation) => {
|
|
250
|
+
seen = invocation;
|
|
251
|
+
return Promise.resolve({
|
|
252
|
+
schemaVersion: 1 as const,
|
|
253
|
+
content: "ba",
|
|
254
|
+
isError: false,
|
|
255
|
+
});
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
const prepared = await subject.prepare(descriptor());
|
|
260
|
+
const active = await prepared!.commit();
|
|
261
|
+
expect(registered).toHaveLength(1);
|
|
262
|
+
expect(registered[0]!.name).toBe("reverse_text");
|
|
263
|
+
expect(registered[0]!.idempotent).toBe(true);
|
|
264
|
+
|
|
265
|
+
const result = await registered[0]!.execute(
|
|
266
|
+
{ text: "ab" },
|
|
267
|
+
executionContext(),
|
|
268
|
+
);
|
|
269
|
+
expect(result).toEqual({ content: "ba", isError: false });
|
|
270
|
+
expect(seen?.deadlineMs).toBe(15_000);
|
|
271
|
+
expect(seen?.generationId).toBe("gen-1");
|
|
272
|
+
|
|
273
|
+
await active.dispose();
|
|
274
|
+
expect(registered).toHaveLength(0);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("an undecodable isolate result is a tool error, not a throw", async () => {
|
|
278
|
+
const { host: subject, registered } = host({
|
|
279
|
+
entrypoint: {
|
|
280
|
+
health: () => Promise.resolve(healthy()),
|
|
281
|
+
execute: () => Promise.resolve({ content: "ba" } as never),
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
const prepared = await subject.prepare(descriptor());
|
|
285
|
+
await prepared!.commit();
|
|
286
|
+
expect(await registered[0]!.execute({}, executionContext())).toMatchObject({
|
|
287
|
+
isError: true,
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("an unavailable artifact names the package and the hash", async () => {
|
|
292
|
+
const { host: subject } = host({
|
|
293
|
+
artifacts: {
|
|
294
|
+
loadPackageArtifact: () => Promise.reject(new Error("not found")),
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
await expect(subject.prepare(descriptor())).rejects.toThrow(
|
|
298
|
+
new RegExp(`"bot-authored" artifact "${CONTENT_HASH}" is unavailable`),
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("the Durable Object side of the deadline", () => {
|
|
304
|
+
test("resolves work inside the deadline", async () => {
|
|
305
|
+
await expect(raceDeadline(() => Promise.resolve(1), 1_000)).resolves.toBe(
|
|
306
|
+
1,
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("rejects work that outlives the deadline", async () => {
|
|
311
|
+
await expect(raceDeadline(() => new Promise(() => {}), 10)).rejects.toThrow(
|
|
312
|
+
/exceeded its deadline of 10ms/,
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("rejects immediately when the Turn is already cancelled", async () => {
|
|
317
|
+
const controller = new AbortController();
|
|
318
|
+
controller.abort();
|
|
319
|
+
await expect(
|
|
320
|
+
raceDeadline(() => new Promise(() => {}), 10_000, controller.signal),
|
|
321
|
+
).rejects.toThrow(/was cancelled/);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("rejects when the Turn is cancelled mid-flight", async () => {
|
|
325
|
+
const controller = new AbortController();
|
|
326
|
+
const pending = raceDeadline(
|
|
327
|
+
() => new Promise(() => {}),
|
|
328
|
+
10_000,
|
|
329
|
+
controller.signal,
|
|
330
|
+
);
|
|
331
|
+
controller.abort();
|
|
332
|
+
await expect(pending).rejects.toThrow(/was cancelled/);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("refuses a deadline outside the contract bound", async () => {
|
|
336
|
+
await expect(raceDeadline(() => Promise.resolve(1), 0)).rejects.toThrow(
|
|
337
|
+
/out of range/,
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe("the manifest bounds the turn types an isolate's tools reach", () => {
|
|
343
|
+
const bounded = (capabilities: unknown[]) =>
|
|
344
|
+
decodeFrockBotManifest({
|
|
345
|
+
schemaVersion: 4,
|
|
346
|
+
id: "bot-authored",
|
|
347
|
+
displayName: "Bot authored",
|
|
348
|
+
version: "0.0.1",
|
|
349
|
+
compatibility: { frockbot: "^0.0.1" },
|
|
350
|
+
dependencies: {},
|
|
351
|
+
contributions: { runtime: { entry: "./runtime.js" } },
|
|
352
|
+
permissions: [],
|
|
353
|
+
configuration: { capabilities },
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("reads the ceiling from the Capabilities that contribute tools", () => {
|
|
357
|
+
expect(botIsolateAdmissionCeilingV1(manifest())).toBeUndefined();
|
|
358
|
+
expect(
|
|
359
|
+
botIsolateAdmissionCeilingV1(
|
|
360
|
+
bounded([
|
|
361
|
+
{
|
|
362
|
+
id: "automation-only",
|
|
363
|
+
kind: "tool",
|
|
364
|
+
connectionTypes: [],
|
|
365
|
+
admission: { turnTypes: ["automation"] },
|
|
366
|
+
},
|
|
367
|
+
]),
|
|
368
|
+
),
|
|
369
|
+
).toEqual(["automation"]);
|
|
370
|
+
// A model Capability says nothing about which turns a tool reaches.
|
|
371
|
+
expect(
|
|
372
|
+
botIsolateAdmissionCeilingV1(
|
|
373
|
+
bounded([{ id: "models", kind: "model", connectionTypes: [] }]),
|
|
374
|
+
),
|
|
375
|
+
).toBeUndefined();
|
|
376
|
+
// One unbounded tool Capability leaves the Package's tools unbounded.
|
|
377
|
+
expect(
|
|
378
|
+
botIsolateAdmissionCeilingV1(
|
|
379
|
+
bounded([
|
|
380
|
+
{
|
|
381
|
+
id: "automation-only",
|
|
382
|
+
kind: "tool",
|
|
383
|
+
connectionTypes: [],
|
|
384
|
+
admission: { turnTypes: ["automation"] },
|
|
385
|
+
},
|
|
386
|
+
{ id: "work", kind: "tool", connectionTypes: [] },
|
|
387
|
+
]),
|
|
388
|
+
),
|
|
389
|
+
).toBeUndefined();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("passes the ceiling to the registry at registration", async () => {
|
|
393
|
+
const { host: subject, ceilings } = host({
|
|
394
|
+
entrypoint: { health: () => Promise.resolve(healthy()) },
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const prepared = await subject.prepare({
|
|
398
|
+
...descriptor(),
|
|
399
|
+
manifest: bounded([
|
|
400
|
+
{
|
|
401
|
+
id: "automation-only",
|
|
402
|
+
kind: "tool",
|
|
403
|
+
connectionTypes: [],
|
|
404
|
+
admission: { turnTypes: ["automation"] },
|
|
405
|
+
},
|
|
406
|
+
]),
|
|
407
|
+
});
|
|
408
|
+
await prepared!.commit();
|
|
409
|
+
|
|
410
|
+
expect(ceilings).toEqual([["automation"]]);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("registers with no ceiling when the manifest declares none", async () => {
|
|
414
|
+
const { host: subject, ceilings } = host({
|
|
415
|
+
entrypoint: { health: () => Promise.resolve(healthy()) },
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const prepared = await subject.prepare(descriptor());
|
|
419
|
+
await prepared!.commit();
|
|
420
|
+
|
|
421
|
+
expect(ceilings).toEqual([undefined]);
|
|
422
|
+
});
|
|
423
|
+
});
|