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