@frockbot/frock-compose 0.0.0 → 0.7.292

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,1545 @@
1
+ // The kernel-generated index module (`index.js`) for a User's Plugin worker.
2
+ //
3
+ // This is composition, not contract: the kernel *generates* this text and
4
+ // content-addresses it with every Plugin artifact it imports, so changing a
5
+ // byte of it is a new module set and therefore a new loader identity. Plugin
6
+ // code never implements the wrapper; each Plugin exports `tools` and
7
+ // `execute`, optionally `hooks`, `services` and `triggers`, and the index
8
+ // adapts: it decodes each invocation, enforces the deadline, fans a hook out
9
+ // to the enabled Plugins in mount order, and hands each Plugin a narrow `ctx`
10
+ // that names only what that Plugin may do.
11
+ //
12
+ // The wrapper is emitted as plain JavaScript because it is a module in the
13
+ // loaded Worker's module map, not a source file this repository compiles.
14
+ import {
15
+ BOT_ISOLATE_HOOK_EVENTS_V1,
16
+ BOT_ISOLATE_SERVING_CONTEXT_KEYS_V1,
17
+ ISOLATE_CONTRACT_VERSION,
18
+ MAX_FAILURE_REASON_V1,
19
+ PLUGIN_CARD_ACTION_NAME_PATTERN_V1,
20
+ type BotPackageContextV1,
21
+ } from "@frockbot/core/contracts";
22
+
23
+ /**
24
+ * The deadline guard, shared verbatim between the generated wrapper and the
25
+ * Bun test that proves it. Kept as source text so the tested function and the
26
+ * shipped function cannot drift.
27
+ */
28
+ export const BOT_ISOLATE_DEADLINE_SOURCE = `function withIsolateDeadline(work, deadlineMs) {
29
+ if (!Number.isSafeInteger(deadlineMs) || deadlineMs <= 0 || deadlineMs > 60000) {
30
+ return Promise.reject(new Error("isolate invocation deadline is out of range"));
31
+ }
32
+ let timer;
33
+ const expiry = new Promise(function (_resolve, reject) {
34
+ timer = setTimeout(function () {
35
+ reject(new Error("isolate invocation exceeded its deadline of " + deadlineMs + "ms"));
36
+ }, deadlineMs);
37
+ });
38
+ return Promise.race([Promise.resolve().then(work), expiry]).finally(function () {
39
+ clearTimeout(timer);
40
+ });
41
+ }`;
42
+
43
+ /**
44
+ * The invocation guards. The worker re-decodes what the Durable Object sent:
45
+ * the boundary is crossed in both directions and both sides decode.
46
+ */
47
+ export const BOT_ISOLATE_INVOCATION_SOURCE = `var TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
48
+ var PLUGIN_ID = /^[a-z][a-z0-9-]{0,63}$/;
49
+ var PROVIDER_ID = /^[a-z][a-z0-9-]{0,63}$/;
50
+ var TRIGGER_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
51
+ var SURFACE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
52
+ var CARD_ID = /^[a-z][a-z0-9_]{0,31}$/;
53
+ var CARD_ACTION_NAME = new RegExp(${JSON.stringify(PLUGIN_CARD_ACTION_NAME_PATTERN_V1)});
54
+ var HOOK_EVENTS = ${JSON.stringify(BOT_ISOLATE_HOOK_EVENTS_V1)};
55
+ var IDENTITY_KEYS = ["botId", "sessionId", "runId", "turnId", "generationId"];
56
+ function isRecord(value) {
57
+ return !!value && typeof value === "object" && !Array.isArray(value);
58
+ }
59
+ function exactKeys(value, keys, label) {
60
+ if (!isRecord(value)) {
61
+ throw new Error(label + " must be an object");
62
+ }
63
+ if (
64
+ Object.keys(value).length !== keys.length ||
65
+ !keys.every(function (key) {
66
+ return Object.hasOwn(value, key);
67
+ })
68
+ ) {
69
+ throw new Error(label + " has invalid fields");
70
+ }
71
+ }
72
+ function identityFields(value, label) {
73
+ for (const key of IDENTITY_KEYS) {
74
+ if (typeof value[key] !== "string" || value[key].length === 0) {
75
+ throw new Error(label + " " + key + " is invalid");
76
+ }
77
+ }
78
+ }
79
+ var INVOCATION_KEYS = [
80
+ "schemaVersion",
81
+ "pluginId",
82
+ "tool",
83
+ "input",
84
+ "botId",
85
+ "sessionId",
86
+ "runId",
87
+ "turnId",
88
+ "generationId",
89
+ "deadlineMs",
90
+ ];
91
+ function decodeInvocation(value) {
92
+ // A tool call inside a Turn names the effect it runs as; one outside any
93
+ // Turn names none.
94
+ const withEffect = isRecord(value) && Object.hasOwn(value, "effectId");
95
+ exactKeys(
96
+ value,
97
+ withEffect ? INVOCATION_KEYS.concat(["effectId"]) : INVOCATION_KEYS,
98
+ "plugin worker tool invocation",
99
+ );
100
+ if (withEffect && (typeof value.effectId !== "string" || value.effectId.length === 0)) {
101
+ throw new Error("plugin worker tool invocation effectId is invalid");
102
+ }
103
+ if (value.schemaVersion !== 1) {
104
+ throw new Error("plugin worker tool invocation schemaVersion is unsupported");
105
+ }
106
+ if (typeof value.pluginId !== "string" || !PLUGIN_ID.test(value.pluginId)) {
107
+ throw new Error("plugin worker tool invocation pluginId is invalid");
108
+ }
109
+ if (typeof value.tool !== "string" || !TOOL_NAME.test(value.tool)) {
110
+ throw new Error("plugin worker tool invocation tool is invalid");
111
+ }
112
+ identityFields(value, "plugin worker tool invocation");
113
+ return value;
114
+ }
115
+ var HOOK_INVOCATION_KEYS = [
116
+ "schemaVersion",
117
+ "event",
118
+ "payload",
119
+ "botId",
120
+ "sessionId",
121
+ "runId",
122
+ "turnId",
123
+ "generationId",
124
+ "deadlineMs",
125
+ "enabled",
126
+ ];
127
+ function decodeHookInvocation(value) {
128
+ exactKeys(value, HOOK_INVOCATION_KEYS, "plugin worker hook invocation");
129
+ if (value.schemaVersion !== 1 || !HOOK_EVENTS.includes(value.event)) {
130
+ throw new Error("plugin worker hook invocation is unsupported");
131
+ }
132
+ if (!isRecord(value.payload)) {
133
+ throw new Error("plugin worker hook invocation payload is invalid");
134
+ }
135
+ if (
136
+ !Array.isArray(value.enabled) ||
137
+ !value.enabled.every(function (id) {
138
+ return typeof id === "string" && PLUGIN_ID.test(id);
139
+ })
140
+ ) {
141
+ throw new Error("plugin worker hook invocation enabled is invalid");
142
+ }
143
+ identityFields(value, "plugin worker hook invocation");
144
+ return value;
145
+ }
146
+ var MODEL_INVOCATION_KEYS = [
147
+ "schemaVersion",
148
+ "pluginId",
149
+ "provider",
150
+ "protocolVersion",
151
+ "request",
152
+ "transportId",
153
+ "botId",
154
+ "sessionId",
155
+ "runId",
156
+ "turnId",
157
+ "generationId",
158
+ "deadlineMs",
159
+ "firstEventDeadlineMs",
160
+ ];
161
+ function decodeModelInvocation(value) {
162
+ exactKeys(value, MODEL_INVOCATION_KEYS, "plugin model invocation");
163
+ if (value.schemaVersion !== 1) {
164
+ throw new Error("plugin model invocation schemaVersion is unsupported");
165
+ }
166
+ if (typeof value.pluginId !== "string" || !PLUGIN_ID.test(value.pluginId)) {
167
+ throw new Error("plugin model invocation pluginId is invalid");
168
+ }
169
+ if (typeof value.provider !== "string" || !PROVIDER_ID.test(value.provider)) {
170
+ throw new Error("plugin model invocation provider is invalid");
171
+ }
172
+ if (!isRecord(value.request) || value.request.provider !== value.provider) {
173
+ throw new Error("plugin model invocation request is invalid");
174
+ }
175
+ if (value.request.modelBinding !== undefined) {
176
+ throw new Error("plugin model invocation request must not carry a Connection binding");
177
+ }
178
+ if (typeof value.transportId !== "string" || value.transportId.length === 0) {
179
+ throw new Error("plugin model invocation transportId is invalid");
180
+ }
181
+ identityFields(value, "plugin model invocation");
182
+ if (
183
+ !Number.isSafeInteger(value.deadlineMs) ||
184
+ value.deadlineMs <= 0 ||
185
+ value.deadlineMs > 60000
186
+ ) {
187
+ throw new Error("plugin model invocation deadlineMs is out of range");
188
+ }
189
+ if (
190
+ !Number.isSafeInteger(value.firstEventDeadlineMs) ||
191
+ value.firstEventDeadlineMs < value.deadlineMs ||
192
+ value.firstEventDeadlineMs > 120000
193
+ ) {
194
+ throw new Error("plugin model invocation firstEventDeadlineMs is out of range");
195
+ }
196
+ return value;
197
+ }
198
+ var TRIGGER_INVOCATION_KEYS = [
199
+ "schemaVersion",
200
+ "pluginId",
201
+ "trigger",
202
+ "headers",
203
+ "body",
204
+ "botId",
205
+ "sessionId",
206
+ "runId",
207
+ "turnId",
208
+ "generationId",
209
+ "routineId",
210
+ "deadlineMs",
211
+ ];
212
+ var VIEW_INVOCATION_KEYS = [
213
+ "schemaVersion",
214
+ "pluginId",
215
+ "surfaceId",
216
+ "botId",
217
+ "sessionId",
218
+ "runId",
219
+ "turnId",
220
+ "generationId",
221
+ "deadlineMs",
222
+ ];
223
+ function decodeViewInvocation(value) {
224
+ exactKeys(value, VIEW_INVOCATION_KEYS, "plugin worker view invocation");
225
+ if (value.schemaVersion !== 1) {
226
+ throw new Error("plugin worker view invocation schemaVersion is unsupported");
227
+ }
228
+ if (typeof value.pluginId !== "string" || !PLUGIN_ID.test(value.pluginId)) {
229
+ throw new Error("plugin worker view invocation pluginId is invalid");
230
+ }
231
+ if (typeof value.surfaceId !== "string" || !SURFACE_ID.test(value.surfaceId)) {
232
+ throw new Error("plugin worker view invocation surfaceId is invalid");
233
+ }
234
+ identityFields(value, "plugin worker view invocation");
235
+ return value;
236
+ }
237
+ var CARD_ACTION_INVOCATION_KEYS = [
238
+ "schemaVersion",
239
+ "pluginId",
240
+ "cardId",
241
+ "surfaceId",
242
+ "action",
243
+ "botId",
244
+ "sessionId",
245
+ "runId",
246
+ "turnId",
247
+ "generationId",
248
+ "deadlineMs",
249
+ ];
250
+ function decodeCardActionInvocation(value) {
251
+ if (!isRecord(value)) {
252
+ throw new Error("plugin worker card action invocation must be an object");
253
+ }
254
+ for (const key of Object.keys(value)) {
255
+ if (!CARD_ACTION_INVOCATION_KEYS.includes(key) && key !== "context" && key !== "dataModel" && key !== "record") {
256
+ throw new Error("plugin worker card action invocation has invalid fields");
257
+ }
258
+ }
259
+ for (const key of CARD_ACTION_INVOCATION_KEYS) {
260
+ if (!Object.hasOwn(value, key)) {
261
+ throw new Error("plugin worker card action invocation has invalid fields");
262
+ }
263
+ }
264
+ if (value.schemaVersion !== 1) {
265
+ throw new Error("plugin worker card action invocation schemaVersion is unsupported");
266
+ }
267
+ if (typeof value.pluginId !== "string" || !PLUGIN_ID.test(value.pluginId)) {
268
+ throw new Error("plugin worker card action invocation pluginId is invalid");
269
+ }
270
+ if (typeof value.cardId !== "string" || !CARD_ID.test(value.cardId)) {
271
+ throw new Error("plugin worker card action invocation cardId is invalid");
272
+ }
273
+ if (typeof value.surfaceId !== "string" || !SURFACE_ID.test(value.surfaceId)) {
274
+ throw new Error("plugin worker card action invocation surfaceId is invalid");
275
+ }
276
+ if (typeof value.action !== "string" || !CARD_ACTION_NAME.test(value.action)) {
277
+ throw new Error("plugin worker card action invocation action is invalid");
278
+ }
279
+ if (value.context !== undefined && !isRecord(value.context)) {
280
+ throw new Error("plugin worker card action invocation context is invalid");
281
+ }
282
+ if (value.dataModel !== undefined && !isRecord(value.dataModel)) {
283
+ throw new Error("plugin worker card action invocation dataModel is invalid");
284
+ }
285
+ if (value.record !== undefined && !isRecord(value.record)) {
286
+ throw new Error("plugin worker card action invocation record is invalid");
287
+ }
288
+ identityFields(value, "plugin worker card action invocation");
289
+ return value;
290
+ }
291
+ var RENDER_CARD_INVOCATION_KEYS = [
292
+ "schemaVersion",
293
+ "pluginId",
294
+ "cardId",
295
+ "surfaceId",
296
+ "data",
297
+ "botId",
298
+ "sessionId",
299
+ "runId",
300
+ "turnId",
301
+ "generationId",
302
+ "deadlineMs",
303
+ ];
304
+ function decodeRenderCardInvocation(value) {
305
+ exactKeys(value, RENDER_CARD_INVOCATION_KEYS, "plugin worker render card invocation");
306
+ if (value.schemaVersion !== 1) {
307
+ throw new Error("plugin worker render card invocation schemaVersion is unsupported");
308
+ }
309
+ if (typeof value.pluginId !== "string" || !PLUGIN_ID.test(value.pluginId)) {
310
+ throw new Error("plugin worker render card invocation pluginId is invalid");
311
+ }
312
+ if (typeof value.cardId !== "string" || !CARD_ID.test(value.cardId)) {
313
+ throw new Error("plugin worker render card invocation cardId is invalid");
314
+ }
315
+ if (typeof value.surfaceId !== "string" || !SURFACE_ID.test(value.surfaceId)) {
316
+ throw new Error("plugin worker render card invocation surfaceId is invalid");
317
+ }
318
+ if (!isRecord(value.data)) {
319
+ throw new Error("plugin worker render card invocation data is invalid");
320
+ }
321
+ identityFields(value, "plugin worker render card invocation");
322
+ return value;
323
+ }
324
+ var REVISE_CARD_INVOCATION_KEYS = [
325
+ "schemaVersion",
326
+ "pluginId",
327
+ "cardId",
328
+ "surfaceId",
329
+ "dataModel",
330
+ "record",
331
+ "botId",
332
+ "sessionId",
333
+ "runId",
334
+ "turnId",
335
+ "generationId",
336
+ "deadlineMs",
337
+ ];
338
+ function decodeReviseCardInvocation(value) {
339
+ exactKeys(value, REVISE_CARD_INVOCATION_KEYS, "plugin worker revise card invocation");
340
+ if (value.schemaVersion !== 1) {
341
+ throw new Error("plugin worker revise card invocation schemaVersion is unsupported");
342
+ }
343
+ if (typeof value.pluginId !== "string" || !PLUGIN_ID.test(value.pluginId)) {
344
+ throw new Error("plugin worker revise card invocation pluginId is invalid");
345
+ }
346
+ if (typeof value.cardId !== "string" || !CARD_ID.test(value.cardId)) {
347
+ throw new Error("plugin worker revise card invocation cardId is invalid");
348
+ }
349
+ if (typeof value.surfaceId !== "string" || !SURFACE_ID.test(value.surfaceId)) {
350
+ throw new Error("plugin worker revise card invocation surfaceId is invalid");
351
+ }
352
+ if (!isRecord(value.dataModel) || !isRecord(value.record)) {
353
+ throw new Error("plugin worker revise card invocation data model is invalid");
354
+ }
355
+ identityFields(value, "plugin worker revise card invocation");
356
+ return value;
357
+ }
358
+ function decodeTriggerInvocation(value) {
359
+ exactKeys(
360
+ value,
361
+ isRecord(value) && Object.hasOwn(value, "source")
362
+ ? TRIGGER_INVOCATION_KEYS.concat("source")
363
+ : TRIGGER_INVOCATION_KEYS,
364
+ "plugin worker trigger invocation",
365
+ );
366
+ if (
367
+ Object.hasOwn(value, "source") &&
368
+ (!isRecord(value.source) ||
369
+ value.source.kind !== "device-module" ||
370
+ typeof value.source.moduleId !== "string" ||
371
+ typeof value.source.machineId !== "string" ||
372
+ typeof value.source.key !== "string")
373
+ ) {
374
+ throw new Error("plugin worker trigger invocation source is invalid");
375
+ }
376
+ if (value.schemaVersion !== 1) {
377
+ throw new Error("plugin worker trigger invocation schemaVersion is unsupported");
378
+ }
379
+ if (typeof value.pluginId !== "string" || !PLUGIN_ID.test(value.pluginId)) {
380
+ throw new Error("plugin worker trigger invocation pluginId is invalid");
381
+ }
382
+ if (typeof value.trigger !== "string" || !TRIGGER_NAME.test(value.trigger)) {
383
+ throw new Error("plugin worker trigger invocation trigger is invalid");
384
+ }
385
+ if (!isRecord(value.headers) || typeof value.body !== "string") {
386
+ throw new Error("plugin worker trigger invocation event is invalid");
387
+ }
388
+ identityFields(value, "plugin worker trigger invocation");
389
+ if (typeof value.routineId !== "string" || value.routineId.length === 0) {
390
+ throw new Error("plugin worker trigger invocation routineId is invalid");
391
+ }
392
+ return value;
393
+ }`;
394
+
395
+ /** Decodes one NDJSON line of the `invokeModel` byte stream inside the isolate. */
396
+ export const BOT_ISOLATE_MODEL_SOURCE = `async function* modelEvents(stream) {
397
+ const reader = stream.getReader();
398
+ const decoder = new TextDecoder();
399
+ let buffer = "";
400
+ try {
401
+ for (;;) {
402
+ const chunk = await reader.read();
403
+ if (chunk.done) break;
404
+ buffer += decoder.decode(chunk.value, { stream: true });
405
+ let newline = buffer.indexOf("\\n");
406
+ while (newline >= 0) {
407
+ const line = buffer.slice(0, newline).trim();
408
+ buffer = buffer.slice(newline + 1);
409
+ if (line.length > 0) yield JSON.parse(line);
410
+ newline = buffer.indexOf("\\n");
411
+ }
412
+ }
413
+ const tail = buffer.trim();
414
+ if (tail.length > 0) yield JSON.parse(tail);
415
+ } finally {
416
+ reader.releaseLock();
417
+ }
418
+ }`;
419
+
420
+ /**
421
+ * The `ctx` members that are always present: the identity of the call, the
422
+ * three context keys, and the services the Plugin consumes. Nothing here is
423
+ * authority.
424
+ */
425
+ const BOT_ISOLATE_CONTEXT_PROPERTY_SOURCE_V1 = {
426
+ tool: "invocation.tool",
427
+ event: "invocation.event",
428
+ user: "{ userId: env.IDENTITY.userId }",
429
+ bot: "{ botId: invocation.botId }",
430
+ session: `{
431
+ sessionId: invocation.sessionId,
432
+ runId: invocation.runId,
433
+ turnId: invocation.turnId,
434
+ generationId: invocation.generationId,
435
+ }`,
436
+ packageId: "plugin.pluginId",
437
+ deadlineMs: "deadlineMs",
438
+ bindings: "Object.keys(env).sort()",
439
+ capabilities: `{
440
+ list: function () {
441
+ return capabilities.list(scope);
442
+ },
443
+ }`,
444
+ services: "plugin.services",
445
+ settings: `{
446
+ read: function () {
447
+ return capabilities.settings(scope);
448
+ },
449
+ }`,
450
+ } satisfies Partial<Record<keyof BotPackageContextV1, string>>;
451
+
452
+ /**
453
+ * One `ctx` member per grant. A grant the Plugin did not declare is never
454
+ * built, so `ctx.workspace` on a Plugin without the Workspace grant is
455
+ * `undefined` rather than a call that reaches the authority and is refused.
456
+ */
457
+ const BOT_ISOLATE_GRANT_PROPERTY_SOURCE_V1 = {
458
+ ai: [
459
+ [
460
+ "model",
461
+ `{
462
+ invoke: async function (request) {
463
+ const outcome = await capabilities.invokeModel(scope, request);
464
+ if (!outcome || outcome.status !== "streaming") return outcome;
465
+ return {
466
+ status: "streaming",
467
+ requestId: outcome.requestId,
468
+ events: modelEvents(outcome.events),
469
+ };
470
+ },
471
+ }`,
472
+ ],
473
+ ],
474
+ jev: [
475
+ [
476
+ "jev",
477
+ `{
478
+ decide: function (request) {
479
+ return capabilities.jevDecide(scope, request);
480
+ },
481
+ }`,
482
+ ],
483
+ ],
484
+ memory: [
485
+ [
486
+ "memory",
487
+ `{
488
+ read: function (request) {
489
+ return capabilities.memoryRead(scope, request);
490
+ },
491
+ write: function (request) {
492
+ return capabilities.memoryWrite(scope, request);
493
+ },
494
+ forget: function (request) {
495
+ return capabilities.memoryForget(scope, request);
496
+ },
497
+ }`,
498
+ ],
499
+ ],
500
+ workspace: [
501
+ [
502
+ "workspace",
503
+ `{
504
+ read: function (path) {
505
+ return capabilities.workspaceRead(scope, path);
506
+ },
507
+ list: function (request) {
508
+ return capabilities.workspaceList(scope, request);
509
+ },
510
+ stat: function (path) {
511
+ return capabilities.workspaceStat(scope, path);
512
+ },
513
+ write: function (request) {
514
+ return capabilities.workspaceWrite(scope, request);
515
+ },
516
+ delete: function (request) {
517
+ return capabilities.workspaceDelete(scope, request);
518
+ },
519
+ }`,
520
+ ],
521
+ ],
522
+ // The one grant that opens two members: reaching a declared host, and
523
+ // asking the deployment's own sender to send mail for the Bot.
524
+ http: [
525
+ [
526
+ "connection",
527
+ "function (connectionId) { return capabilities.connection(scope, connectionId); }",
528
+ ],
529
+ [
530
+ "email",
531
+ "function (request) { return capabilities.sendEmail(scope, request); }",
532
+ ],
533
+ ],
534
+ schedule: [
535
+ [
536
+ "schedule",
537
+ "function (request) { return capabilities.schedule(scope, request); }",
538
+ ],
539
+ ],
540
+ // A call to one of the Plugin's own device modules (ADR 0037). It is keyed
541
+ // by the tool call it runs inside and its place among that call's device
542
+ // calls, so a replayed Turn is answered from the record, never sent again.
543
+ device: [
544
+ [
545
+ "device",
546
+ `{
547
+ call: function (moduleId, call, input, options) {
548
+ const request = {
549
+ moduleId: moduleId,
550
+ call: call,
551
+ input: input === undefined ? null : input,
552
+ sequence: deviceCalls++,
553
+ };
554
+ if (options && options.deviceId !== undefined) {
555
+ request.deviceId = options.deviceId;
556
+ }
557
+ if (typeof invocation.effectId === "string") {
558
+ request.effectId = invocation.effectId;
559
+ }
560
+ return capabilities.deviceCall(scope, request);
561
+ },
562
+ }`,
563
+ ],
564
+ ],
565
+ storage: [
566
+ [
567
+ "storage",
568
+ `{
569
+ get: function (request) {
570
+ return capabilities.storageGet(scope, request);
571
+ },
572
+ put: function (request) {
573
+ return capabilities.storagePut(scope, request);
574
+ },
575
+ delete: function (request) {
576
+ return capabilities.storageDelete(scope, request);
577
+ },
578
+ list: function (request) {
579
+ return capabilities.storageList(scope, request);
580
+ },
581
+ }`,
582
+ ],
583
+ ],
584
+ } satisfies Record<string, [keyof BotPackageContextV1, string][]>;
585
+
586
+ /**
587
+ * The keys the generated wrapper can place on `ctx`. Every member of the
588
+ * context type is here: the common ones always, a grant member when its grant
589
+ * is held, and the serving-only ones — `modelTransport` above all — only while
590
+ * the host is serving a model call, which the contract names separately in
591
+ * `BOT_ISOLATE_SERVING_CONTEXT_KEYS_V1`.
592
+ */
593
+ export const BOT_ISOLATE_NARROW_CONTEXT_KEYS_V1 = [
594
+ ...Object.keys(BOT_ISOLATE_CONTEXT_PROPERTY_SOURCE_V1),
595
+ ...Object.values(BOT_ISOLATE_GRANT_PROPERTY_SOURCE_V1).flatMap((members) =>
596
+ members.map(([key]) => key),
597
+ ),
598
+ ...BOT_ISOLATE_SERVING_CONTEXT_KEYS_V1,
599
+ ] as Array<keyof BotPackageContextV1>;
600
+
601
+ /**
602
+ * One model provider's answer (ADR 0032), shared verbatim between the
603
+ * generated wrapper and the Bun test that proves it.
604
+ *
605
+ * The Plugin's `stream` is an async iterable of normalized events; the
606
+ * boundary carries bytes, so the wrapper encodes each event as one NDJSON
607
+ * line. The deadline is the model protocol's silence allowance rather than a
608
+ * bound on the whole answer: a reply may stream for minutes, and the timer is
609
+ * reset by every event, so a provider that stops producing them is stopped
610
+ * and one that keeps producing them is not. Cancelling the stream — a stop,
611
+ * the host's idle deadline, the Turn ending — returns the generator, which is
612
+ * what lets a Plugin release the upstream body it is reading.
613
+ */
614
+ export const BOT_ISOLATE_MODEL_PROVIDER_SOURCE = `function modelEventStream(iterable, deadlineMs, firstEventDeadlineMs) {
615
+ const encoder = new TextEncoder();
616
+ const iterator = iterable[Symbol.asyncIterator]();
617
+ let timer;
618
+ let controller;
619
+ let done = false;
620
+ function stop() {
621
+ if (timer !== undefined) {
622
+ clearTimeout(timer);
623
+ timer = undefined;
624
+ }
625
+ }
626
+ function abort(error) {
627
+ if (done) return;
628
+ done = true;
629
+ stop();
630
+ // The generator is released and the stream fails. A generator parked on a
631
+ // promise of its own is settled by the host aborting the call it is
632
+ // waiting on, which is why the host owns the upstream call.
633
+ void Promise.resolve(iterator.return?.(undefined)).catch(function () {});
634
+ if (controller) controller.error(error);
635
+ }
636
+ function arm(allowedMs) {
637
+ stop();
638
+ timer = setTimeout(function () {
639
+ abort(new Error("the model provider produced no event for " + allowedMs + "ms"));
640
+ }, allowedMs);
641
+ }
642
+ const stream = new ReadableStream({
643
+ start(value) {
644
+ controller = value;
645
+ // Waiting for the first event is the model protocol's first-byte
646
+ // allowance — a provider that has accepted a request and not answered
647
+ // yet is slow, not dead; after that, a minute of silence is a dead
648
+ // socket.
649
+ arm(firstEventDeadlineMs);
650
+ },
651
+ async pull(value) {
652
+ if (done) return;
653
+ try {
654
+ const next = await iterator.next();
655
+ if (done) return;
656
+ if (next.done) {
657
+ done = true;
658
+ stop();
659
+ value.close();
660
+ return;
661
+ }
662
+ arm(deadlineMs);
663
+ value.enqueue(encoder.encode(JSON.stringify(next.value) + "\\n"));
664
+ } catch (error) {
665
+ abort(error);
666
+ }
667
+ },
668
+ cancel() {
669
+ done = true;
670
+ stop();
671
+ // Not awaited: a generator parked inside its transport call returns
672
+ // only when the host aborts that call, and the host is waiting on this
673
+ // cancel to do it. The return is queued instead, so cancellation is
674
+ // never what a hung provider is waiting behind.
675
+ void Promise.resolve(iterator.return?.(undefined)).catch(function () {});
676
+ return;
677
+ },
678
+ });
679
+ return stream;
680
+ }
681
+
682
+ async function runModelStream(invocation, resolve, contextFor) {
683
+ try {
684
+ const plugin = resolve(invocation.pluginId);
685
+ if (!plugin.modelProviders.includes(invocation.provider)) {
686
+ throw new Error('plugin "' + invocation.pluginId + '" does not serve provider "' + invocation.provider + '"');
687
+ }
688
+ const context = contextFor(invocation, plugin, invocation.deadlineMs, invocation.transportId);
689
+ const iterable = plugin.module.modelProviders[invocation.provider].stream(
690
+ invocation.request,
691
+ context,
692
+ );
693
+ if (!iterable || typeof iterable[Symbol.asyncIterator] !== "function") {
694
+ throw new Error('plugin "' + invocation.pluginId + '" provider "' + invocation.provider + '" returned no event stream');
695
+ }
696
+ return {
697
+ schemaVersion: 1,
698
+ status: "streaming",
699
+ events: modelEventStream(
700
+ iterable,
701
+ invocation.deadlineMs,
702
+ invocation.firstEventDeadlineMs,
703
+ ),
704
+ };
705
+ } catch (error) {
706
+ return { schemaVersion: 1, status: "refused", reason: errorText(error) };
707
+ }
708
+ }`;
709
+
710
+ export const BOT_ISOLATE_NARROW_CONTEXT_SOURCE_V1 = `function narrowContext(env, invocation, plugin, deadlineMs, transportId) {
711
+ const capabilities = env.CAPABILITIES;
712
+ const grants = plugin.grants || [];
713
+ // Every loopback call names the Turn, the Bot and the Plugin it is for: the
714
+ // stub itself is per User and carries none of that.
715
+ const scope = {
716
+ botId: invocation.botId,
717
+ sessionId: invocation.sessionId,
718
+ runId: invocation.runId,
719
+ turnId: invocation.turnId,
720
+ generationId: invocation.generationId,
721
+ pluginId: plugin.pluginId,
722
+ };
723
+ let deviceCalls = 0;
724
+ const context = {
725
+ ${Object.entries(BOT_ISOLATE_CONTEXT_PROPERTY_SOURCE_V1)
726
+ .map(([key, source]) => ` ${JSON.stringify(key)}: ${source},`)
727
+ .join("\n")}
728
+ };
729
+ ${Object.entries(BOT_ISOLATE_GRANT_PROPERTY_SOURCE_V1)
730
+ .flatMap(([grant, members]) =>
731
+ members.map(
732
+ ([key, source]) =>
733
+ ` if (grants.includes(${JSON.stringify(grant)})) {\n context[${JSON.stringify(key)}] = ${source};\n }`,
734
+ ),
735
+ )
736
+ .join("\n")}
737
+ // One model call's one credentialed transport, and only while the host is
738
+ // serving that call: the ticket is the host's own and is spent by the call
739
+ // it is spent on, and the destination is the host's to choose. A tool
740
+ // call's context has no member to call.
741
+ if (transportId) {
742
+ context.modelTransport = function (request) {
743
+ const call = request && typeof request === "object" ? request : {};
744
+ return capabilities.modelTransport(scope, {
745
+ schemaVersion: 1,
746
+ transportId: transportId,
747
+ body: call.body,
748
+ });
749
+ };
750
+ }
751
+ return context;
752
+ }`;
753
+
754
+ /**
755
+ * Which key of a hook's payload carries the value the hook may replace, so a
756
+ * later Plugin sees an earlier Plugin's change. A notification carries none.
757
+ */
758
+ export const BOT_ISOLATE_HOOK_VALUE_KEYS_V1 = {
759
+ "system-prompt/assemble": "assembly",
760
+ "agent/tool-exposure": "tools",
761
+ "agent/request": "request",
762
+ "tools/pre-execute": "preparation",
763
+ "tools/post-execute": "result",
764
+ "agent/turn-stopping": null,
765
+ "theme/assemble": "document",
766
+ } as const satisfies Record<
767
+ (typeof BOT_ISOLATE_HOOK_EVENTS_V1)[number],
768
+ string | null
769
+ >;
770
+
771
+ /** How an error anywhere in the index is reduced to text for the kernel. */
772
+ export const BOT_ISOLATE_ERROR_TEXT_SOURCE = `function errorText(error) {
773
+ var text = String((error && error.message) || error).slice(0, ${MAX_FAILURE_REASON_V1});
774
+ return text.length > 0 ? text : "unknown error";
775
+ }`;
776
+
777
+ /**
778
+ * The hook chain, shared verbatim between the generated wrapper and the Bun
779
+ * test that proves it. The invocation's deadline is the budget for the whole
780
+ * chain, because the Durable Object races the single `hook()` call against
781
+ * that number plus a fixed margin: each Plugin is given only what is left of
782
+ * it, and a Plugin the chain reaches with nothing left is skipped and named
783
+ * rather than started on borrowed time the kernel would charge to everyone.
784
+ */
785
+ export const BOT_ISOLATE_HOOK_CHAIN_SOURCE = `var HOOK_MIN_SLICE_MS = 25;
786
+ async function runHookChain(plugins, invocation, contextFor) {
787
+ const startedAt = Date.now();
788
+ const valueKey = HOOK_VALUE_KEYS[invocation.event];
789
+ const failures = [];
790
+ let replacement;
791
+ let replaced = false;
792
+ for (const plugin of plugins) {
793
+ if (!plugin.ok || !invocation.enabled.includes(plugin.pluginId)) continue;
794
+ if (!plugin.hooks.includes(invocation.event)) continue;
795
+ const remaining = invocation.deadlineMs - (Date.now() - startedAt);
796
+ if (remaining < HOOK_MIN_SLICE_MS) {
797
+ failures.push({
798
+ pluginId: plugin.pluginId,
799
+ reason: "the hook chain exhausted its deadline of " + invocation.deadlineMs + "ms before this plugin ran",
800
+ });
801
+ continue;
802
+ }
803
+ const payload =
804
+ replaced && valueKey
805
+ ? Object.assign({}, invocation.payload, { [valueKey]: replacement })
806
+ : invocation.payload;
807
+ const context = contextFor(plugin, remaining);
808
+ try {
809
+ const value = await withIsolateDeadline(function () {
810
+ return plugin.module.hooks[invocation.event](payload, context);
811
+ }, remaining);
812
+ if (value !== undefined) {
813
+ if (!valueKey) {
814
+ throw new Error("a notification hook cannot replace a value");
815
+ }
816
+ replacement = value;
817
+ replaced = true;
818
+ }
819
+ } catch (error) {
820
+ failures.push({ pluginId: plugin.pluginId, reason: errorText(error) });
821
+ }
822
+ }
823
+ return replaced
824
+ ? { schemaVersion: 1, status: "replaced", replacement: replacement, failures: failures }
825
+ : { schemaVersion: 1, status: "unchanged", failures: failures };
826
+ }`;
827
+
828
+ /**
829
+ * One trigger delivered to one Plugin, shared verbatim between the generated
830
+ * wrapper and the Bun test that proves it. A trigger runs outside any Turn, so
831
+ * it narrows its context with the standalone call's identity the Bot sent.
832
+ * The fired text is returned whole: the Durable Object holds the contract's
833
+ * bound and names the Plugin when a body exceeds it.
834
+ */
835
+ export const BOT_ISOLATE_TRIGGER_SOURCE = `async function runTrigger(invocation, resolve, contextFor) {
836
+ try {
837
+ const plugin = resolve(invocation.pluginId);
838
+ if (!plugin.triggers.includes(invocation.trigger)) {
839
+ throw new Error('plugin "' + invocation.pluginId + '" did not declare trigger "' + invocation.trigger + '"');
840
+ }
841
+ const context = contextFor(invocation, plugin, invocation.deadlineMs);
842
+ const value = await withIsolateDeadline(function () {
843
+ return plugin.module.triggers[invocation.trigger](
844
+ invocation.source
845
+ ? {
846
+ headers: invocation.headers,
847
+ body: invocation.body,
848
+ source: {
849
+ kind: invocation.source.kind,
850
+ moduleId: invocation.source.moduleId,
851
+ machineId: invocation.source.machineId,
852
+ key: invocation.source.key,
853
+ },
854
+ }
855
+ : { headers: invocation.headers, body: invocation.body },
856
+ context,
857
+ );
858
+ }, invocation.deadlineMs);
859
+ if (typeof value === "string" && value.length > 0) {
860
+ return { schemaVersion: 1, status: "fire", text: value };
861
+ }
862
+ if (value && typeof value === "object" && value.drop === true) {
863
+ return Object.assign(
864
+ { schemaVersion: 1, status: "drop" },
865
+ typeof value.reason === "string" ? { reason: errorText(value.reason) } : {},
866
+ );
867
+ }
868
+ return { schemaVersion: 1, status: "drop", reason: "the trigger returned no text" };
869
+ } catch (error) {
870
+ return { schemaVersion: 1, status: "drop", reason: errorText(error) };
871
+ }
872
+ }`;
873
+
874
+ /** What one Plugin's module must export, checked once at mount. */
875
+ export const BOT_ISOLATE_DECLARATION_SOURCE = `function declaredTools(module, pluginId) {
876
+ // A Plugin that only serves hooks, triggers or views exports an empty
877
+ // array: the build admits one, so the worker does too.
878
+ if (!Array.isArray(module.tools)) {
879
+ throw new Error('plugin "' + pluginId + '" must export a "tools" array');
880
+ }
881
+ const declared = module.tools;
882
+ if (typeof module.execute !== "function") {
883
+ throw new Error('plugin "' + pluginId + '" must export an "execute" function');
884
+ }
885
+ return declared.map(function (tool) {
886
+ if (!tool || typeof tool.name !== "string" || !TOOL_NAME.test(tool.name)) {
887
+ throw new Error('plugin "' + pluginId + '" declared a tool with an invalid name');
888
+ }
889
+ const schema =
890
+ tool.inputSchema && typeof tool.inputSchema === "object" && !Array.isArray(tool.inputSchema)
891
+ ? tool.inputSchema
892
+ : {};
893
+ const admission =
894
+ tool.admission && typeof tool.admission === "object"
895
+ ? {
896
+ turnTypes: tool.admission.turnTypes,
897
+ ...(tool.admission.subagentRoles
898
+ ? { subagentRoles: tool.admission.subagentRoles }
899
+ : {}),
900
+ }
901
+ : undefined;
902
+ return Object.assign(
903
+ {
904
+ name: tool.name,
905
+ description: typeof tool.description === "string" ? tool.description : "",
906
+ inputSchema: schema,
907
+ idempotent: tool.idempotent === true,
908
+ },
909
+ admission ? { admission: admission } : {},
910
+ );
911
+ });
912
+ }
913
+
914
+ function declaredHooks(module, pluginId) {
915
+ if (module.hooks === undefined) return [];
916
+ if (!isRecord(module.hooks)) {
917
+ throw new Error('plugin "' + pluginId + '" "hooks" must be an object');
918
+ }
919
+ return Object.keys(module.hooks).map(function (event) {
920
+ if (!HOOK_EVENTS.includes(event)) {
921
+ throw new Error('plugin "' + pluginId + '" declared an unsupported hook "' + event + '"');
922
+ }
923
+ if (typeof module.hooks[event] !== "function") {
924
+ throw new Error('plugin "' + pluginId + '" hook "' + event + '" must be a function');
925
+ }
926
+ return event;
927
+ });
928
+ }
929
+
930
+ function declaredServices(module, pluginId) {
931
+ if (module.services === undefined) return {};
932
+ if (!isRecord(module.services)) {
933
+ throw new Error('plugin "' + pluginId + '" "services" must be an object');
934
+ }
935
+ return module.services;
936
+ }
937
+
938
+ function declaredTriggers(module, pluginId) {
939
+ if (module.triggers === undefined) return [];
940
+ if (!isRecord(module.triggers)) {
941
+ throw new Error('plugin "' + pluginId + '" "triggers" must be an object');
942
+ }
943
+ return Object.keys(module.triggers).map(function (name) {
944
+ if (!TRIGGER_NAME.test(name)) {
945
+ throw new Error('plugin "' + pluginId + '" declared a trigger with an invalid name');
946
+ }
947
+ if (typeof module.triggers[name] !== "function") {
948
+ throw new Error('plugin "' + pluginId + '" trigger "' + name + '" must be a function');
949
+ }
950
+ return name;
951
+ });
952
+ }
953
+ function declaredCards(module, pluginId) {
954
+ if (module.cards === undefined) return [];
955
+ if (!isRecord(module.cards)) {
956
+ throw new Error('plugin "' + pluginId + '" "cards" must be an object');
957
+ }
958
+ const owner = {};
959
+ return Object.keys(module.cards).map(function (cardId) {
960
+ if (!CARD_ID.test(cardId)) {
961
+ throw new Error('plugin "' + pluginId + '" declared a card with an invalid id');
962
+ }
963
+ const card = module.cards[cardId];
964
+ if (!isRecord(card) || typeof card.render !== "function") {
965
+ throw new Error('plugin "' + pluginId + '" card "' + cardId + '" must export a render function');
966
+ }
967
+ if (card.revise !== undefined && typeof card.revise !== "function") {
968
+ throw new Error('plugin "' + pluginId + '" card "' + cardId + '" revise must be a function');
969
+ }
970
+ const actions = [];
971
+ if (card.actions !== undefined) {
972
+ if (!isRecord(card.actions)) {
973
+ throw new Error('plugin "' + pluginId + '" card "' + cardId + '" actions must be an object');
974
+ }
975
+ for (const name of Object.keys(card.actions)) {
976
+ if (typeof card.actions[name] !== "function") {
977
+ throw new Error('plugin "' + pluginId + '" card action "' + name + '" must be a function');
978
+ }
979
+ // A press names no card, so one action name on two cards would be two
980
+ // handlers behind one press and the one reached would be whichever
981
+ // card was scanned first. The descriptor refuses it; so does the
982
+ // mount, and a module that does it runs nothing at all.
983
+ if (Object.hasOwn(owner, name)) {
984
+ throw new Error('plugin "' + pluginId + '" declares card action "' + name + '" on both "' + owner[name] + '" and "' + cardId + '"');
985
+ }
986
+ owner[name] = cardId;
987
+ actions.push(name);
988
+ }
989
+ }
990
+ return { id: cardId, actions: actions };
991
+ });
992
+ }
993
+ function declaredModelProviders(module, pluginId) {
994
+ if (module.modelProviders === undefined) return [];
995
+ if (!isRecord(module.modelProviders)) {
996
+ throw new Error('plugin "' + pluginId + '" "modelProviders" must be an object');
997
+ }
998
+ return Object.keys(module.modelProviders).map(function (providerId) {
999
+ if (!PROVIDER_ID.test(providerId)) {
1000
+ throw new Error('plugin "' + pluginId + '" declared a model provider with an invalid id');
1001
+ }
1002
+ const provider = module.modelProviders[providerId];
1003
+ if (!isRecord(provider) || typeof provider.stream !== "function") {
1004
+ throw new Error('plugin "' + pluginId + '" model provider "' + providerId + '" must export a stream function');
1005
+ }
1006
+ return providerId;
1007
+ });
1008
+ }
1009
+
1010
+ function declaredViews(module, pluginId) {
1011
+ if (module.views === undefined) return [];
1012
+ if (!isRecord(module.views)) {
1013
+ throw new Error('plugin "' + pluginId + '" "views" must be an object');
1014
+ }
1015
+ return Object.keys(module.views).map(function (surfaceId) {
1016
+ if (!SURFACE_ID.test(surfaceId)) {
1017
+ throw new Error('plugin "' + pluginId + '" declared a view with an invalid surface id');
1018
+ }
1019
+ if (typeof module.views[surfaceId] !== "function") {
1020
+ throw new Error('plugin "' + pluginId + '" view "' + surfaceId + '" must be a function');
1021
+ }
1022
+ return surfaceId;
1023
+ });
1024
+ }`;
1025
+
1026
+ /**
1027
+ * One slot render: the Plugin's view function is handed a `ctx` shaped like a
1028
+ * tool call's and answers with a document, or drops with a reason.
1029
+ */
1030
+ export const BOT_ISOLATE_VIEW_SOURCE = `async function runView(invocation, resolve, contextFor) {
1031
+ try {
1032
+ const plugin = resolve(invocation.pluginId);
1033
+ if (!plugin.views.includes(invocation.surfaceId)) {
1034
+ throw new Error('plugin "' + invocation.pluginId + '" did not declare view "' + invocation.surfaceId + '"');
1035
+ }
1036
+ const context = contextFor(invocation, plugin, invocation.deadlineMs);
1037
+ const value = await withIsolateDeadline(function () {
1038
+ return plugin.module.views[invocation.surfaceId](context);
1039
+ }, invocation.deadlineMs);
1040
+ if (value && typeof value === "object" && !Array.isArray(value)) {
1041
+ return { schemaVersion: 1, status: "rendered", document: value };
1042
+ }
1043
+ return { schemaVersion: 1, status: "drop", reason: "the view returned no document" };
1044
+ } catch (error) {
1045
+ return { schemaVersion: 1, status: "drop", reason: errorText(error) };
1046
+ }
1047
+ }`;
1048
+
1049
+ /**
1050
+ * Drawing one Card and answering one press on it, shared verbatim between the
1051
+ * generated wrapper and the Bun test that proves it.
1052
+ *
1053
+ * A handler answers with the A2UI messages the kernel folds — an array, or
1054
+ * `{ messages, input }` when it also has a line for the Bot's next Turn — and
1055
+ * anything else is a drop with its reason, so a Card is never half-redrawn.
1056
+ * A press's name carries no card: `plugin/<pluginId>/<action>` is the
1057
+ * plugin's namespace, so the press is resolved against the card the module
1058
+ * declared that action on. One name on two cards fails the mount, and the
1059
+ * host checks the declaration against the descriptor at health, so the card a
1060
+ * press reaches is the one the descriptor says owns it — never whichever card
1061
+ * the module happened to be scanned in first. The invocation's `cardId` is
1062
+ * the card the pressed surface was minted for: it is what the handler is told
1063
+ * it is on, and a press whose surface names a different card of the same
1064
+ * Plugin is refused rather than answered on the wrong surface.
1065
+ */
1066
+ export const BOT_ISOLATE_CARD_SOURCE = `function cardAnswer(value) {
1067
+ if (Array.isArray(value)) {
1068
+ return { schemaVersion: 1, status: "rendered", messages: value };
1069
+ }
1070
+ if (isRecord(value) && Array.isArray(value.messages)) {
1071
+ return Object.assign(
1072
+ { schemaVersion: 1, status: "rendered", messages: value.messages },
1073
+ typeof value.input === "string" && value.input.length > 0 ? { input: value.input } : {},
1074
+ );
1075
+ }
1076
+ if (isRecord(value) && value.drop === true) {
1077
+ // The handler refused on purpose. Marked so the kernel can tell this
1078
+ // apart from a throw, an overrun or an answer it could not read, which
1079
+ // are the only drops a Plugin's health is charged for.
1080
+ return Object.assign(
1081
+ { schemaVersion: 1, status: "drop", deliberate: true },
1082
+ typeof value.reason === "string" ? { reason: errorText(value.reason) } : {},
1083
+ );
1084
+ }
1085
+ return { schemaVersion: 1, status: "drop", reason: "the card handler drew nothing" };
1086
+ }
1087
+
1088
+ async function runRenderCard(invocation, resolve, contextFor) {
1089
+ try {
1090
+ const plugin = resolve(invocation.pluginId);
1091
+ const declared = plugin.cards.find(function (candidate) {
1092
+ return candidate.id === invocation.cardId;
1093
+ });
1094
+ if (!declared) {
1095
+ throw new Error('plugin "' + invocation.pluginId + '" did not declare card "' + invocation.cardId + '"');
1096
+ }
1097
+ const context = contextFor(invocation, plugin, invocation.deadlineMs);
1098
+ const value = await withIsolateDeadline(function () {
1099
+ return plugin.module.cards[invocation.cardId].render(
1100
+ { surfaceId: invocation.surfaceId, data: invocation.data },
1101
+ context,
1102
+ );
1103
+ }, invocation.deadlineMs);
1104
+ const answer = cardAnswer(value);
1105
+ // A draw has no line for the Bot; only a press does. The deliberate flag
1106
+ // stays: a draw that refused in as many words is charged nothing, and
1107
+ // everything else is charged to the Plugin's health.
1108
+ delete answer.input;
1109
+ // What this draw is about, in the Plugin's own words. A card that asks
1110
+ // for a decision and names none of these is refused at the seam, so the
1111
+ // decision a person gives can never cover values they were not shown.
1112
+ if (answer.status === "rendered" && isRecord(value) && isRecord(value.covers)) {
1113
+ answer.covers = value.covers;
1114
+ }
1115
+ // And what that decision asks, in the Plugin's own words. The catalog
1116
+ // allows the ApprovalActions component nothing but its id and its labels,
1117
+ // so the wording rides here and the kernel records the Approval with it.
1118
+ if (answer.status === "rendered" && isRecord(value) && isRecord(value.decision)) {
1119
+ answer.decision = value.decision;
1120
+ }
1121
+ return answer;
1122
+ } catch (error) {
1123
+ return { schemaVersion: 1, status: "drop", reason: errorText(error) };
1124
+ }
1125
+ }
1126
+
1127
+ async function runCardAction(invocation, resolve, contextFor) {
1128
+ try {
1129
+ const plugin = resolve(invocation.pluginId);
1130
+ const owner = plugin.cards.find(function (candidate) {
1131
+ return candidate.actions.includes(invocation.action);
1132
+ });
1133
+ if (owner === undefined) {
1134
+ throw new Error('plugin "' + invocation.pluginId + '" declares no card action "' + invocation.action + '"');
1135
+ }
1136
+ // The card the pressed surface was minted for, as the kernel read it off
1137
+ // the surface id. The handler is still the one that declared the action;
1138
+ // this only refuses a press whose surface belongs to a different card of
1139
+ // the same Plugin, which would otherwise hand one card's handler another
1140
+ // card's surface, data model and record.
1141
+ if (owner.id !== invocation.cardId) {
1142
+ throw new Error('plugin "' + invocation.pluginId + '" card "' + invocation.cardId + '" does not declare action "' + invocation.action + '"');
1143
+ }
1144
+ const cardId = owner.id;
1145
+ const context = contextFor(invocation, plugin, invocation.deadlineMs);
1146
+ const value = await withIsolateDeadline(function () {
1147
+ return plugin.module.cards[cardId].actions[invocation.action](
1148
+ {
1149
+ cardId: cardId,
1150
+ surfaceId: invocation.surfaceId,
1151
+ action: invocation.action,
1152
+ context: invocation.context,
1153
+ dataModel: invocation.dataModel,
1154
+ record: invocation.record,
1155
+ },
1156
+ context,
1157
+ );
1158
+ }, invocation.deadlineMs);
1159
+ return cardAnswer(value);
1160
+ } catch (error) {
1161
+ return { schemaVersion: 1, status: "drop", reason: errorText(error) };
1162
+ }
1163
+ }
1164
+
1165
+ async function runReviseCard(invocation, resolve, contextFor) {
1166
+ try {
1167
+ const plugin = resolve(invocation.pluginId);
1168
+ const declared = plugin.cards.find(function (candidate) {
1169
+ return candidate.id === invocation.cardId;
1170
+ });
1171
+ if (!declared) {
1172
+ throw new Error('plugin "' + invocation.pluginId + '" did not declare card "' + invocation.cardId + '"');
1173
+ }
1174
+ const card = plugin.module.cards[invocation.cardId];
1175
+ // A card that takes no edits is decided as it was drawn. Whatever its
1176
+ // fields hold is the person's answer to the Bot, not a change to what
1177
+ // the decision covers, and saying so costs the Plugin nothing.
1178
+ if (typeof card.revise !== "function") {
1179
+ return { schemaVersion: 1, status: "unchanged" };
1180
+ }
1181
+ const context = contextFor(invocation, plugin, invocation.deadlineMs);
1182
+ const value = await withIsolateDeadline(function () {
1183
+ return card.revise(
1184
+ {
1185
+ cardId: invocation.cardId,
1186
+ surfaceId: invocation.surfaceId,
1187
+ dataModel: invocation.dataModel,
1188
+ record: invocation.record,
1189
+ },
1190
+ context,
1191
+ );
1192
+ }, invocation.deadlineMs);
1193
+ if (isRecord(value) && value.drop === true) {
1194
+ return Object.assign(
1195
+ { schemaVersion: 1, status: "drop", deliberate: true },
1196
+ typeof value.reason === "string" ? { reason: errorText(value.reason) } : {},
1197
+ );
1198
+ }
1199
+ // A revision has to say what the decision now covers and in what words,
1200
+ // or there is nothing the kernel could bind the person's Send to.
1201
+ if (isRecord(value) && isRecord(value.covers) && isRecord(value.decision)) {
1202
+ return Object.assign(
1203
+ { schemaVersion: 1, status: "revised", covers: value.covers, decision: value.decision },
1204
+ Array.isArray(value.messages) && value.messages.length > 0 ? { messages: value.messages } : {},
1205
+ );
1206
+ }
1207
+ return { schemaVersion: 1, status: "drop", reason: "the card's revise named no covers or no decision" };
1208
+ } catch (error) {
1209
+ return { schemaVersion: 1, status: "drop", reason: errorText(error) };
1210
+ }
1211
+ }`;
1212
+
1213
+ /** The path a Plugin's artifact is mounted at inside the worker's module map. */
1214
+ export function pluginWorkerModulePathV1(pluginId: string): string {
1215
+ return `plugins/${pluginId}.js`;
1216
+ }
1217
+
1218
+ export const PLUGIN_WORKER_MAIN_MODULE = "index.js";
1219
+
1220
+ /**
1221
+ * The index module text for one set of Plugins, in mount order. The order is
1222
+ * the host's: providers before the Plugins that consume them.
1223
+ */
1224
+ export function pluginWorkerIndexSourceV1(
1225
+ pluginIds: readonly string[],
1226
+ ): string {
1227
+ const imports = pluginIds
1228
+ .map(
1229
+ (pluginId, index) =>
1230
+ `import * as plugin_${index} from ${JSON.stringify(`./${pluginWorkerModulePathV1(pluginId)}`)};`,
1231
+ )
1232
+ .join("\n");
1233
+ const modules = pluginIds
1234
+ .map((pluginId, index) => ` ${JSON.stringify(pluginId)}: plugin_${index},`)
1235
+ .join("\n");
1236
+ return `// Generated by @frockbot/frock-compose. Do not edit inside the isolate.
1237
+ import { WorkerEntrypoint } from "cloudflare:workers";
1238
+ ${imports}
1239
+
1240
+ const CONTRACT_VERSION = ${ISOLATE_CONTRACT_VERSION};
1241
+ const PLUGIN_MODULES = {
1242
+ ${modules}
1243
+ };
1244
+ const HOOK_VALUE_KEYS = ${JSON.stringify(BOT_ISOLATE_HOOK_VALUE_KEYS_V1)};
1245
+
1246
+ ${BOT_ISOLATE_DEADLINE_SOURCE}
1247
+
1248
+ ${BOT_ISOLATE_INVOCATION_SOURCE}
1249
+
1250
+ ${BOT_ISOLATE_MODEL_SOURCE}
1251
+
1252
+ ${BOT_ISOLATE_DECLARATION_SOURCE}
1253
+
1254
+ ${BOT_ISOLATE_NARROW_CONTEXT_SOURCE_V1}
1255
+
1256
+ ${BOT_ISOLATE_ERROR_TEXT_SOURCE}
1257
+
1258
+ ${BOT_ISOLATE_HOOK_CHAIN_SOURCE}
1259
+
1260
+ ${BOT_ISOLATE_TRIGGER_SOURCE}
1261
+
1262
+ ${BOT_ISOLATE_VIEW_SOURCE}
1263
+
1264
+ ${BOT_ISOLATE_CARD_SOURCE}
1265
+
1266
+ ${BOT_ISOLATE_MODEL_PROVIDER_SOURCE}
1267
+
1268
+ /**
1269
+ * Every Plugin the identity names, mounted once in identity order. A Plugin
1270
+ * whose module does not declare itself correctly is carried as not ok and
1271
+ * never invoked; the others still mount. A service a Plugin provides is read
1272
+ * from its module once here and handed to the Plugins mounted after it that
1273
+ * consume it, so a provider is always mounted before its consumers.
1274
+ */
1275
+ let mountedPlugins;
1276
+ function mountAll(env) {
1277
+ if (mountedPlugins) return mountedPlugins;
1278
+ const provided = {};
1279
+ mountedPlugins = (env.IDENTITY.plugins || []).map(function (identity) {
1280
+ const pluginId = identity.pluginId;
1281
+ const module = PLUGIN_MODULES[pluginId];
1282
+ const plugin = {
1283
+ pluginId: pluginId,
1284
+ grants: identity.grants || [],
1285
+ consumes: identity.consumes || [],
1286
+ module: module,
1287
+ ok: false,
1288
+ reason: undefined,
1289
+ tools: [],
1290
+ hooks: [],
1291
+ provides: [],
1292
+ triggers: [],
1293
+ modelProviders: [],
1294
+ views: [],
1295
+ cards: [],
1296
+ services: {},
1297
+ };
1298
+ try {
1299
+ if (!module) throw new Error('plugin "' + pluginId + '" has no module');
1300
+ plugin.tools = declaredTools(module, pluginId);
1301
+ plugin.hooks = declaredHooks(module, pluginId);
1302
+ plugin.triggers = declaredTriggers(module, pluginId);
1303
+ plugin.views = declaredViews(module, pluginId);
1304
+ plugin.cards = declaredCards(module, pluginId);
1305
+ plugin.modelProviders = declaredModelProviders(module, pluginId);
1306
+ const services = declaredServices(module, pluginId);
1307
+ plugin.provides = Object.keys(services);
1308
+ for (const name of plugin.consumes) {
1309
+ if (!Object.hasOwn(provided, name)) {
1310
+ throw new Error('plugin "' + pluginId + '" consumes "' + name + '", which no earlier plugin provides');
1311
+ }
1312
+ plugin.services[name] = provided[name];
1313
+ }
1314
+ for (const name of plugin.provides) provided[name] = services[name];
1315
+ plugin.ok = true;
1316
+ } catch (error) {
1317
+ plugin.reason = errorText(error);
1318
+ }
1319
+ return plugin;
1320
+ });
1321
+ return mountedPlugins;
1322
+ }
1323
+
1324
+ function findPlugin(env, pluginId) {
1325
+ const plugin = mountAll(env).find(function (candidate) {
1326
+ return candidate.pluginId === pluginId;
1327
+ });
1328
+ if (!plugin) throw new Error('plugin "' + pluginId + '" is not mounted');
1329
+ if (!plugin.ok) throw new Error('plugin "' + pluginId + '" failed to mount: ' + plugin.reason);
1330
+ return plugin;
1331
+ }
1332
+
1333
+ export default class extends WorkerEntrypoint {
1334
+ async health() {
1335
+ return {
1336
+ schemaVersion: 1,
1337
+ contractVersion: CONTRACT_VERSION,
1338
+ plugins: mountAll(this.env).map(function (plugin) {
1339
+ return Object.assign(
1340
+ {
1341
+ pluginId: plugin.pluginId,
1342
+ ok: plugin.ok,
1343
+ tools: plugin.tools,
1344
+ hooks: plugin.hooks,
1345
+ provides: plugin.provides.map(function (name) {
1346
+ return { name: name, version: 1 };
1347
+ }),
1348
+ consumes: plugin.consumes.map(function (name) {
1349
+ return { name: name, version: 1 };
1350
+ }),
1351
+ triggers: plugin.triggers,
1352
+ modelProviders: plugin.modelProviders,
1353
+ views: plugin.views,
1354
+ cards: plugin.cards,
1355
+ },
1356
+ plugin.ok ? {} : { reason: plugin.reason },
1357
+ );
1358
+ }),
1359
+ };
1360
+ }
1361
+
1362
+ async execute(rawInvocation) {
1363
+ let invocation;
1364
+ try {
1365
+ invocation = decodeInvocation(rawInvocation);
1366
+ } catch (error) {
1367
+ return { schemaVersion: 1, content: errorText(error), isError: true };
1368
+ }
1369
+ try {
1370
+ const plugin = findPlugin(this.env, invocation.pluginId);
1371
+ const context = narrowContext(this.env, invocation, plugin, invocation.deadlineMs);
1372
+ const value = await withIsolateDeadline(function () {
1373
+ return plugin.module.execute(invocation.tool, invocation.input, context);
1374
+ }, invocation.deadlineMs);
1375
+ return {
1376
+ schemaVersion: 1,
1377
+ content: typeof value === "string" ? value : JSON.stringify(value ?? null),
1378
+ isError: false,
1379
+ };
1380
+ } catch (error) {
1381
+ return { schemaVersion: 1, content: errorText(error), isError: true };
1382
+ }
1383
+ }
1384
+
1385
+ /**
1386
+ * One model call, served by the Plugin that declared the provider
1387
+ * (ADR 0032). The answer's events are an NDJSON byte stream; a provider
1388
+ * that refuses before reaching upstream answers with its reason.
1389
+ */
1390
+ async streamModel(rawInvocation) {
1391
+ let invocation;
1392
+ try {
1393
+ invocation = decodeModelInvocation(rawInvocation);
1394
+ } catch (error) {
1395
+ return { schemaVersion: 1, status: "refused", reason: errorText(error) };
1396
+ }
1397
+ const env = this.env;
1398
+ return runModelStream(
1399
+ invocation,
1400
+ function (pluginId) {
1401
+ return findPlugin(env, pluginId);
1402
+ },
1403
+ function (identity, plugin, deadlineMs, transportId) {
1404
+ return narrowContext(env, identity, plugin, deadlineMs, transportId);
1405
+ },
1406
+ );
1407
+ }
1408
+
1409
+ /**
1410
+ * One call per open hook per Turn. The enabled Plugins that declared the
1411
+ * event run in mount order; each sees the value the one before it left,
1412
+ * and a Plugin that throws, times out or answers with something that is
1413
+ * not a value is skipped and named, never allowed to stop the chain.
1414
+ */
1415
+ async hook(rawInvocation) {
1416
+ const invocation = decodeHookInvocation(rawInvocation);
1417
+ const env = this.env;
1418
+ return runHookChain(mountAll(env), invocation, function (plugin, deadlineMs) {
1419
+ return narrowContext(env, invocation, plugin, deadlineMs);
1420
+ });
1421
+ }
1422
+
1423
+ async receiveTrigger(rawInvocation) {
1424
+ const invocation = decodeTriggerInvocation(rawInvocation);
1425
+ const env = this.env;
1426
+ return runTrigger(
1427
+ invocation,
1428
+ function (pluginId) {
1429
+ return findPlugin(env, pluginId);
1430
+ },
1431
+ function (identity, plugin, deadlineMs) {
1432
+ return narrowContext(env, identity, plugin, deadlineMs);
1433
+ },
1434
+ );
1435
+ }
1436
+
1437
+ /**
1438
+ * One press on a Card this Plugin drew (ADR 0030). The handler answers with
1439
+ * the messages the kernel folds; a handler that throws, overruns or answers
1440
+ * with anything else is a drop and the Card is left exactly as it was.
1441
+ */
1442
+ async cardAction(rawInvocation) {
1443
+ let invocation;
1444
+ try {
1445
+ invocation = decodeCardActionInvocation(rawInvocation);
1446
+ } catch (error) {
1447
+ return { schemaVersion: 1, status: "drop", reason: errorText(error) };
1448
+ }
1449
+ const env = this.env;
1450
+ return runCardAction(
1451
+ invocation,
1452
+ function (pluginId) {
1453
+ return findPlugin(env, pluginId);
1454
+ },
1455
+ function (identity, plugin, deadlineMs) {
1456
+ return narrowContext(env, identity, plugin, deadlineMs);
1457
+ },
1458
+ );
1459
+ }
1460
+
1461
+ /** One Card drawn from the values the Bot sent to its card tool. */
1462
+ async renderCard(rawInvocation) {
1463
+ let invocation;
1464
+ try {
1465
+ invocation = decodeRenderCardInvocation(rawInvocation);
1466
+ } catch (error) {
1467
+ return { schemaVersion: 1, status: "drop", reason: errorText(error) };
1468
+ }
1469
+ const env = this.env;
1470
+ return runRenderCard(
1471
+ invocation,
1472
+ function (pluginId) {
1473
+ return findPlugin(env, pluginId);
1474
+ },
1475
+ function (identity, plugin, deadlineMs) {
1476
+ return narrowContext(env, identity, plugin, deadlineMs);
1477
+ },
1478
+ );
1479
+ }
1480
+
1481
+ /**
1482
+ * One card a person edited and then approved: its Plugin restates what the
1483
+ * decision now covers before the kernel records it.
1484
+ */
1485
+ async reviseCard(rawInvocation) {
1486
+ let invocation;
1487
+ try {
1488
+ invocation = decodeReviseCardInvocation(rawInvocation);
1489
+ } catch (error) {
1490
+ return { schemaVersion: 1, status: "drop", reason: errorText(error) };
1491
+ }
1492
+ const env = this.env;
1493
+ return runReviseCard(
1494
+ invocation,
1495
+ function (pluginId) {
1496
+ return findPlugin(env, pluginId);
1497
+ },
1498
+ function (identity, plugin, deadlineMs) {
1499
+ return narrowContext(env, identity, plugin, deadlineMs);
1500
+ },
1501
+ );
1502
+ }
1503
+
1504
+ async view(rawInvocation) {
1505
+ let invocation;
1506
+ try {
1507
+ invocation = decodeViewInvocation(rawInvocation);
1508
+ } catch (error) {
1509
+ return { schemaVersion: 1, status: "drop", reason: errorText(error) };
1510
+ }
1511
+ const env = this.env;
1512
+ return runView(
1513
+ invocation,
1514
+ function (pluginId) {
1515
+ return findPlugin(env, pluginId);
1516
+ },
1517
+ function (identity, plugin, deadlineMs) {
1518
+ return narrowContext(env, identity, plugin, deadlineMs);
1519
+ },
1520
+ );
1521
+ }
1522
+ }
1523
+ `;
1524
+ }
1525
+
1526
+ /**
1527
+ * Bumped with any change to the generated text; folded into the module-set
1528
+ * hash beside the contract version, so a wrapper change is a new worker.
1529
+ */
1530
+ export const PLUGIN_WORKER_INDEX_VERSION = "index-v12";
1531
+
1532
+ /** The module map a Plugin worker mounts: the index and one module per Plugin. */
1533
+ export function pluginWorkerModuleMap(
1534
+ plugins: readonly { pluginId: string; source: string }[],
1535
+ ): Record<string, { js: string }> {
1536
+ const modules: Record<string, { js: string }> = {
1537
+ [PLUGIN_WORKER_MAIN_MODULE]: {
1538
+ js: pluginWorkerIndexSourceV1(plugins.map((plugin) => plugin.pluginId)),
1539
+ },
1540
+ };
1541
+ for (const plugin of plugins) {
1542
+ modules[pluginWorkerModulePathV1(plugin.pluginId)] = { js: plugin.source };
1543
+ }
1544
+ return modules;
1545
+ }