@frockbot/kernel-composition 0.1.4 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/compiler.ts +8 -2
- package/src/generation.test.ts +55 -0
- package/src/generation.ts +111 -7
- package/src/index.test.ts +142 -0
- package/src/isolate-host.test.ts +341 -53
- package/src/isolate-host.ts +308 -26
- package/src/isolate-wrapper.test.ts +35 -0
- package/src/isolate-wrapper.ts +155 -32
- package/src/manifest.ts +271 -29
package/src/isolate-host.ts
CHANGED
|
@@ -5,21 +5,30 @@
|
|
|
5
5
|
// It sits beside `LocalCordisContributionHost` because it is the *other*
|
|
6
6
|
// execution host the constitution names — first-party Packages run in the
|
|
7
7
|
// kernel isolate, everything else runs in a loaded Worker with
|
|
8
|
-
// `globalOutbound` disabled and only
|
|
8
|
+
// `globalOutbound` disabled and only the Bot's authority bindings.
|
|
9
9
|
//
|
|
10
10
|
// Two behaviours come straight from `docs/research/spike-worker-loader-from-do.md`:
|
|
11
11
|
// `.get()` never throws, so mount and `health()` are a single guarded phase;
|
|
12
12
|
// and a reused loader id silently serves the first code, so the id is nothing
|
|
13
|
-
// but the content address of the
|
|
13
|
+
// but the content address of the module set actually mounted.
|
|
14
14
|
import {
|
|
15
|
+
decodeBotIsolateHookReplacementV1,
|
|
15
16
|
decodeIsolateHealthV1,
|
|
17
|
+
decodeIsolateHookResultV1,
|
|
16
18
|
decodeIsolateToolResultV1,
|
|
17
19
|
isolateToolSchemaV1,
|
|
18
20
|
ISOLATE_MAX_DEADLINE_MS,
|
|
19
21
|
isolateLoaderIdV1,
|
|
20
22
|
type BotCapabilitiesStub,
|
|
21
23
|
type BotIsolateEntrypoint,
|
|
24
|
+
type BotIsolateHookEventNameV1,
|
|
22
25
|
type IsolateHealthV1,
|
|
26
|
+
type IsolateHookInvocationV1,
|
|
27
|
+
type LoopAgentRuntimeV1,
|
|
28
|
+
type LoopEventPayloadMapV1,
|
|
29
|
+
type LoopEventReturnMapV1,
|
|
30
|
+
type LoopStepSnapshotV1,
|
|
31
|
+
loopToolExecutionContextSnapshotV1,
|
|
23
32
|
type IsolateToolDescriptorV1,
|
|
24
33
|
type IsolateToolInvocationV1,
|
|
25
34
|
type ToolDefinition,
|
|
@@ -28,6 +37,7 @@ import {
|
|
|
28
37
|
type ToolRegistration,
|
|
29
38
|
type TurnTypeV1,
|
|
30
39
|
} from "@frockbot/kernel-contracts";
|
|
40
|
+
import type { Context } from "cordis";
|
|
31
41
|
import { CompositionMountFailureError } from "./activation.ts";
|
|
32
42
|
import { canonicalJson, sha256 } from "./compiler.ts";
|
|
33
43
|
import type { CompositionMemberV1 } from "./generation.ts";
|
|
@@ -81,13 +91,19 @@ export interface BotIsolateHostOptions {
|
|
|
81
91
|
loader: BotIsolateLoader;
|
|
82
92
|
artifacts: BotIsolateArtifactStore;
|
|
83
93
|
/** Where the isolate's tools are registered — the kernel's tool surface. */
|
|
84
|
-
tools: ToolRegistration
|
|
94
|
+
tools: Pick<ToolRegistration, "register">;
|
|
95
|
+
/** The mounted Bot/generation root. Isolate hook listeners live only here. */
|
|
96
|
+
loop: Context;
|
|
85
97
|
userId: string;
|
|
86
98
|
botId: string;
|
|
87
99
|
sessionId: string;
|
|
88
100
|
runId: string;
|
|
89
101
|
turnId: string;
|
|
90
102
|
generationId: string;
|
|
103
|
+
turnType: TurnTypeV1;
|
|
104
|
+
subagentRole?: string;
|
|
105
|
+
/** Persists a visible failure without letting a broken hook wedge the loop. */
|
|
106
|
+
recordHookFailure(failure: IsolateHookFailureV1): Promise<void>;
|
|
91
107
|
/**
|
|
92
108
|
* The loopback service binding minted with
|
|
93
109
|
* `ctx.exports.BotCapabilities({ props })`. Opaque to the kernel: it only
|
|
@@ -95,11 +111,11 @@ export interface BotIsolateHostOptions {
|
|
|
95
111
|
*/
|
|
96
112
|
capabilities: BotCapabilitiesStub;
|
|
97
113
|
/**
|
|
98
|
-
* A content address of
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* isolate
|
|
114
|
+
* A content address of the Bot authority bindings this isolate is
|
|
115
|
+
* loaded with. Required, and part of the loader id, because a loader id is
|
|
116
|
+
* served from cache: the `env` a Bot isolate was first loaded with is the
|
|
117
|
+
* `env` it keeps, so a change in the Bot's Connections must produce a new
|
|
118
|
+
* isolate or the isolate would keep answering from a revoked authority.
|
|
103
119
|
*/
|
|
104
120
|
bindingDigest: string;
|
|
105
121
|
compatibilityDate: string;
|
|
@@ -110,6 +126,13 @@ export interface BotIsolateHostOptions {
|
|
|
110
126
|
healthDeadlineMs?: number;
|
|
111
127
|
}
|
|
112
128
|
|
|
129
|
+
export interface IsolateHookFailureV1 {
|
|
130
|
+
packageId: string;
|
|
131
|
+
event: BotIsolateHookEventNameV1;
|
|
132
|
+
generationId: string;
|
|
133
|
+
message: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
113
136
|
export const BOT_ISOLATE_DEFAULT_LIMITS: BotIsolateLimits = {
|
|
114
137
|
cpuMs: 5_000,
|
|
115
138
|
subRequests: 5,
|
|
@@ -120,8 +143,8 @@ export const BOT_ISOLATE_DEFAULT_HEALTH_DEADLINE_MS = 10_000;
|
|
|
120
143
|
|
|
121
144
|
/**
|
|
122
145
|
* The content address of what a Bot isolate mounts: the kernel wrapper text,
|
|
123
|
-
* the Package artifact, and the digest of
|
|
124
|
-
* A change to any of the three is a new isolate.
|
|
146
|
+
* the Package artifact, and the digest of the Bot authority bindings it
|
|
147
|
+
* is loaded with. A change to any of the three is a new isolate.
|
|
125
148
|
*/
|
|
126
149
|
export async function botIsolateModuleSetHashV1(
|
|
127
150
|
artifactContentHash: string,
|
|
@@ -195,26 +218,34 @@ export function botIsolateSubagentRoleCeilingV1(
|
|
|
195
218
|
}
|
|
196
219
|
|
|
197
220
|
/**
|
|
198
|
-
* A Composition member projected onto the descriptor
|
|
199
|
-
* consumes.
|
|
200
|
-
*
|
|
201
|
-
* Package identity, its specifier, and its immutable artifact.
|
|
221
|
+
* A Composition member and its stored manifest projected onto the descriptor
|
|
222
|
+
* a contribution host consumes. Hash and identity checks happen here so no
|
|
223
|
+
* mount caller can replace the durable manifest with a synthesized one.
|
|
202
224
|
*/
|
|
203
|
-
export function botIsolatePackageDescriptorV1(
|
|
225
|
+
export async function botIsolatePackageDescriptorV1(
|
|
204
226
|
member: CompositionMemberV1,
|
|
205
|
-
|
|
227
|
+
storedManifest: unknown,
|
|
228
|
+
): Promise<PackageDescriptor> {
|
|
229
|
+
const manifestHash = await sha256(canonicalJson(storedManifest));
|
|
230
|
+
if (manifestHash !== member.manifestHash) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`package "${member.packageId}" stored manifest failed hash verification`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const manifest = decodeFrockBotManifest(storedManifest);
|
|
236
|
+
if (manifest.id !== member.packageId || manifest.version !== member.version) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`package "${member.packageId}" stored manifest does not match its Composition member`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (manifest.contributions.runtime?.host !== "bot-isolate") {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`package "${member.packageId}" manifest declares no Bot isolate runtime`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
206
246
|
return {
|
|
207
247
|
specifier: member.specifier,
|
|
208
|
-
manifest
|
|
209
|
-
schemaVersion: 3,
|
|
210
|
-
id: member.packageId,
|
|
211
|
-
displayName: member.packageId,
|
|
212
|
-
version: member.version,
|
|
213
|
-
compatibility: { frockbot: `^${member.version}` },
|
|
214
|
-
dependencies: {},
|
|
215
|
-
contributions: { runtime: { entry: "./package.js" } },
|
|
216
|
-
permissions: [],
|
|
217
|
-
}),
|
|
248
|
+
manifest,
|
|
218
249
|
...(member.artifact ? { artifact: member.artifact } : {}),
|
|
219
250
|
};
|
|
220
251
|
}
|
|
@@ -288,6 +319,38 @@ export class BotIsolateContributionHost implements ContributionHost {
|
|
|
288
319
|
[`reported:${health.packageId}`],
|
|
289
320
|
);
|
|
290
321
|
}
|
|
322
|
+
const declaredTools = (pkg.manifest.tools ?? [])
|
|
323
|
+
.map((tool) => tool.name)
|
|
324
|
+
.toSorted();
|
|
325
|
+
const reportedTools = health.tools.map((tool) => tool.name).toSorted();
|
|
326
|
+
if (
|
|
327
|
+
declaredTools.length !== reportedTools.length ||
|
|
328
|
+
declaredTools.some((name, index) => name !== reportedTools[index])
|
|
329
|
+
) {
|
|
330
|
+
throw new CompositionMountFailureError(
|
|
331
|
+
"health",
|
|
332
|
+
`package "${packageId}" isolate tools do not match its stored manifest`,
|
|
333
|
+
[
|
|
334
|
+
`declared:${declaredTools.join(",")}`,
|
|
335
|
+
`reported:${reportedTools.join(",")}`,
|
|
336
|
+
],
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
const declaredHooks = (pkg.manifest.hooks ?? []).toSorted();
|
|
340
|
+
const reportedHooks = (health.hooks ?? []).toSorted();
|
|
341
|
+
if (
|
|
342
|
+
declaredHooks.length !== reportedHooks.length ||
|
|
343
|
+
declaredHooks.some((name, index) => name !== reportedHooks[index])
|
|
344
|
+
) {
|
|
345
|
+
throw new CompositionMountFailureError(
|
|
346
|
+
"health",
|
|
347
|
+
`package "${packageId}" isolate hooks do not match its stored manifest`,
|
|
348
|
+
[
|
|
349
|
+
`declared:${declaredHooks.join(",")}`,
|
|
350
|
+
`reported:${reportedHooks.join(",")}`,
|
|
351
|
+
],
|
|
352
|
+
);
|
|
353
|
+
}
|
|
291
354
|
|
|
292
355
|
let disposed = false;
|
|
293
356
|
const registered: (() => void)[] = [];
|
|
@@ -315,6 +378,9 @@ export class BotIsolateContributionHost implements ContributionHost {
|
|
|
315
378
|
),
|
|
316
379
|
);
|
|
317
380
|
}
|
|
381
|
+
for (const event of health.hooks ?? []) {
|
|
382
|
+
registered.push(this.registerHook(packageId, entrypoint, event));
|
|
383
|
+
}
|
|
318
384
|
return Promise.resolve({
|
|
319
385
|
dispose: () => {
|
|
320
386
|
if (disposed) return Promise.resolve();
|
|
@@ -369,6 +435,222 @@ export class BotIsolateContributionHost implements ContributionHost {
|
|
|
369
435
|
);
|
|
370
436
|
}
|
|
371
437
|
|
|
438
|
+
private agentSnapshot(agent: LoopAgentRuntimeV1) {
|
|
439
|
+
return {
|
|
440
|
+
botId: agent.botId,
|
|
441
|
+
agentId: agent.id,
|
|
442
|
+
sessionId: agent.session.id,
|
|
443
|
+
status: agent.status,
|
|
444
|
+
} as const;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private stepSnapshot(
|
|
448
|
+
agent: LoopAgentRuntimeV1,
|
|
449
|
+
turn: number,
|
|
450
|
+
step: number,
|
|
451
|
+
): LoopStepSnapshotV1 {
|
|
452
|
+
return {
|
|
453
|
+
...this.agentSnapshot(agent),
|
|
454
|
+
compositionGenerationId: this.options.generationId,
|
|
455
|
+
turn,
|
|
456
|
+
step,
|
|
457
|
+
turnType: this.options.turnType,
|
|
458
|
+
...(this.options.subagentRole === undefined
|
|
459
|
+
? {}
|
|
460
|
+
: { subagentRole: this.options.subagentRole }),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private registerHook(
|
|
465
|
+
packageId: string,
|
|
466
|
+
entrypoint: BotIsolateEntrypoint,
|
|
467
|
+
event: BotIsolateHookEventNameV1,
|
|
468
|
+
): () => void {
|
|
469
|
+
const root = this.options.loop;
|
|
470
|
+
switch (event) {
|
|
471
|
+
case "agent/pre-step":
|
|
472
|
+
return root.on(event, async (agent, _inputs, turn, step, next) => {
|
|
473
|
+
const current = await next();
|
|
474
|
+
if (agent.botId !== this.options.botId) return current;
|
|
475
|
+
return this.invokeHook(
|
|
476
|
+
packageId,
|
|
477
|
+
entrypoint,
|
|
478
|
+
event,
|
|
479
|
+
{
|
|
480
|
+
step: this.stepSnapshot(agent, turn, step),
|
|
481
|
+
inputs: current.kind === "enter" ? current.inputs : _inputs,
|
|
482
|
+
decision: current,
|
|
483
|
+
},
|
|
484
|
+
current,
|
|
485
|
+
);
|
|
486
|
+
});
|
|
487
|
+
case "system-prompt/assemble":
|
|
488
|
+
return root.on(event, async (context, next) => {
|
|
489
|
+
const current = await next();
|
|
490
|
+
return this.invokeHook(
|
|
491
|
+
packageId,
|
|
492
|
+
entrypoint,
|
|
493
|
+
event,
|
|
494
|
+
{ context: structuredClone(context), assembly: current },
|
|
495
|
+
current,
|
|
496
|
+
);
|
|
497
|
+
});
|
|
498
|
+
case "agent/message-window":
|
|
499
|
+
return root.on(
|
|
500
|
+
event,
|
|
501
|
+
async (agent, _messages, turn, step, signal, next) => {
|
|
502
|
+
const current = await next();
|
|
503
|
+
if (agent.botId !== this.options.botId) return current;
|
|
504
|
+
return this.invokeHook(
|
|
505
|
+
packageId,
|
|
506
|
+
entrypoint,
|
|
507
|
+
event,
|
|
508
|
+
{
|
|
509
|
+
step: this.stepSnapshot(agent, turn, step),
|
|
510
|
+
messages: current,
|
|
511
|
+
},
|
|
512
|
+
current,
|
|
513
|
+
signal,
|
|
514
|
+
);
|
|
515
|
+
},
|
|
516
|
+
);
|
|
517
|
+
case "agent/tool-exposure":
|
|
518
|
+
return root.on(
|
|
519
|
+
event,
|
|
520
|
+
async (agent, _tools, turn, step, signal, next) => {
|
|
521
|
+
const current = await next();
|
|
522
|
+
if (agent.botId !== this.options.botId) return current;
|
|
523
|
+
return this.invokeHook(
|
|
524
|
+
packageId,
|
|
525
|
+
entrypoint,
|
|
526
|
+
event,
|
|
527
|
+
{ step: this.stepSnapshot(agent, turn, step), tools: current },
|
|
528
|
+
current,
|
|
529
|
+
signal,
|
|
530
|
+
);
|
|
531
|
+
},
|
|
532
|
+
);
|
|
533
|
+
case "tools/pre-execute":
|
|
534
|
+
return root.on(event, async (call, context, next) => {
|
|
535
|
+
const current = await next();
|
|
536
|
+
if (
|
|
537
|
+
context.botId !== this.options.botId ||
|
|
538
|
+
context.compositionGenerationId !== this.options.generationId
|
|
539
|
+
) {
|
|
540
|
+
return current;
|
|
541
|
+
}
|
|
542
|
+
return this.invokeHook(
|
|
543
|
+
packageId,
|
|
544
|
+
entrypoint,
|
|
545
|
+
event,
|
|
546
|
+
{
|
|
547
|
+
call,
|
|
548
|
+
context: loopToolExecutionContextSnapshotV1(context),
|
|
549
|
+
preparation: current,
|
|
550
|
+
},
|
|
551
|
+
current,
|
|
552
|
+
context.signal,
|
|
553
|
+
);
|
|
554
|
+
});
|
|
555
|
+
case "tools/post-execute":
|
|
556
|
+
return root.on(event, async (call, _result, context, next) => {
|
|
557
|
+
const current = await next();
|
|
558
|
+
if (
|
|
559
|
+
context.botId !== this.options.botId ||
|
|
560
|
+
context.compositionGenerationId !== this.options.generationId
|
|
561
|
+
) {
|
|
562
|
+
return current;
|
|
563
|
+
}
|
|
564
|
+
return this.invokeHook(
|
|
565
|
+
packageId,
|
|
566
|
+
entrypoint,
|
|
567
|
+
event,
|
|
568
|
+
{
|
|
569
|
+
call,
|
|
570
|
+
context: loopToolExecutionContextSnapshotV1(context),
|
|
571
|
+
result: current,
|
|
572
|
+
},
|
|
573
|
+
current,
|
|
574
|
+
context.signal,
|
|
575
|
+
);
|
|
576
|
+
});
|
|
577
|
+
case "agent/step-continuation":
|
|
578
|
+
return root.on(
|
|
579
|
+
event,
|
|
580
|
+
async (agent, _decision, turn, step, signal, next) => {
|
|
581
|
+
const current = await next();
|
|
582
|
+
if (agent.botId !== this.options.botId) return current;
|
|
583
|
+
return this.invokeHook(
|
|
584
|
+
packageId,
|
|
585
|
+
entrypoint,
|
|
586
|
+
event,
|
|
587
|
+
{
|
|
588
|
+
step: this.stepSnapshot(agent, turn, step),
|
|
589
|
+
decision: current,
|
|
590
|
+
},
|
|
591
|
+
current,
|
|
592
|
+
signal,
|
|
593
|
+
);
|
|
594
|
+
},
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private async invokeHook<Event extends BotIsolateHookEventNameV1>(
|
|
600
|
+
packageId: string,
|
|
601
|
+
entrypoint: BotIsolateEntrypoint,
|
|
602
|
+
event: Event,
|
|
603
|
+
payload: LoopEventPayloadMapV1[Event],
|
|
604
|
+
original: LoopEventReturnMapV1[Event],
|
|
605
|
+
signal?: AbortSignal,
|
|
606
|
+
): Promise<LoopEventReturnMapV1[Event]> {
|
|
607
|
+
const deadlineMs = Math.min(
|
|
608
|
+
this.options.deadlineMs ?? BOT_ISOLATE_DEFAULT_DEADLINE_MS,
|
|
609
|
+
ISOLATE_MAX_DEADLINE_MS,
|
|
610
|
+
);
|
|
611
|
+
try {
|
|
612
|
+
const invocation: IsolateHookInvocationV1<Event> = {
|
|
613
|
+
schemaVersion: 1,
|
|
614
|
+
event,
|
|
615
|
+
payload: structuredClone(payload),
|
|
616
|
+
botId: this.options.botId,
|
|
617
|
+
sessionId: this.options.sessionId,
|
|
618
|
+
runId: this.options.runId,
|
|
619
|
+
turnId: this.options.turnId,
|
|
620
|
+
generationId: this.options.generationId,
|
|
621
|
+
deadlineMs,
|
|
622
|
+
};
|
|
623
|
+
const result = decodeIsolateHookResultV1(
|
|
624
|
+
await raceDeadline(
|
|
625
|
+
() => entrypoint.hook(invocation),
|
|
626
|
+
deadlineMs,
|
|
627
|
+
signal,
|
|
628
|
+
),
|
|
629
|
+
`package "${packageId}" isolate hook result`,
|
|
630
|
+
);
|
|
631
|
+
if (result.status === "unchanged") return original;
|
|
632
|
+
return decodeBotIsolateHookReplacementV1(
|
|
633
|
+
event,
|
|
634
|
+
result.replacement,
|
|
635
|
+
original,
|
|
636
|
+
);
|
|
637
|
+
} catch (error) {
|
|
638
|
+
const message = errorMessage(error).slice(0, 2_048);
|
|
639
|
+
try {
|
|
640
|
+
await this.options.recordHookFailure({
|
|
641
|
+
packageId,
|
|
642
|
+
event,
|
|
643
|
+
generationId: this.options.generationId,
|
|
644
|
+
message,
|
|
645
|
+
});
|
|
646
|
+
} catch {
|
|
647
|
+
// Failure recording is itself an external durability boundary. A
|
|
648
|
+
// broken hook still cannot wedge the loop if that boundary is down.
|
|
649
|
+
}
|
|
650
|
+
return original;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
372
654
|
private definition(
|
|
373
655
|
packageId: string,
|
|
374
656
|
entrypoint: BotIsolateEntrypoint,
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { BOT_ISOLATE_CONTEXT_KEYS_V1 } from "@frockbot/kernel-contracts";
|
|
2
3
|
import {
|
|
3
4
|
BOT_ISOLATE_DEADLINE_SOURCE,
|
|
4
5
|
BOT_ISOLATE_INVOCATION_SOURCE,
|
|
5
6
|
BOT_ISOLATE_MAIN_MODULE,
|
|
7
|
+
BOT_ISOLATE_NARROW_CONTEXT_KEYS_V1,
|
|
6
8
|
BOT_ISOLATE_PACKAGE_MODULE,
|
|
7
9
|
BOT_ISOLATE_WRAPPER_SOURCE,
|
|
8
10
|
botIsolateModuleMap,
|
|
@@ -21,6 +23,9 @@ type DecodeInvocation = (value: unknown) => unknown;
|
|
|
21
23
|
const decodeInvocation = new Function(
|
|
22
24
|
`${BOT_ISOLATE_INVOCATION_SOURCE}\nreturn decodeInvocation;`,
|
|
23
25
|
)() as DecodeInvocation;
|
|
26
|
+
const decodeHookInvocation = new Function(
|
|
27
|
+
`${BOT_ISOLATE_INVOCATION_SOURCE}\nreturn decodeHookInvocation;`,
|
|
28
|
+
)() as DecodeInvocation;
|
|
24
29
|
|
|
25
30
|
function invocation(overrides: Record<string, unknown> = {}) {
|
|
26
31
|
return {
|
|
@@ -84,6 +89,25 @@ describe("the generated wrapper's invocation decoder", () => {
|
|
|
84
89
|
});
|
|
85
90
|
});
|
|
86
91
|
|
|
92
|
+
test("accepts only a public waterfall hook invocation", () => {
|
|
93
|
+
const hook = {
|
|
94
|
+
...invocation(),
|
|
95
|
+
event: "agent/tool-exposure",
|
|
96
|
+
payload: { tools: [] },
|
|
97
|
+
} as Record<string, unknown>;
|
|
98
|
+
delete hook.tool;
|
|
99
|
+
delete hook.input;
|
|
100
|
+
expect(decodeHookInvocation(hook)).toMatchObject({
|
|
101
|
+
event: "agent/tool-exposure",
|
|
102
|
+
});
|
|
103
|
+
expect(() =>
|
|
104
|
+
decodeHookInvocation({ ...hook, event: "agent/request" }),
|
|
105
|
+
).toThrow(/unsupported/);
|
|
106
|
+
expect(() =>
|
|
107
|
+
decodeHookInvocation({ ...hook, signal: "live AbortSignal" }),
|
|
108
|
+
).toThrow(/invalid fields/);
|
|
109
|
+
});
|
|
110
|
+
|
|
87
111
|
test("refuses an invocation carrying an undeclared field", () => {
|
|
88
112
|
expect(() =>
|
|
89
113
|
decodeInvocation(invocation({ capabilities: ["models:invoke"] })),
|
|
@@ -99,6 +123,12 @@ describe("the generated wrapper's invocation decoder", () => {
|
|
|
99
123
|
});
|
|
100
124
|
|
|
101
125
|
describe("the generated wrapper module map", () => {
|
|
126
|
+
test("the wrapper context keys equal the generated contract catalog", () => {
|
|
127
|
+
expect(BOT_ISOLATE_NARROW_CONTEXT_KEYS_V1).toEqual([
|
|
128
|
+
...BOT_ISOLATE_CONTEXT_KEYS_V1,
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
102
132
|
test("is exactly two entries", () => {
|
|
103
133
|
const modules = botIsolateModuleMap("export const tools = [];");
|
|
104
134
|
expect(Object.keys(modules).sort()).toEqual([
|
|
@@ -121,5 +151,10 @@ describe("the generated wrapper module map", () => {
|
|
|
121
151
|
expect(BOT_ISOLATE_WRAPPER_SOURCE).toContain(
|
|
122
152
|
"async execute(rawInvocation)",
|
|
123
153
|
);
|
|
154
|
+
expect(BOT_ISOLATE_WRAPPER_SOURCE).toContain("async hook(rawInvocation)");
|
|
155
|
+
expect(BOT_ISOLATE_WRAPPER_SOURCE).toContain("hooks: declaredHooks()");
|
|
156
|
+
expect(BOT_ISOLATE_WRAPPER_SOURCE).toContain(
|
|
157
|
+
"return capabilities.schedule(request);",
|
|
158
|
+
);
|
|
124
159
|
});
|
|
125
160
|
});
|