@frockbot/frock-compose 0.0.0 → 0.7.291
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/README.md +22 -2
- package/index.ts +18 -0
- package/package.json +33 -5
- package/plugin-worker-host.ts +2152 -0
- package/plugin-worker-wrapper.ts +1545 -0
|
@@ -0,0 +1,2152 @@
|
|
|
1
|
+
// The Plugin worker host: mounts every Plugin a Composition generation names
|
|
2
|
+
// as one Dynamic Worker and registers the tools and hooks its health report
|
|
3
|
+
// declares.
|
|
4
|
+
//
|
|
5
|
+
// First-party code is ordinary imports in the kernel isolate; everything else
|
|
6
|
+
// runs in one loaded Worker per User with only the loopback bindings the Bot's
|
|
7
|
+
// authority grants — `globalOutbound` among them, bound to the egress loopback
|
|
8
|
+
// when the enabled Plugins declared network and null when they did not — and
|
|
9
|
+
// this is what loads it.
|
|
10
|
+
//
|
|
11
|
+
// Two loader behaviours are load-bearing here: `.get()` never throws, so mount
|
|
12
|
+
// and `health()` are a single guarded phase; and a reused loader id silently
|
|
13
|
+
// serves the first code, so the id is nothing but the content address of the
|
|
14
|
+
// module set actually mounted.
|
|
15
|
+
import {
|
|
16
|
+
decodeBotIsolateHookReplacementV1,
|
|
17
|
+
decodeIsolateToolResultV1,
|
|
18
|
+
decodePluginWorkerHealthV1,
|
|
19
|
+
decodePluginWorkerHookResultV1,
|
|
20
|
+
decodePluginWorkerTriggerResultV1,
|
|
21
|
+
decodePluginWorkerCardActionResultV1,
|
|
22
|
+
decodePluginWorkerRenderCardResultV1,
|
|
23
|
+
decodePluginWorkerReviseCardResultV1,
|
|
24
|
+
decodePluginWorkerViewResultV1,
|
|
25
|
+
isolateToolSchemaV1,
|
|
26
|
+
ISOLATE_CONTRACT_VERSION,
|
|
27
|
+
ISOLATE_MAX_DEADLINE_MS,
|
|
28
|
+
MAX_FAILURE_REASON_V1,
|
|
29
|
+
MAX_TRIGGER_BODY_BYTES_V1,
|
|
30
|
+
pluginWorkerUtf8LengthV1,
|
|
31
|
+
pluginWorkerLoaderIdV1,
|
|
32
|
+
pluginWorkerModuleSetHashV1,
|
|
33
|
+
type BotCapabilitiesStub,
|
|
34
|
+
type BotIsolateEnv,
|
|
35
|
+
type FirstPartySecretRequestV1,
|
|
36
|
+
type BotIsolateHookEventNameV1,
|
|
37
|
+
type IsolateIdentityV1,
|
|
38
|
+
type IsolateToolDescriptorV1,
|
|
39
|
+
type LoopAgentRuntimeV1,
|
|
40
|
+
type LoopEventPayloadMapV1,
|
|
41
|
+
type LoopHookListV1,
|
|
42
|
+
type LoopEventReturnMapV1,
|
|
43
|
+
type LoopStepSnapshotV1,
|
|
44
|
+
loopToolExecutionContextSnapshotV1,
|
|
45
|
+
type PluginWorkerEntrypoint,
|
|
46
|
+
type PluginWorkerHealthV1,
|
|
47
|
+
type PluginWorkerHookInvocationV1,
|
|
48
|
+
type PluginWorkerPluginHealthV1,
|
|
49
|
+
type IsolateToolResultV1,
|
|
50
|
+
type PluginWorkerToolInvocationV1,
|
|
51
|
+
type PluginWorkerTriggerInvocationV1,
|
|
52
|
+
type PluginWorkerTriggerResultV1,
|
|
53
|
+
type PluginWorkerCardActionInvocationV1,
|
|
54
|
+
type PluginWorkerCardActionResultV1,
|
|
55
|
+
type PluginWorkerRenderCardInvocationV1,
|
|
56
|
+
type PluginWorkerReviseCardInvocationV1,
|
|
57
|
+
type PluginWorkerReviseCardResultV1,
|
|
58
|
+
type PluginWorkerViewInvocationV1,
|
|
59
|
+
type PluginWorkerViewResultV1,
|
|
60
|
+
type ToolDefinition,
|
|
61
|
+
type ToolExecutionContext,
|
|
62
|
+
type ToolExecutionResult,
|
|
63
|
+
type ToolRegistration,
|
|
64
|
+
type TurnTypeV1,
|
|
65
|
+
} from "@frockbot/core/contracts";
|
|
66
|
+
import { boundedPromiseCacheV1 } from "@frockbot/core/promise-cache";
|
|
67
|
+
import { sha256HexV1 } from "@frockbot/core/crypto";
|
|
68
|
+
import {
|
|
69
|
+
cardSurfacePrefixV1,
|
|
70
|
+
decodePluginWorkerModelResultV1,
|
|
71
|
+
pluginCardToolNameV1,
|
|
72
|
+
pluginModelProviderV1,
|
|
73
|
+
validateAgainstJsonSchemaV1,
|
|
74
|
+
PLUGIN_MODEL_PROTOCOL_VERSIONS_V1,
|
|
75
|
+
type PluginCardDecisionV1,
|
|
76
|
+
type PluginCardV1,
|
|
77
|
+
type PluginDescriptorV1,
|
|
78
|
+
type PluginGrantV1,
|
|
79
|
+
type PluginModelInvocationV1,
|
|
80
|
+
type PluginSlotV1,
|
|
81
|
+
type PluginWorkerModelResultV1,
|
|
82
|
+
} from "@frockbot/core/contracts";
|
|
83
|
+
import { createConcurrencyLimiterV1 } from "@frockbot/core/concurrency";
|
|
84
|
+
import {
|
|
85
|
+
CompositionMountFailureError,
|
|
86
|
+
type CompositionFailurePhaseV1,
|
|
87
|
+
} from "@frockbot/core/durable/composition-failure";
|
|
88
|
+
import {
|
|
89
|
+
PLUGIN_WORKER_INDEX_VERSION,
|
|
90
|
+
PLUGIN_WORKER_MAIN_MODULE,
|
|
91
|
+
pluginWorkerModuleMap,
|
|
92
|
+
} from "./plugin-worker-wrapper.ts";
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What the host needs of a Composition member. Structural, so this package
|
|
96
|
+
* never imports the Durable Object that stores one.
|
|
97
|
+
*/
|
|
98
|
+
export interface BotIsolateMemberV1 {
|
|
99
|
+
packageId: string;
|
|
100
|
+
version: string;
|
|
101
|
+
artifact: { contentHash: string };
|
|
102
|
+
descriptor: PluginDescriptorV1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The grants a host in this deployment can actually honour. `files` and
|
|
107
|
+
* `computer` are named in the vocabulary and wait on their hosts; a Plugin
|
|
108
|
+
* declaring one is refused at resolve rather than mounted inert.
|
|
109
|
+
*/
|
|
110
|
+
/** The `Identifier` a surface id is, as the Card seam bounds one. */
|
|
111
|
+
const CARD_SURFACE_ID_V1 = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The surface id one card draw is minted under. It names the Plugin and the
|
|
115
|
+
* card so a person reading durable state can tell what drew it, and the
|
|
116
|
+
* effect that drew it is what makes it new.
|
|
117
|
+
*
|
|
118
|
+
* Derived from the Session and the effect rather than random for the same
|
|
119
|
+
* reason the card's Approval ids are: a Turn interrupted before its tool
|
|
120
|
+
* result landed re-runs the same call under the same effect, and a freshly
|
|
121
|
+
* minted surface would name a card nobody is looking at while the one in the
|
|
122
|
+
* conversation — which the send deduped under that effect — kept a
|
|
123
|
+
* live-looking button forever. The Session is hashed in because an effect id
|
|
124
|
+
* is only unique inside one Session and every Routine of a Bot has its own,
|
|
125
|
+
* while card records are Bot-wide: two Sessions drawing at the same turn and
|
|
126
|
+
* step would otherwise land on one surface. A hash is what keeps the pair
|
|
127
|
+
* inside the 128 characters the Card seam bounds a surface id to.
|
|
128
|
+
*/
|
|
129
|
+
async function mintedCardSurfaceIdV1(
|
|
130
|
+
pluginId: string,
|
|
131
|
+
cardId: string,
|
|
132
|
+
sessionId: string,
|
|
133
|
+
effectId: string,
|
|
134
|
+
): Promise<string> {
|
|
135
|
+
const unique = (await sha256HexV1(`${sessionId}\n${effectId}`)).slice(0, 24);
|
|
136
|
+
return `${cardSurfacePrefixV1(pluginId, cardId)}${unique}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const OPEN_PLUGIN_GRANTS_V1: readonly PluginGrantV1[] = [
|
|
140
|
+
"http",
|
|
141
|
+
"schedule",
|
|
142
|
+
"ai",
|
|
143
|
+
"jev",
|
|
144
|
+
"memory",
|
|
145
|
+
"workspace",
|
|
146
|
+
"storage",
|
|
147
|
+
// Opened by the client for a page (ADR 0035), and on the worker only as a
|
|
148
|
+
// call to the Plugin's own device module from its tool (ADR 0037).
|
|
149
|
+
"device",
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The slots this deployment draws. `settings.sections` is the Plugin card;
|
|
154
|
+
* `conversation.panel` and `bot.nav` are the page beside the chat and its
|
|
155
|
+
* door (ADR 0034). The rest wait on the surfaces that use them.
|
|
156
|
+
*/
|
|
157
|
+
const OPEN_PLUGIN_SLOTS_V1: readonly PluginSlotV1[] = [
|
|
158
|
+
"settings.sections",
|
|
159
|
+
"conversation.panel",
|
|
160
|
+
"bot.nav",
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
const PLUGIN_WORKER_HEALTH_CACHE_LIMIT_V1 = 64;
|
|
164
|
+
const pluginWorkerHealthCacheV1 = new WeakMap<
|
|
165
|
+
BotIsolateLoader,
|
|
166
|
+
Map<string, Promise<PluginWorkerHealthV1>>
|
|
167
|
+
>();
|
|
168
|
+
|
|
169
|
+
function pluginWorkerHealthV1(
|
|
170
|
+
loader: BotIsolateLoader,
|
|
171
|
+
loaderId: string,
|
|
172
|
+
load: () => Promise<PluginWorkerHealthV1>,
|
|
173
|
+
): Promise<PluginWorkerHealthV1> {
|
|
174
|
+
let cache = pluginWorkerHealthCacheV1.get(loader);
|
|
175
|
+
if (!cache) {
|
|
176
|
+
cache = new Map();
|
|
177
|
+
pluginWorkerHealthCacheV1.set(loader, cache);
|
|
178
|
+
}
|
|
179
|
+
// The loader id addresses immutable modules, identities and bindings, so a
|
|
180
|
+
// successful declaration is stable across Turns. Failures remain retryable:
|
|
181
|
+
// an overloaded worker must not poison that generation for its lifetime.
|
|
182
|
+
return boundedPromiseCacheV1(
|
|
183
|
+
cache,
|
|
184
|
+
loaderId,
|
|
185
|
+
PLUGIN_WORKER_HEALTH_CACHE_LIMIT_V1,
|
|
186
|
+
load,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The `WorkerCode` a Plugin worker is loaded from. Structurally the platform's. */
|
|
191
|
+
export interface BotIsolateWorkerCode {
|
|
192
|
+
compatibilityDate: string;
|
|
193
|
+
mainModule: string;
|
|
194
|
+
modules: Record<string, { js: string }>;
|
|
195
|
+
globalOutbound: null | unknown;
|
|
196
|
+
env: BotIsolateEnv;
|
|
197
|
+
limits: { cpuMs: number; subRequests: number };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface BotIsolateLoadedWorker {
|
|
201
|
+
getEntrypoint(name?: string | null): PluginWorkerEntrypoint;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The `worker_loaders` binding, declared structurally so the kernel stays platform-free. */
|
|
205
|
+
export interface BotIsolateLoader {
|
|
206
|
+
get(
|
|
207
|
+
id: string,
|
|
208
|
+
callback: () => Promise<BotIsolateWorkerCode>,
|
|
209
|
+
): BotIsolateLoadedWorker;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Reads an immutable, content-addressed Plugin artifact and verifies its hash. */
|
|
213
|
+
export interface BotIsolateArtifactStore {
|
|
214
|
+
loadPackageArtifact(contentHash: string): Promise<string>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface BotIsolateLimits {
|
|
218
|
+
cpuMs: number;
|
|
219
|
+
subRequests: number;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface IsolateHookFailureV1 {
|
|
223
|
+
packageId: string;
|
|
224
|
+
event: BotIsolateHookEventNameV1;
|
|
225
|
+
generationId: string;
|
|
226
|
+
message: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* A Plugin failure the host must not swallow: a Plugin that cannot be skipped
|
|
231
|
+
* failed, so the Turn fails with it. Raised by `recordHookFailure` and carried
|
|
232
|
+
* out past the catches that keep an ordinary Plugin's failure from wedging the
|
|
233
|
+
* loop.
|
|
234
|
+
*/
|
|
235
|
+
export class PluginFatalFailureError extends Error {
|
|
236
|
+
readonly name = "PluginFatalFailureError";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The rejection `raceDeadline` gives when the caller's own signal ended the
|
|
241
|
+
* race, not the Plugin's deadline. A cancelled or expired Turn is not the
|
|
242
|
+
* Plugin failing, so a charge site can tell the two apart at the rejection
|
|
243
|
+
* rather than guessing from a signal that may have aborted afterwards.
|
|
244
|
+
*/
|
|
245
|
+
export class RaceAbortedError extends Error {
|
|
246
|
+
readonly name = "RaceAbortedError";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** One Plugin the worker could not mount, with the phase it failed at. */
|
|
250
|
+
export interface PluginMountFailureV1 {
|
|
251
|
+
pluginId: string;
|
|
252
|
+
phase: CompositionFailurePhaseV1;
|
|
253
|
+
message: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* One model provider this deployment serves through a Plugin, and the bytes
|
|
258
|
+
* that serve it (ADR 0032): the Plugin the deployment's provider catalog names
|
|
259
|
+
* for the provider, at its own artifact. It is the deployment's answer and
|
|
260
|
+
* never a member's claim, which is what keeps "this Plugin serves this
|
|
261
|
+
* provider" a fact about content and not about a descriptor.
|
|
262
|
+
*/
|
|
263
|
+
export interface PluginServedProviderClaimV1 {
|
|
264
|
+
provider: string;
|
|
265
|
+
/** The Plugin the catalog names for the provider. */
|
|
266
|
+
pluginId: string;
|
|
267
|
+
/** The content hash of that Plugin's own artifact. */
|
|
268
|
+
contentHash: string;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface PluginWorkerHostOptions {
|
|
272
|
+
loader: BotIsolateLoader;
|
|
273
|
+
artifacts: BotIsolateArtifactStore;
|
|
274
|
+
/** Where the worker's tools are registered — the kernel's tool surface. */
|
|
275
|
+
tools: ToolRegistration;
|
|
276
|
+
/** Where the worker's hooks are added — the runtime's loop hook list. */
|
|
277
|
+
hooks: LoopHookListV1;
|
|
278
|
+
userId: string;
|
|
279
|
+
botId: string;
|
|
280
|
+
sessionId: string;
|
|
281
|
+
runId: string;
|
|
282
|
+
turnId: string;
|
|
283
|
+
generationId: string;
|
|
284
|
+
turnType: TurnTypeV1;
|
|
285
|
+
subagentRole?: string;
|
|
286
|
+
/**
|
|
287
|
+
* Whether an artifact is one this deployment built and seeded. Its tools
|
|
288
|
+
* are the deployment's own code behind the Plugin boundary, so Turn
|
|
289
|
+
* supervision does not review each call; every other Plugin's tools are
|
|
290
|
+
* `mutate`. Absent, every Plugin's are.
|
|
291
|
+
*/
|
|
292
|
+
deploymentArtifact?(contentHash: string): boolean;
|
|
293
|
+
/** Durably records a hook the worker skipped, before the loop continues. */
|
|
294
|
+
recordHookFailure(failure: IsolateHookFailureV1): Promise<void>;
|
|
295
|
+
/**
|
|
296
|
+
* Charges one card draw that failed to the Plugin's health, the way a press
|
|
297
|
+
* that failed is charged (ADR 0030): a throw, a deadline overrun, an
|
|
298
|
+
* unreachable worker and an answer the kernel could not read all count
|
|
299
|
+
* toward quarantine, while a draw that refused in as many words does not.
|
|
300
|
+
* The model still reads the tool error; this is the count beside it.
|
|
301
|
+
*/
|
|
302
|
+
recordCardFailure?(failure: PluginCardFailureV1): Promise<void>;
|
|
303
|
+
/**
|
|
304
|
+
* Puts one Card on the Turn's log, exactly as `send_to_user` would. The
|
|
305
|
+
* host has the Plugin worker and the app has the Session, so the send is
|
|
306
|
+
* the app's to record; a host without one registers no card tools, which
|
|
307
|
+
* is what a standalone mount is.
|
|
308
|
+
*/
|
|
309
|
+
sendCard?(send: PluginCardSendV1): Promise<PluginCardSendOutcomeV1>;
|
|
310
|
+
/**
|
|
311
|
+
* The Plugin tool calls running now, effect id to Plugin. A device call
|
|
312
|
+
* names the tool call it runs inside, and the Bot's object admits it only
|
|
313
|
+
* while that Plugin's call is running here: Plugin code writes the name,
|
|
314
|
+
* so the name alone proves nothing.
|
|
315
|
+
*/
|
|
316
|
+
toolEffects?: Map<string, string>;
|
|
317
|
+
/**
|
|
318
|
+
* The loopback `CAPABILITIES` binding, minted by the Bot's Durable Object
|
|
319
|
+
* for this User. Per User, never per Turn: every call carries its scope.
|
|
320
|
+
*/
|
|
321
|
+
capabilities: BotCapabilitiesStub;
|
|
322
|
+
/**
|
|
323
|
+
* The worker's `globalOutbound`: a loopback service the Durable Object
|
|
324
|
+
* minted with the hosts the User's enabled Plugins declared, or nothing,
|
|
325
|
+
* which leaves `fetch` refused. Whatever it is, it is baked into `env` and
|
|
326
|
+
* therefore into the binding digest.
|
|
327
|
+
*/
|
|
328
|
+
egress?: unknown;
|
|
329
|
+
compatibilityDate: string;
|
|
330
|
+
/**
|
|
331
|
+
* Content address of the bindings baked into `env` — the User and the
|
|
332
|
+
* egress policy. Folded into the loader id so a cached worker never answers
|
|
333
|
+
* under a stale `env`.
|
|
334
|
+
*/
|
|
335
|
+
bindingDigest: string;
|
|
336
|
+
limits?: BotIsolateLimits;
|
|
337
|
+
deadlineMs?: number;
|
|
338
|
+
healthDeadlineMs?: number;
|
|
339
|
+
/**
|
|
340
|
+
* The Plugins this Bot runs, out of the ones its User installed. Absent
|
|
341
|
+
* means every mounted Plugin. A Plugin the User installed but this Bot has
|
|
342
|
+
* off is still in the module set — the worker is per User — but registers
|
|
343
|
+
* no tools here and is left out of every hook's enabled list.
|
|
344
|
+
*/
|
|
345
|
+
enabled?: readonly string[];
|
|
346
|
+
/**
|
|
347
|
+
* Every model provider this deployment serves through a Plugin, and the
|
|
348
|
+
* Plugin and artifact that may serve each (ADR 0032). It is a property of
|
|
349
|
+
* the deployment, not of a Bot's selection, so every mount supplies it: an
|
|
350
|
+
* account that installed a provider's Plugin still carries it in its
|
|
351
|
+
* Composition while its Bot's model is something else, and only this makes
|
|
352
|
+
* such a member mountable.
|
|
353
|
+
*
|
|
354
|
+
* A member declaring one of these providers is served by it only as that
|
|
355
|
+
* Plugin at that artifact: a claimant whose id or artifact differs — a
|
|
356
|
+
* Plugin a Bot wrote, above all — is refused here, and so is a second
|
|
357
|
+
* claimant, whatever order the generation lists them in. A member declaring
|
|
358
|
+
* a provider this lists none of is refused with the reason. Absent means
|
|
359
|
+
* this deployment opens none to Plugins.
|
|
360
|
+
*/
|
|
361
|
+
openModelProviders?: readonly PluginServedProviderClaimV1[];
|
|
362
|
+
/**
|
|
363
|
+
* The one provider this Bot's model selection runs, when it names one the
|
|
364
|
+
* entries above serve. Selection is what runs a provider contribution, not
|
|
365
|
+
* the Bot's plugin switch: a Bot whose model is this provider is served by
|
|
366
|
+
* it whatever the switch says, and the switch's own tools and hooks stay
|
|
367
|
+
* off until it is on. Absent leaves every model contribution unserved.
|
|
368
|
+
*/
|
|
369
|
+
selectedModelProvider?: string;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** One model provider contribution this worker mounted and this Bot selected. */
|
|
373
|
+
export interface MountedModelProviderV1 {
|
|
374
|
+
pluginId: string;
|
|
375
|
+
providerId: string;
|
|
376
|
+
protocolVersion: number;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** One card draw a Plugin could not answer, charged to its health. */
|
|
380
|
+
export interface PluginCardFailureV1 {
|
|
381
|
+
pluginId: string;
|
|
382
|
+
cardId: string;
|
|
383
|
+
message: string;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** One Card a Plugin's card tool asks the app to record on the Turn's log. */
|
|
387
|
+
export interface PluginCardSendV1 {
|
|
388
|
+
pluginId: string;
|
|
389
|
+
cardId: string;
|
|
390
|
+
surfaceId: string;
|
|
391
|
+
/**
|
|
392
|
+
* The canonical values this draw says a decision on it would authorize, as
|
|
393
|
+
* the Plugin declared them. The seam binds the Card's Approvals to these,
|
|
394
|
+
* so a decision a person gives covers what the Plugin drew and will act on
|
|
395
|
+
* rather than whatever the model passed to the tool. Absent when the draw
|
|
396
|
+
* declared none, which is only allowed of a card that asks for nothing.
|
|
397
|
+
*/
|
|
398
|
+
covers?: Record<string, unknown>;
|
|
399
|
+
/**
|
|
400
|
+
* What the decision this draw asks for is recorded as, as the Plugin stated
|
|
401
|
+
* it. Absent when the draw asks for none; a draw that puts an
|
|
402
|
+
* `ApprovalActions` on the card and states none is refused at the seam.
|
|
403
|
+
*/
|
|
404
|
+
decision?: PluginCardDecisionV1;
|
|
405
|
+
/** The A2UI messages the Plugin drew, still undecoded. */
|
|
406
|
+
messages: Record<string, unknown>[];
|
|
407
|
+
/**
|
|
408
|
+
* The Approvals the kernel has *already* recorded for this draw, in the
|
|
409
|
+
* order the surface's `ApprovalActions` are to be bound to them (ADR 0030
|
|
410
|
+
* step 7).
|
|
411
|
+
*
|
|
412
|
+
* It is present only on a draw the kernel itself asked for: the locked
|
|
413
|
+
* first-party cards, where the decision is the one the old `approval`
|
|
414
|
+
* payload put on the log under the id the Bot chose. Binding to it rather
|
|
415
|
+
* than minting keeps an existing Bot's own `approvalId` — the id its
|
|
416
|
+
* Machine command, its Plugin intent and its next Turn's durable input are
|
|
417
|
+
* all keyed by — the id the card decides. Absent everywhere else, where the
|
|
418
|
+
* seam mints, which is what stops a Plugin naming a decision.
|
|
419
|
+
*/
|
|
420
|
+
approvalIds?: readonly string[];
|
|
421
|
+
/**
|
|
422
|
+
* The secret request the kernel asked this draw to carry the field for.
|
|
423
|
+
* Present only on the locked first-party draw of a `secret-request`; the
|
|
424
|
+
* seam refuses a `SecretField` on any draw without it.
|
|
425
|
+
*/
|
|
426
|
+
secretRequest?: FirstPartySecretRequestV1;
|
|
427
|
+
context: ToolExecutionContext;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Whether the send landed. A refusal is the tool result the Bot reads, and
|
|
432
|
+
* `approvals` is how many decisions the Card asked the kernel to record: a
|
|
433
|
+
* Card that asks for one ends the Turn, exactly as an approval send does.
|
|
434
|
+
*/
|
|
435
|
+
export type PluginCardSendOutcomeV1 =
|
|
436
|
+
{ status: "sent"; approvals: number } | { status: "refused"; reason: string };
|
|
437
|
+
|
|
438
|
+
/** What one caller asks a card to be drawn with. */
|
|
439
|
+
export interface PluginCardDrawRequestV1 {
|
|
440
|
+
data: Record<string, unknown>;
|
|
441
|
+
/** A surface this card already drew, to update it in place. */
|
|
442
|
+
surfaceId?: string;
|
|
443
|
+
/** See `PluginCardSendV1.approvalIds`: the kernel's own, never a Plugin's. */
|
|
444
|
+
approvalIds?: readonly string[];
|
|
445
|
+
/** See `PluginCardSendV1.secretRequest`: the kernel's own, never a Plugin's. */
|
|
446
|
+
secretRequest?: FirstPartySecretRequestV1;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* How one draw ended, in the four kinds the caller has to tell apart: it
|
|
451
|
+
* landed; the Plugin refused it in as many words; the Plugin broke; or the
|
|
452
|
+
* seam would not record it. The card's tool turns each into a sentence for
|
|
453
|
+
* the model, and the Shell's first-party seam turns each into the fallback
|
|
454
|
+
* line the person reads.
|
|
455
|
+
*/
|
|
456
|
+
export type PluginCardDrawOutcomeV1 =
|
|
457
|
+
| { status: "drawn"; surfaceId: string; approvals: number }
|
|
458
|
+
| { status: "dropped"; reason: string }
|
|
459
|
+
| { status: "failed"; reason: string }
|
|
460
|
+
| { status: "refused"; reason: string };
|
|
461
|
+
|
|
462
|
+
export const BOT_ISOLATE_DEFAULT_LIMITS: BotIsolateLimits = {
|
|
463
|
+
cpuMs: 5_000,
|
|
464
|
+
subRequests: 5,
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
export const BOT_ISOLATE_DEFAULT_DEADLINE_MS = 15_000;
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* What the Durable Object's race allows on top of the chain's own budget, so
|
|
471
|
+
* a chain that spends every millisecond it was given still gets its answer —
|
|
472
|
+
* including the Plugins it named as skipped — back before the race fires.
|
|
473
|
+
*/
|
|
474
|
+
export const PLUGIN_WORKER_HOOK_RACE_MARGIN_MS = 250;
|
|
475
|
+
|
|
476
|
+
export const BOT_ISOLATE_DEFAULT_HEALTH_DEADLINE_MS = 10_000;
|
|
477
|
+
|
|
478
|
+
/** A mounted worker: what it registers on commit, and what it could not mount. */
|
|
479
|
+
export interface PreparedPluginWorker {
|
|
480
|
+
/** Plugins the worker mounted and verified, in mount order. */
|
|
481
|
+
readonly mounted: readonly string[];
|
|
482
|
+
readonly failures: readonly PluginMountFailureV1[];
|
|
483
|
+
commit(): Promise<ActivePluginWorker>;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export interface ActivePluginWorker {
|
|
487
|
+
/**
|
|
488
|
+
* The model provider contributions this Bot's selection runs, each served
|
|
489
|
+
* by a Plugin this worker verified and mounted (ADR 0032). Empty when the
|
|
490
|
+
* selection names no provider a mounted Plugin serves.
|
|
491
|
+
*/
|
|
492
|
+
readonly modelProviders: readonly MountedModelProviderV1[];
|
|
493
|
+
/**
|
|
494
|
+
* One model call, served by the Plugin that declared the provider. The
|
|
495
|
+
* answer carries its events as an NDJSON byte stream, bounded by the
|
|
496
|
+
* invocation's silence allowance. A Plugin this worker did not verify, or
|
|
497
|
+
* that does not serve the provider, is refused before the worker is
|
|
498
|
+
* reached.
|
|
499
|
+
*/
|
|
500
|
+
streamModel(
|
|
501
|
+
invocation: PluginModelInvocationV1,
|
|
502
|
+
): Promise<PluginWorkerModelResultV1>;
|
|
503
|
+
/**
|
|
504
|
+
* Delivers an app-owned trigger to one Plugin. Only a Plugin this worker
|
|
505
|
+
* verified and enabled runs: the index knows nothing of the host's verified
|
|
506
|
+
* set, so the gate lives here, and a trigger naming any other Plugin is
|
|
507
|
+
* dropped with the reason rather than thrown.
|
|
508
|
+
*/
|
|
509
|
+
deliverTrigger(
|
|
510
|
+
invocation: PluginWorkerTriggerInvocationV1,
|
|
511
|
+
): Promise<PluginWorkerTriggerResultV1>;
|
|
512
|
+
/**
|
|
513
|
+
* Renders one of a Plugin's declared views (ADR 0026 step 9's
|
|
514
|
+
* `settings.sections` slot). Gated like a trigger: only a verified and
|
|
515
|
+
* enabled Plugin renders, and any other answer is a drop with its reason.
|
|
516
|
+
*/
|
|
517
|
+
renderView(
|
|
518
|
+
invocation: PluginWorkerViewInvocationV1,
|
|
519
|
+
): Promise<PluginWorkerViewResultV1>;
|
|
520
|
+
/**
|
|
521
|
+
* Runs one Card action a renderer named `plugin/<pluginId>/<action>` (ADR
|
|
522
|
+
* 0030). Gated like a view, and answered with the A2UI messages the kernel
|
|
523
|
+
* folds into the Card; a handler that throws or overruns is a drop and the
|
|
524
|
+
* Card is left as it was.
|
|
525
|
+
*/
|
|
526
|
+
cardAction(
|
|
527
|
+
invocation: PluginWorkerCardActionInvocationV1,
|
|
528
|
+
): Promise<PluginWorkerCardActionResultV1>;
|
|
529
|
+
/**
|
|
530
|
+
* Puts a card a person edited and approved back to the Plugin that drew
|
|
531
|
+
* it, which restates what the decision now covers. Gated like a press; a
|
|
532
|
+
* Plugin that cannot be reached is a drop, and the kernel decides nothing
|
|
533
|
+
* on edits nobody could restate.
|
|
534
|
+
*/
|
|
535
|
+
reviseCard(
|
|
536
|
+
invocation: PluginWorkerReviseCardInvocationV1,
|
|
537
|
+
): Promise<PluginWorkerReviseCardResultV1>;
|
|
538
|
+
/**
|
|
539
|
+
* Draws one of a Plugin's declared cards outside the Bot's tool registry.
|
|
540
|
+
*
|
|
541
|
+
* The Shell's send seam is the caller: the five first-party payload members
|
|
542
|
+
* are locked Plugins now, and an old `send_to_user` member is mapped onto
|
|
543
|
+
* one of them here (ADR 0030 step 7). It is the very same draw the card's
|
|
544
|
+
* tool makes — the same schema check, the same minted surface, the same
|
|
545
|
+
* `renderCard`, the same send — so first-party is not a shorter path.
|
|
546
|
+
*/
|
|
547
|
+
drawCard(
|
|
548
|
+
pluginId: string,
|
|
549
|
+
cardId: string,
|
|
550
|
+
request: PluginCardDrawRequestV1,
|
|
551
|
+
context: ToolExecutionContext,
|
|
552
|
+
): Promise<PluginCardDrawOutcomeV1>;
|
|
553
|
+
/**
|
|
554
|
+
* Runs one declared tool outside any Turn: a control on a Plugin's settings
|
|
555
|
+
* section is the User's own click, so the call is made here rather than
|
|
556
|
+
* through the Bot's tool registry. Gated like a view; a tool the Plugin's
|
|
557
|
+
* health report did not list is refused before the worker is reached.
|
|
558
|
+
*/
|
|
559
|
+
executeTool(
|
|
560
|
+
invocation: PluginWorkerToolInvocationV1,
|
|
561
|
+
): Promise<IsolateToolResultV1>;
|
|
562
|
+
/**
|
|
563
|
+
* Assembles this Bot's look outside any Turn. `theme/assemble` is not a
|
|
564
|
+
* loop event: `_select` never waits on it, and the Agent loop never fires
|
|
565
|
+
* it. A Plugin that throws or answers with a document the kernel refuses
|
|
566
|
+
* is skipped and the last good document is kept.
|
|
567
|
+
*/
|
|
568
|
+
assembleTheme(
|
|
569
|
+
payload: LoopEventPayloadMapV1["theme/assemble"],
|
|
570
|
+
original: LoopEventReturnMapV1["theme/assemble"],
|
|
571
|
+
): Promise<LoopEventReturnMapV1["theme/assemble"]>;
|
|
572
|
+
dispose(): Promise<void>;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
interface ResolvedPlugin {
|
|
576
|
+
member: BotIsolateMemberV1;
|
|
577
|
+
source: string;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function pluginIdentityV1(member: BotIsolateMemberV1): {
|
|
581
|
+
grants: string[];
|
|
582
|
+
consumes: string[];
|
|
583
|
+
} {
|
|
584
|
+
return {
|
|
585
|
+
grants: [...member.descriptor.grants],
|
|
586
|
+
consumes: (member.descriptor.consumes ?? []).map((service) => service.name),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function errorMessage(error: unknown): string {
|
|
591
|
+
return error instanceof Error ? error.message : String(error);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Mount order: a Plugin after every Plugin whose service it consumes, and
|
|
596
|
+
* otherwise the order the generation listed them. A Plugin whose need no
|
|
597
|
+
* sibling meets, or that sits in a cycle, is left out and named, and the rest
|
|
598
|
+
* still mount.
|
|
599
|
+
*
|
|
600
|
+
* `alreadyExcluded` names Plugins the caller has already failed and named. They
|
|
601
|
+
* are still read as providers, so a Plugin consuming their services is told the
|
|
602
|
+
* provider did not mount rather than that nothing provides the service.
|
|
603
|
+
*/
|
|
604
|
+
export function pluginMountOrderV1(
|
|
605
|
+
members: readonly BotIsolateMemberV1[],
|
|
606
|
+
alreadyExcluded: ReadonlySet<string> = new Set(),
|
|
607
|
+
): {
|
|
608
|
+
order: BotIsolateMemberV1[];
|
|
609
|
+
failures: PluginMountFailureV1[];
|
|
610
|
+
} {
|
|
611
|
+
const failures: PluginMountFailureV1[] = [];
|
|
612
|
+
const providers = new Map<
|
|
613
|
+
string,
|
|
614
|
+
{ member: BotIsolateMemberV1; version: number }
|
|
615
|
+
>();
|
|
616
|
+
for (const member of members) {
|
|
617
|
+
if (alreadyExcluded.has(member.packageId)) continue;
|
|
618
|
+
for (const service of member.descriptor.provides ?? []) {
|
|
619
|
+
const existing = providers.get(service.name);
|
|
620
|
+
if (existing) {
|
|
621
|
+
failures.push({
|
|
622
|
+
pluginId: member.packageId,
|
|
623
|
+
phase: "resolve",
|
|
624
|
+
message: `plugin "${member.packageId}" provides "${service.name}", which "${existing.member.packageId}" already provides`,
|
|
625
|
+
});
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
providers.set(service.name, { member, version: service.version });
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// An already-failed Plugin fills only the services no mountable sibling
|
|
632
|
+
// claims, so it never displaces a live provider or earns a duplicate failure
|
|
633
|
+
// on top of the one it already has.
|
|
634
|
+
for (const member of members) {
|
|
635
|
+
if (!alreadyExcluded.has(member.packageId)) continue;
|
|
636
|
+
for (const service of member.descriptor.provides ?? []) {
|
|
637
|
+
if (providers.has(service.name)) continue;
|
|
638
|
+
providers.set(service.name, { member, version: service.version });
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const excluded = new Set([
|
|
642
|
+
...alreadyExcluded,
|
|
643
|
+
...failures.map((failure) => failure.pluginId),
|
|
644
|
+
]);
|
|
645
|
+
const needs = new Map<string, BotIsolateMemberV1[]>();
|
|
646
|
+
for (const member of members) {
|
|
647
|
+
if (excluded.has(member.packageId)) continue;
|
|
648
|
+
const upstream: BotIsolateMemberV1[] = [];
|
|
649
|
+
for (const service of member.descriptor.consumes ?? []) {
|
|
650
|
+
const provider = providers.get(service.name);
|
|
651
|
+
if (!provider) {
|
|
652
|
+
failures.push({
|
|
653
|
+
pluginId: member.packageId,
|
|
654
|
+
phase: "resolve",
|
|
655
|
+
message: `plugin "${member.packageId}" consumes "${service.name}", which no installed plugin provides`,
|
|
656
|
+
});
|
|
657
|
+
excluded.add(member.packageId);
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
if (excluded.has(provider.member.packageId)) {
|
|
661
|
+
// The Kahn pass below names this as consuming from a Plugin that did
|
|
662
|
+
// not mount, which is the true reason whatever else the provider
|
|
663
|
+
// declared.
|
|
664
|
+
upstream.push(provider.member);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
if (provider.version !== service.version) {
|
|
668
|
+
failures.push({
|
|
669
|
+
pluginId: member.packageId,
|
|
670
|
+
phase: "resolve",
|
|
671
|
+
message: `plugin "${member.packageId}" consumes "${service.name}" version ${service.version}, but "${provider.member.packageId}" provides version ${provider.version}`,
|
|
672
|
+
});
|
|
673
|
+
excluded.add(member.packageId);
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
upstream.push(provider.member);
|
|
677
|
+
}
|
|
678
|
+
if (!excluded.has(member.packageId)) needs.set(member.packageId, upstream);
|
|
679
|
+
}
|
|
680
|
+
// Kahn's algorithm over the listed order, so an unconstrained Plugin keeps
|
|
681
|
+
// its place. A consumer of an excluded Plugin is excluded in turn.
|
|
682
|
+
const order: BotIsolateMemberV1[] = [];
|
|
683
|
+
const placed = new Set<string>();
|
|
684
|
+
let progressed = true;
|
|
685
|
+
while (progressed) {
|
|
686
|
+
progressed = false;
|
|
687
|
+
for (const member of members) {
|
|
688
|
+
const id = member.packageId;
|
|
689
|
+
if (placed.has(id) || excluded.has(id)) continue;
|
|
690
|
+
const upstream = needs.get(id) ?? [];
|
|
691
|
+
if (upstream.some((dependency) => excluded.has(dependency.packageId))) {
|
|
692
|
+
failures.push({
|
|
693
|
+
pluginId: id,
|
|
694
|
+
phase: "resolve",
|
|
695
|
+
message: `plugin "${id}" consumes a service from a plugin that did not mount`,
|
|
696
|
+
});
|
|
697
|
+
excluded.add(id);
|
|
698
|
+
progressed = true;
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
if (upstream.every((dependency) => placed.has(dependency.packageId))) {
|
|
702
|
+
order.push(member);
|
|
703
|
+
placed.add(id);
|
|
704
|
+
progressed = true;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
// What is left when Kahn stalls is a cycle plus whatever hangs off it. Only
|
|
709
|
+
// a Plugin that can reach itself is in the cycle; the rest simply consume a
|
|
710
|
+
// service from a Plugin that could not be ordered.
|
|
711
|
+
const stalled = members.filter(
|
|
712
|
+
(member) =>
|
|
713
|
+
!placed.has(member.packageId) && !excluded.has(member.packageId),
|
|
714
|
+
);
|
|
715
|
+
const stalledIds = new Set(stalled.map((member) => member.packageId));
|
|
716
|
+
const inCycle = (start: string): boolean => {
|
|
717
|
+
const seen = new Set<string>();
|
|
718
|
+
const pending = (needs.get(start) ?? []).map(
|
|
719
|
+
(dependency) => dependency.packageId,
|
|
720
|
+
);
|
|
721
|
+
while (pending.length > 0) {
|
|
722
|
+
const id = pending.pop()!;
|
|
723
|
+
if (id === start) return true;
|
|
724
|
+
if (seen.has(id) || !stalledIds.has(id)) continue;
|
|
725
|
+
seen.add(id);
|
|
726
|
+
for (const dependency of needs.get(id) ?? []) {
|
|
727
|
+
pending.push(dependency.packageId);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return false;
|
|
731
|
+
};
|
|
732
|
+
for (const member of stalled) {
|
|
733
|
+
const id = member.packageId;
|
|
734
|
+
failures.push({
|
|
735
|
+
pluginId: id,
|
|
736
|
+
phase: "resolve",
|
|
737
|
+
message: inCycle(id)
|
|
738
|
+
? `plugin "${id}" consumes a service in a cycle`
|
|
739
|
+
: `plugin "${id}" consumes a service from a plugin that did not mount`,
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
return { order, failures };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Mounts a generation's Plugins as one worker and registers what they report. */
|
|
746
|
+
export class PluginWorkerHost {
|
|
747
|
+
private readonly options: PluginWorkerHostOptions;
|
|
748
|
+
|
|
749
|
+
constructor(options: PluginWorkerHostOptions) {
|
|
750
|
+
this.options = options;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async mount(
|
|
754
|
+
members: readonly BotIsolateMemberV1[],
|
|
755
|
+
): Promise<PreparedPluginWorker> {
|
|
756
|
+
const failures: PluginMountFailureV1[] = [];
|
|
757
|
+
const refused = new Set<string>();
|
|
758
|
+
// One provider, one contribution, and it is the deployment's own. What
|
|
759
|
+
// may serve a provider is the deployment's compiled claim, judged by the
|
|
760
|
+
// member's own id and artifact bytes — never by who claimed it first, so
|
|
761
|
+
// the generation's package-id order cannot hand a provider to a
|
|
762
|
+
// Bot-written claimant. A Plugin that fails reserves nothing.
|
|
763
|
+
const open = new Map(
|
|
764
|
+
(this.options.openModelProviders ?? []).map((claim) => [
|
|
765
|
+
claim.provider,
|
|
766
|
+
claim,
|
|
767
|
+
]),
|
|
768
|
+
);
|
|
769
|
+
const claimed = new Set<string>();
|
|
770
|
+
for (const member of members) {
|
|
771
|
+
const providers = member.descriptor.modelProviders ?? [];
|
|
772
|
+
let untrusted: { id: string; servedBy: string } | undefined;
|
|
773
|
+
for (const provider of providers) {
|
|
774
|
+
const claim = open.get(provider.id);
|
|
775
|
+
if (
|
|
776
|
+
claim !== undefined &&
|
|
777
|
+
(member.packageId !== claim.pluginId ||
|
|
778
|
+
member.artifact.contentHash !== claim.contentHash)
|
|
779
|
+
) {
|
|
780
|
+
untrusted = { id: provider.id, servedBy: claim.pluginId };
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
const claimedAlready =
|
|
785
|
+
untrusted === undefined
|
|
786
|
+
? providers.find((provider) => claimed.has(provider.id))
|
|
787
|
+
: undefined;
|
|
788
|
+
const refusal =
|
|
789
|
+
untrusted !== undefined
|
|
790
|
+
? `plugin "${member.packageId}" claims model provider "${untrusted.id}", which this deployment serves only through the Plugin "${untrusted.servedBy}" at its own artifact`
|
|
791
|
+
: claimedAlready === undefined
|
|
792
|
+
? this.refusal(member)
|
|
793
|
+
: `plugin "${member.packageId}" serves model provider "${claimedAlready.id}", which an earlier plugin in this generation already serves`;
|
|
794
|
+
// Only a Plugin that mounts reserves its provider: a refused claimant —
|
|
795
|
+
// a Bot's own code above all — must not be able to take the provider
|
|
796
|
+
// away from the deployment's own Plugin by claiming it first.
|
|
797
|
+
if (refusal === undefined) {
|
|
798
|
+
for (const provider of providers) claimed.add(provider.id);
|
|
799
|
+
}
|
|
800
|
+
if (!refusal) continue;
|
|
801
|
+
failures.push({
|
|
802
|
+
pluginId: member.packageId,
|
|
803
|
+
phase: "resolve",
|
|
804
|
+
message: refusal,
|
|
805
|
+
});
|
|
806
|
+
refused.add(member.packageId);
|
|
807
|
+
}
|
|
808
|
+
// A refused Plugin still counts as the provider of its services, so its
|
|
809
|
+
// consumers are told the provider did not mount.
|
|
810
|
+
const ordered = pluginMountOrderV1(members, refused);
|
|
811
|
+
failures.push(...ordered.failures);
|
|
812
|
+
|
|
813
|
+
// The artifacts are immutable objects addressed by content hash, so
|
|
814
|
+
// reading them is order-independent and they are read together: one round
|
|
815
|
+
// trip per member, in sequence, is mount latency nobody gets back. The
|
|
816
|
+
// mount order itself is unchanged — `ordered.order` still decides it, and
|
|
817
|
+
// a member whose artifact is missing still fails in its own place.
|
|
818
|
+
const inFlight = createConcurrencyLimiterV1();
|
|
819
|
+
const sources = await Promise.all(
|
|
820
|
+
ordered.order.map((member) =>
|
|
821
|
+
inFlight(() =>
|
|
822
|
+
this.options.artifacts.loadPackageArtifact(
|
|
823
|
+
member.artifact.contentHash,
|
|
824
|
+
),
|
|
825
|
+
).then(
|
|
826
|
+
(source) => ({ source, error: undefined }),
|
|
827
|
+
(error: unknown) => ({ source: undefined, error }),
|
|
828
|
+
),
|
|
829
|
+
),
|
|
830
|
+
);
|
|
831
|
+
const resolved: ResolvedPlugin[] = [];
|
|
832
|
+
for (const [index, member] of ordered.order.entries()) {
|
|
833
|
+
const { source, error } = sources[index]!;
|
|
834
|
+
if (source === undefined) {
|
|
835
|
+
// Site one: the immutable artifact read. A generation whose artifact
|
|
836
|
+
// is gone never resolves, and that is a different repair from a broken
|
|
837
|
+
// one.
|
|
838
|
+
failures.push({
|
|
839
|
+
pluginId: member.packageId,
|
|
840
|
+
phase: "resolve",
|
|
841
|
+
message: `plugin "${member.packageId}" artifact "${member.artifact.contentHash}" is unavailable: ${errorMessage(error)}`,
|
|
842
|
+
});
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
resolved.push({ member, source });
|
|
846
|
+
}
|
|
847
|
+
if (resolved.length === 0) {
|
|
848
|
+
return {
|
|
849
|
+
mounted: [],
|
|
850
|
+
failures,
|
|
851
|
+
commit: () =>
|
|
852
|
+
Promise.resolve({
|
|
853
|
+
modelProviders: [],
|
|
854
|
+
streamModel: (invocation: PluginModelInvocationV1) =>
|
|
855
|
+
Promise.resolve<PluginWorkerModelResultV1>({
|
|
856
|
+
schemaVersion: 1,
|
|
857
|
+
status: "refused",
|
|
858
|
+
reason: `plugin "${invocation.pluginId}" did not mount in this generation`,
|
|
859
|
+
}),
|
|
860
|
+
deliverTrigger: (invocation: PluginWorkerTriggerInvocationV1) =>
|
|
861
|
+
Promise.resolve(droppedTrigger(invocation.pluginId)),
|
|
862
|
+
renderView: (invocation: PluginWorkerViewInvocationV1) =>
|
|
863
|
+
Promise.resolve<PluginWorkerViewResultV1>({
|
|
864
|
+
schemaVersion: 1,
|
|
865
|
+
status: "drop",
|
|
866
|
+
reason: `plugin "${invocation.pluginId}" did not mount in this generation`,
|
|
867
|
+
}),
|
|
868
|
+
cardAction: (invocation: PluginWorkerCardActionInvocationV1) =>
|
|
869
|
+
Promise.resolve<PluginWorkerCardActionResultV1>({
|
|
870
|
+
schemaVersion: 1,
|
|
871
|
+
status: "drop",
|
|
872
|
+
reason: `plugin "${invocation.pluginId}" did not mount in this generation`,
|
|
873
|
+
}),
|
|
874
|
+
reviseCard: (invocation: PluginWorkerReviseCardInvocationV1) =>
|
|
875
|
+
Promise.resolve<PluginWorkerReviseCardResultV1>({
|
|
876
|
+
schemaVersion: 1,
|
|
877
|
+
status: "drop",
|
|
878
|
+
reason: `plugin "${invocation.pluginId}" did not mount in this generation`,
|
|
879
|
+
}),
|
|
880
|
+
drawCard: (pluginId: string) =>
|
|
881
|
+
Promise.resolve<PluginCardDrawOutcomeV1>({
|
|
882
|
+
status: "refused",
|
|
883
|
+
reason: `plugin "${pluginId}" did not mount in this generation`,
|
|
884
|
+
}),
|
|
885
|
+
executeTool: (invocation: PluginWorkerToolInvocationV1) =>
|
|
886
|
+
Promise.resolve<IsolateToolResultV1>({
|
|
887
|
+
schemaVersion: 1,
|
|
888
|
+
content: `plugin "${invocation.pluginId}" did not mount in this generation`,
|
|
889
|
+
isError: true,
|
|
890
|
+
}),
|
|
891
|
+
assembleTheme: (_payload, original) => Promise.resolve(original),
|
|
892
|
+
dispose: () => Promise.resolve(),
|
|
893
|
+
}),
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const loaderId = pluginWorkerLoaderIdV1({
|
|
898
|
+
userId: this.options.userId,
|
|
899
|
+
moduleSetHash: await pluginWorkerModuleSetHashV1({
|
|
900
|
+
contractVersion: ISOLATE_CONTRACT_VERSION,
|
|
901
|
+
indexVersion: PLUGIN_WORKER_INDEX_VERSION,
|
|
902
|
+
members: resolved.map(({ member }) => ({
|
|
903
|
+
pluginId: member.packageId,
|
|
904
|
+
contentHash: member.artifact.contentHash,
|
|
905
|
+
...pluginIdentityV1(member),
|
|
906
|
+
})),
|
|
907
|
+
bindingDigest: this.options.bindingDigest,
|
|
908
|
+
}),
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
// Mount and health-check are one guarded phase: `.get()` is lazy and never
|
|
912
|
+
// throws, so a broken module only surfaces on the first RPC.
|
|
913
|
+
let entrypoint: PluginWorkerEntrypoint;
|
|
914
|
+
let health;
|
|
915
|
+
try {
|
|
916
|
+
entrypoint = this.load(loaderId, resolved).getEntrypoint();
|
|
917
|
+
health = await pluginWorkerHealthV1(
|
|
918
|
+
this.options.loader,
|
|
919
|
+
loaderId,
|
|
920
|
+
async () =>
|
|
921
|
+
decodePluginWorkerHealthV1(
|
|
922
|
+
await raceDeadline(
|
|
923
|
+
() => entrypoint.health(),
|
|
924
|
+
Math.min(
|
|
925
|
+
this.options.healthDeadlineMs ??
|
|
926
|
+
BOT_ISOLATE_DEFAULT_HEALTH_DEADLINE_MS,
|
|
927
|
+
ISOLATE_MAX_DEADLINE_MS,
|
|
928
|
+
),
|
|
929
|
+
),
|
|
930
|
+
"plugin worker health",
|
|
931
|
+
),
|
|
932
|
+
);
|
|
933
|
+
} catch (error) {
|
|
934
|
+
// Site two: `LOADER.get` plus the first RPC. A module that does not
|
|
935
|
+
// parse fails the whole worker here, because the index imports every
|
|
936
|
+
// module and cannot say which one broke.
|
|
937
|
+
throw new CompositionMountFailureError(
|
|
938
|
+
"mount",
|
|
939
|
+
`the plugin worker failed to mount (plugins: ${resolved
|
|
940
|
+
.map(({ member }) => member.packageId)
|
|
941
|
+
.join(", ")}): ${errorMessage(error)}`,
|
|
942
|
+
resolved.map(({ member }) => `plugin:${member.packageId}`),
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
if (health.contractVersion !== ISOLATE_CONTRACT_VERSION) {
|
|
946
|
+
throw new CompositionMountFailureError(
|
|
947
|
+
"health",
|
|
948
|
+
`the plugin worker speaks contract ${health.contractVersion}, not ${ISOLATE_CONTRACT_VERSION}`,
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const verified: {
|
|
953
|
+
member: BotIsolateMemberV1;
|
|
954
|
+
health: PluginWorkerPluginHealthV1;
|
|
955
|
+
}[] = [];
|
|
956
|
+
for (const { member } of resolved) {
|
|
957
|
+
const reported = health.plugins.find(
|
|
958
|
+
(plugin) => plugin.pluginId === member.packageId,
|
|
959
|
+
);
|
|
960
|
+
const mismatch = reported
|
|
961
|
+
? this.healthMismatch(member.descriptor, reported)
|
|
962
|
+
: `plugin "${member.packageId}" is missing from the worker's health report`;
|
|
963
|
+
if (mismatch) {
|
|
964
|
+
failures.push({
|
|
965
|
+
pluginId: member.packageId,
|
|
966
|
+
phase: "health",
|
|
967
|
+
message: mismatch,
|
|
968
|
+
});
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
verified.push({ member, health: reported! });
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// A Plugin excluded at `health` still has its module in the index, so a
|
|
975
|
+
// consumer mounted after it would be handed its services. Drop those
|
|
976
|
+
// consumers too, naming the provider that did not survive; mount order
|
|
977
|
+
// already puts every provider ahead of its consumers.
|
|
978
|
+
const providerOf = new Map<string, string>();
|
|
979
|
+
for (const member of ordered.order) {
|
|
980
|
+
for (const service of member.descriptor.provides ?? []) {
|
|
981
|
+
providerOf.set(service.name, member.packageId);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
const surviving: typeof verified = [];
|
|
985
|
+
const live = new Set<string>();
|
|
986
|
+
for (const entry of verified) {
|
|
987
|
+
const pluginId = entry.member.packageId;
|
|
988
|
+
const broken = (entry.member.descriptor.consumes ?? []).find(
|
|
989
|
+
(service) => !live.has(providerOf.get(service.name)!),
|
|
990
|
+
);
|
|
991
|
+
if (broken) {
|
|
992
|
+
failures.push({
|
|
993
|
+
pluginId,
|
|
994
|
+
phase: "health",
|
|
995
|
+
message: `plugin "${pluginId}" consumes "${broken.name}", which "${providerOf.get(broken.name)}" did not mount`,
|
|
996
|
+
});
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
live.add(pluginId);
|
|
1000
|
+
surviving.push(entry);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
let disposed = false;
|
|
1004
|
+
const registered: (() => void)[] = [];
|
|
1005
|
+
const mounted = surviving.map(({ member }) => member.packageId);
|
|
1006
|
+
// The model provider contributions this Bot's selection runs. They are
|
|
1007
|
+
// chosen from what mounted and survived — never from the Bot's switch,
|
|
1008
|
+
// because selecting a provider is the decision that runs it — and only
|
|
1009
|
+
// for the one provider the selection named.
|
|
1010
|
+
const selectedProvider = this.options.selectedModelProvider;
|
|
1011
|
+
const modelProviders: MountedModelProviderV1[] =
|
|
1012
|
+
selectedProvider === undefined
|
|
1013
|
+
? []
|
|
1014
|
+
: surviving.flatMap(({ member }) => {
|
|
1015
|
+
const provider = pluginModelProviderV1(
|
|
1016
|
+
member.descriptor,
|
|
1017
|
+
selectedProvider,
|
|
1018
|
+
);
|
|
1019
|
+
return provider === undefined
|
|
1020
|
+
? []
|
|
1021
|
+
: [
|
|
1022
|
+
{
|
|
1023
|
+
pluginId: member.packageId,
|
|
1024
|
+
providerId: provider.id,
|
|
1025
|
+
protocolVersion: provider.protocolVersion,
|
|
1026
|
+
},
|
|
1027
|
+
];
|
|
1028
|
+
});
|
|
1029
|
+
// Of the Plugins that mounted and survived, this Bot runs the ones its
|
|
1030
|
+
// own enable map allows. The others stay in the worker — it is per User
|
|
1031
|
+
// — but register no tools here and are left out of every hook's list.
|
|
1032
|
+
const enabledHere = this.options.enabled;
|
|
1033
|
+
const running = surviving.filter(
|
|
1034
|
+
({ member }) =>
|
|
1035
|
+
enabledHere === undefined || enabledHere.includes(member.packageId),
|
|
1036
|
+
);
|
|
1037
|
+
const enabled = running.map(({ member }) => member.packageId);
|
|
1038
|
+
return {
|
|
1039
|
+
mounted,
|
|
1040
|
+
failures,
|
|
1041
|
+
commit: (): Promise<ActivePluginWorker> => {
|
|
1042
|
+
for (const { member, health: plugin } of running) {
|
|
1043
|
+
registered.push(
|
|
1044
|
+
this.options.tools.registerNamespace({
|
|
1045
|
+
name: member.packageId,
|
|
1046
|
+
// Not external: `external` exists to force a human-readable
|
|
1047
|
+
// reason onto a call that leaves for a third-party MCP server.
|
|
1048
|
+
// A Plugin is this account's own code reached over no network.
|
|
1049
|
+
external: false,
|
|
1050
|
+
// A Plugin cannot confer `read` on itself: only the deployment's
|
|
1051
|
+
// own seeded artifacts go unreviewed.
|
|
1052
|
+
effect:
|
|
1053
|
+
this.options.deploymentArtifact?.(
|
|
1054
|
+
member.artifact.contentHash,
|
|
1055
|
+
) === true
|
|
1056
|
+
? "read"
|
|
1057
|
+
: "mutate",
|
|
1058
|
+
status: "ready",
|
|
1059
|
+
}),
|
|
1060
|
+
);
|
|
1061
|
+
for (const tool of plugin.tools) {
|
|
1062
|
+
registered.push(
|
|
1063
|
+
this.options.tools.register(
|
|
1064
|
+
this.definition(member.packageId, entrypoint, tool),
|
|
1065
|
+
),
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
// One tool per declared card, in the same namespace as the Plugin's
|
|
1069
|
+
// own tools: a card is something the Bot asks this Plugin to draw.
|
|
1070
|
+
for (const card of member.descriptor.cards ?? []) {
|
|
1071
|
+
const definition = this.cardDefinition(
|
|
1072
|
+
member.packageId,
|
|
1073
|
+
entrypoint,
|
|
1074
|
+
card,
|
|
1075
|
+
);
|
|
1076
|
+
if (definition)
|
|
1077
|
+
registered.push(this.options.tools.register(definition));
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
const declaring = new Map<BotIsolateHookEventNameV1, string[]>();
|
|
1081
|
+
for (const { member, health: plugin } of running) {
|
|
1082
|
+
for (const event of plugin.hooks) {
|
|
1083
|
+
declaring.set(event, [
|
|
1084
|
+
...(declaring.get(event) ?? []),
|
|
1085
|
+
member.packageId,
|
|
1086
|
+
]);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
for (const [event, plugins] of declaring) {
|
|
1090
|
+
registered.push(
|
|
1091
|
+
this.registerHook(entrypoint, enabled, plugins, event),
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
return Promise.resolve({
|
|
1095
|
+
modelProviders,
|
|
1096
|
+
streamModel: async (
|
|
1097
|
+
invocation: PluginModelInvocationV1,
|
|
1098
|
+
): Promise<PluginWorkerModelResultV1> => {
|
|
1099
|
+
const refuse = (reason: string): PluginWorkerModelResultV1 => ({
|
|
1100
|
+
schemaVersion: 1,
|
|
1101
|
+
status: "refused",
|
|
1102
|
+
reason: reason.slice(0, MAX_FAILURE_REASON_V1),
|
|
1103
|
+
});
|
|
1104
|
+
if (disposed) {
|
|
1105
|
+
return refuse(
|
|
1106
|
+
"the plugin worker for this generation is no longer mounted",
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
const serving = modelProviders.find(
|
|
1110
|
+
(provider) => provider.pluginId === invocation.pluginId,
|
|
1111
|
+
);
|
|
1112
|
+
if (!serving || !live.has(invocation.pluginId)) {
|
|
1113
|
+
return refuse(
|
|
1114
|
+
`plugin "${invocation.pluginId}" did not mount in this generation`,
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
if (invocation.provider !== serving.providerId) {
|
|
1118
|
+
return refuse(
|
|
1119
|
+
`plugin "${invocation.pluginId}" serves "${serving.providerId}", not "${invocation.provider}"`,
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
const deadlineMs = Math.min(
|
|
1123
|
+
invocation.deadlineMs,
|
|
1124
|
+
ISOLATE_MAX_DEADLINE_MS - PLUGIN_WORKER_HOOK_RACE_MARGIN_MS,
|
|
1125
|
+
);
|
|
1126
|
+
try {
|
|
1127
|
+
return decodePluginWorkerModelResultV1(
|
|
1128
|
+
await raceDeadline(
|
|
1129
|
+
() => entrypoint.streamModel({ ...invocation, deadlineMs }),
|
|
1130
|
+
deadlineMs + PLUGIN_WORKER_HOOK_RACE_MARGIN_MS,
|
|
1131
|
+
),
|
|
1132
|
+
`plugin "${invocation.pluginId}" model result`,
|
|
1133
|
+
);
|
|
1134
|
+
} catch (error) {
|
|
1135
|
+
return refuse(errorMessage(error));
|
|
1136
|
+
}
|
|
1137
|
+
},
|
|
1138
|
+
deliverTrigger: async (
|
|
1139
|
+
invocation: PluginWorkerTriggerInvocationV1,
|
|
1140
|
+
): Promise<PluginWorkerTriggerResultV1> => {
|
|
1141
|
+
if (disposed) {
|
|
1142
|
+
return droppedTrigger(
|
|
1143
|
+
invocation.pluginId,
|
|
1144
|
+
"the plugin worker for this generation is no longer mounted",
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
if (!live.has(invocation.pluginId)) {
|
|
1148
|
+
return droppedTrigger(invocation.pluginId);
|
|
1149
|
+
}
|
|
1150
|
+
// The worker gets the whole budget the caller asked for, less the
|
|
1151
|
+
// margin the host keeps for the answer's return trip, so a trigger
|
|
1152
|
+
// that spends its budget still answers before the race fires.
|
|
1153
|
+
const deadlineMs = Math.min(
|
|
1154
|
+
invocation.deadlineMs,
|
|
1155
|
+
ISOLATE_MAX_DEADLINE_MS - PLUGIN_WORKER_HOOK_RACE_MARGIN_MS,
|
|
1156
|
+
);
|
|
1157
|
+
try {
|
|
1158
|
+
const raw = await raceDeadline(
|
|
1159
|
+
() => entrypoint.receiveTrigger({ ...invocation, deadlineMs }),
|
|
1160
|
+
deadlineMs + PLUGIN_WORKER_HOOK_RACE_MARGIN_MS,
|
|
1161
|
+
);
|
|
1162
|
+
const oversized = firedTextBytes(raw) > MAX_TRIGGER_BODY_BYTES_V1;
|
|
1163
|
+
if (oversized) {
|
|
1164
|
+
return droppedTrigger(
|
|
1165
|
+
invocation.pluginId,
|
|
1166
|
+
`plugin "${invocation.pluginId}" fired a trigger body over the ${MAX_TRIGGER_BODY_BYTES_V1} byte limit`,
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
return decodePluginWorkerTriggerResultV1(
|
|
1170
|
+
raw,
|
|
1171
|
+
`plugin "${invocation.pluginId}" trigger result`,
|
|
1172
|
+
);
|
|
1173
|
+
} catch (error) {
|
|
1174
|
+
return droppedTrigger(invocation.pluginId, errorMessage(error));
|
|
1175
|
+
}
|
|
1176
|
+
},
|
|
1177
|
+
renderView: async (
|
|
1178
|
+
invocation: PluginWorkerViewInvocationV1,
|
|
1179
|
+
): Promise<PluginWorkerViewResultV1> => {
|
|
1180
|
+
const drop = (reason: string): PluginWorkerViewResultV1 => ({
|
|
1181
|
+
schemaVersion: 1,
|
|
1182
|
+
status: "drop",
|
|
1183
|
+
reason: reason.slice(0, MAX_FAILURE_REASON_V1),
|
|
1184
|
+
});
|
|
1185
|
+
if (disposed) {
|
|
1186
|
+
return drop(
|
|
1187
|
+
"the plugin worker for this generation is no longer mounted",
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
if (!live.has(invocation.pluginId)) {
|
|
1191
|
+
return drop(
|
|
1192
|
+
`plugin "${invocation.pluginId}" did not mount in this generation`,
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
const deadlineMs = Math.min(
|
|
1196
|
+
invocation.deadlineMs,
|
|
1197
|
+
ISOLATE_MAX_DEADLINE_MS - PLUGIN_WORKER_HOOK_RACE_MARGIN_MS,
|
|
1198
|
+
);
|
|
1199
|
+
try {
|
|
1200
|
+
const raw = await raceDeadline(
|
|
1201
|
+
() => entrypoint.view({ ...invocation, deadlineMs }),
|
|
1202
|
+
deadlineMs + PLUGIN_WORKER_HOOK_RACE_MARGIN_MS,
|
|
1203
|
+
);
|
|
1204
|
+
return decodePluginWorkerViewResultV1(
|
|
1205
|
+
raw,
|
|
1206
|
+
`plugin "${invocation.pluginId}" view result`,
|
|
1207
|
+
);
|
|
1208
|
+
} catch (error) {
|
|
1209
|
+
return drop(errorMessage(error));
|
|
1210
|
+
}
|
|
1211
|
+
},
|
|
1212
|
+
cardAction: async (
|
|
1213
|
+
invocation: PluginWorkerCardActionInvocationV1,
|
|
1214
|
+
): Promise<PluginWorkerCardActionResultV1> => {
|
|
1215
|
+
const drop = (reason: string): PluginWorkerCardActionResultV1 => ({
|
|
1216
|
+
schemaVersion: 1,
|
|
1217
|
+
status: "drop",
|
|
1218
|
+
reason: reason.slice(0, MAX_FAILURE_REASON_V1),
|
|
1219
|
+
});
|
|
1220
|
+
if (disposed) {
|
|
1221
|
+
return drop(
|
|
1222
|
+
"the plugin worker for this generation is no longer mounted",
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
if (!live.has(invocation.pluginId)) {
|
|
1226
|
+
return drop(
|
|
1227
|
+
`plugin "${invocation.pluginId}" did not mount in this generation`,
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
const deadlineMs = Math.min(
|
|
1231
|
+
invocation.deadlineMs,
|
|
1232
|
+
ISOLATE_MAX_DEADLINE_MS - PLUGIN_WORKER_HOOK_RACE_MARGIN_MS,
|
|
1233
|
+
);
|
|
1234
|
+
try {
|
|
1235
|
+
const raw = await raceDeadline(
|
|
1236
|
+
() => entrypoint.cardAction({ ...invocation, deadlineMs }),
|
|
1237
|
+
deadlineMs + PLUGIN_WORKER_HOOK_RACE_MARGIN_MS,
|
|
1238
|
+
);
|
|
1239
|
+
return decodePluginWorkerCardActionResultV1(
|
|
1240
|
+
raw,
|
|
1241
|
+
`plugin "${invocation.pluginId}" card action result`,
|
|
1242
|
+
);
|
|
1243
|
+
} catch (error) {
|
|
1244
|
+
return drop(errorMessage(error));
|
|
1245
|
+
}
|
|
1246
|
+
},
|
|
1247
|
+
reviseCard: async (
|
|
1248
|
+
invocation: PluginWorkerReviseCardInvocationV1,
|
|
1249
|
+
): Promise<PluginWorkerReviseCardResultV1> => {
|
|
1250
|
+
const drop = (reason: string): PluginWorkerReviseCardResultV1 => ({
|
|
1251
|
+
schemaVersion: 1,
|
|
1252
|
+
status: "drop",
|
|
1253
|
+
reason: reason.slice(0, MAX_FAILURE_REASON_V1),
|
|
1254
|
+
});
|
|
1255
|
+
if (disposed) {
|
|
1256
|
+
return drop(
|
|
1257
|
+
"the plugin worker for this generation is no longer mounted",
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
if (!live.has(invocation.pluginId)) {
|
|
1261
|
+
return drop(
|
|
1262
|
+
`plugin "${invocation.pluginId}" did not mount in this generation`,
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
const deadlineMs = Math.min(
|
|
1266
|
+
invocation.deadlineMs,
|
|
1267
|
+
ISOLATE_MAX_DEADLINE_MS - PLUGIN_WORKER_HOOK_RACE_MARGIN_MS,
|
|
1268
|
+
);
|
|
1269
|
+
try {
|
|
1270
|
+
const raw = await raceDeadline(
|
|
1271
|
+
() => entrypoint.reviseCard({ ...invocation, deadlineMs }),
|
|
1272
|
+
deadlineMs + PLUGIN_WORKER_HOOK_RACE_MARGIN_MS,
|
|
1273
|
+
);
|
|
1274
|
+
return decodePluginWorkerReviseCardResultV1(
|
|
1275
|
+
raw,
|
|
1276
|
+
`plugin "${invocation.pluginId}" revise card result`,
|
|
1277
|
+
);
|
|
1278
|
+
} catch (error) {
|
|
1279
|
+
return drop(errorMessage(error));
|
|
1280
|
+
}
|
|
1281
|
+
},
|
|
1282
|
+
drawCard: async (
|
|
1283
|
+
pluginId: string,
|
|
1284
|
+
cardId: string,
|
|
1285
|
+
request: PluginCardDrawRequestV1,
|
|
1286
|
+
context: ToolExecutionContext,
|
|
1287
|
+
): Promise<PluginCardDrawOutcomeV1> => {
|
|
1288
|
+
if (disposed) {
|
|
1289
|
+
return {
|
|
1290
|
+
status: "refused",
|
|
1291
|
+
reason:
|
|
1292
|
+
"the plugin worker for this generation is no longer mounted",
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
// Off the Bot's own running set, not the mounted one: a Plugin
|
|
1296
|
+
// this Bot does not run draws nothing here either, and a card it
|
|
1297
|
+
// does not declare is not a card.
|
|
1298
|
+
const card = running
|
|
1299
|
+
.find((candidate) => candidate.member.packageId === pluginId)
|
|
1300
|
+
?.member.descriptor.cards?.find(
|
|
1301
|
+
(candidate) => candidate.id === cardId,
|
|
1302
|
+
);
|
|
1303
|
+
if (!card) {
|
|
1304
|
+
return {
|
|
1305
|
+
status: "refused",
|
|
1306
|
+
reason: `plugin "${pluginId}" draws no card "${cardId}" for this Bot`,
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
return this.drawCard(pluginId, entrypoint, card, request, context);
|
|
1310
|
+
},
|
|
1311
|
+
executeTool: async (
|
|
1312
|
+
invocation: PluginWorkerToolInvocationV1,
|
|
1313
|
+
): Promise<IsolateToolResultV1> => {
|
|
1314
|
+
const refuse = (content: string): IsolateToolResultV1 => ({
|
|
1315
|
+
schemaVersion: 1,
|
|
1316
|
+
content: content.slice(0, MAX_FAILURE_REASON_V1),
|
|
1317
|
+
isError: true,
|
|
1318
|
+
});
|
|
1319
|
+
if (disposed) {
|
|
1320
|
+
return refuse(
|
|
1321
|
+
"the plugin worker for this generation is no longer mounted",
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
const entry = surviving.find(
|
|
1325
|
+
(candidate) => candidate.member.packageId === invocation.pluginId,
|
|
1326
|
+
);
|
|
1327
|
+
if (!entry) {
|
|
1328
|
+
return refuse(
|
|
1329
|
+
`plugin "${invocation.pluginId}" did not mount in this generation`,
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
if (
|
|
1333
|
+
!entry.health.tools.some((tool) => tool.name === invocation.tool)
|
|
1334
|
+
) {
|
|
1335
|
+
return refuse(
|
|
1336
|
+
`plugin "${invocation.pluginId}" declares no tool "${invocation.tool}"`,
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
const deadlineMs = Math.min(
|
|
1340
|
+
invocation.deadlineMs,
|
|
1341
|
+
ISOLATE_MAX_DEADLINE_MS - PLUGIN_WORKER_HOOK_RACE_MARGIN_MS,
|
|
1342
|
+
);
|
|
1343
|
+
try {
|
|
1344
|
+
const raw = await raceDeadline(
|
|
1345
|
+
() => entrypoint.execute({ ...invocation, deadlineMs }),
|
|
1346
|
+
deadlineMs + PLUGIN_WORKER_HOOK_RACE_MARGIN_MS,
|
|
1347
|
+
);
|
|
1348
|
+
return decodeIsolateToolResultV1(
|
|
1349
|
+
raw,
|
|
1350
|
+
`plugin "${invocation.pluginId}" tool result`,
|
|
1351
|
+
);
|
|
1352
|
+
} catch (error) {
|
|
1353
|
+
return refuse(
|
|
1354
|
+
`Tool "${invocation.tool}" failed in its plugin: ${errorMessage(error)}`,
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
},
|
|
1358
|
+
assembleTheme: async (payload, original) => {
|
|
1359
|
+
if (disposed) return original;
|
|
1360
|
+
const plugins = declaring.get("theme/assemble") ?? [];
|
|
1361
|
+
if (plugins.length === 0) return original;
|
|
1362
|
+
return this.invokeHook(
|
|
1363
|
+
entrypoint,
|
|
1364
|
+
enabled,
|
|
1365
|
+
plugins,
|
|
1366
|
+
"theme/assemble",
|
|
1367
|
+
payload,
|
|
1368
|
+
original,
|
|
1369
|
+
);
|
|
1370
|
+
},
|
|
1371
|
+
dispose: () => {
|
|
1372
|
+
if (disposed) return Promise.resolve();
|
|
1373
|
+
disposed = true;
|
|
1374
|
+
for (const unregister of registered.toReversed()) unregister();
|
|
1375
|
+
return Promise.resolve();
|
|
1376
|
+
},
|
|
1377
|
+
});
|
|
1378
|
+
},
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
private refusal(member: BotIsolateMemberV1): string | undefined {
|
|
1383
|
+
const descriptor = member.descriptor;
|
|
1384
|
+
const pluginId = member.packageId;
|
|
1385
|
+
if (descriptor.id !== pluginId || descriptor.version !== member.version) {
|
|
1386
|
+
return `plugin "${pluginId}" descriptor does not match its Composition member`;
|
|
1387
|
+
}
|
|
1388
|
+
// A plugin built against a contract this deployment no longer serves is
|
|
1389
|
+
// disabled here with the reason, never rebuilt silently.
|
|
1390
|
+
if (
|
|
1391
|
+
descriptor.contractVersion !== ISOLATE_CONTRACT_VERSION &&
|
|
1392
|
+
descriptor.contractVersion !== ISOLATE_CONTRACT_VERSION - 1
|
|
1393
|
+
) {
|
|
1394
|
+
return `plugin "${pluginId}" was built against contract ${descriptor.contractVersion}, which this deployment no longer serves`;
|
|
1395
|
+
}
|
|
1396
|
+
const closedGrants = descriptor.grants.filter(
|
|
1397
|
+
(grant) => !OPEN_PLUGIN_GRANTS_V1.includes(grant),
|
|
1398
|
+
);
|
|
1399
|
+
if (closedGrants.length > 0) {
|
|
1400
|
+
return `plugin "${pluginId}" declares grants this deployment has not opened: ${closedGrants.join(", ")}`;
|
|
1401
|
+
}
|
|
1402
|
+
const closedSlots = [
|
|
1403
|
+
...(descriptor.slots ?? []),
|
|
1404
|
+
...(descriptor.views ?? []).map((view) => view.slot),
|
|
1405
|
+
].filter((slot) => !OPEN_PLUGIN_SLOTS_V1.includes(slot));
|
|
1406
|
+
if (closedSlots.length > 0) {
|
|
1407
|
+
return `plugin "${pluginId}" declares slots this deployment has not opened: ${[...new Set(closedSlots)].join(", ")}`;
|
|
1408
|
+
}
|
|
1409
|
+
const open = new Set(
|
|
1410
|
+
(this.options.openModelProviders ?? []).map((claim) => claim.provider),
|
|
1411
|
+
);
|
|
1412
|
+
const closedProviders = (descriptor.modelProviders ?? []).filter(
|
|
1413
|
+
(provider) => !open.has(provider.id),
|
|
1414
|
+
);
|
|
1415
|
+
if (closedProviders.length > 0) {
|
|
1416
|
+
return `plugin "${pluginId}" serves model providers this deployment does not open to plugins: ${closedProviders
|
|
1417
|
+
.map((provider) => provider.id)
|
|
1418
|
+
.join(", ")}`;
|
|
1419
|
+
}
|
|
1420
|
+
const unserved = (descriptor.modelProviders ?? []).find(
|
|
1421
|
+
(provider) =>
|
|
1422
|
+
!PLUGIN_MODEL_PROTOCOL_VERSIONS_V1.includes(provider.protocolVersion),
|
|
1423
|
+
);
|
|
1424
|
+
if (unserved) {
|
|
1425
|
+
return `plugin "${pluginId}" serves model provider "${unserved.id}" over protocol ${unserved.protocolVersion}, which this deployment does not serve`;
|
|
1426
|
+
}
|
|
1427
|
+
return undefined;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
private healthMismatch(
|
|
1431
|
+
descriptor: PluginDescriptorV1,
|
|
1432
|
+
reported: PluginWorkerPluginHealthV1,
|
|
1433
|
+
): string | undefined {
|
|
1434
|
+
const pluginId = descriptor.id;
|
|
1435
|
+
if (!reported.ok) {
|
|
1436
|
+
return `plugin "${pluginId}" failed to mount in the worker: ${reported.reason}`;
|
|
1437
|
+
}
|
|
1438
|
+
const declaredTools = descriptor.tools.map((tool) => tool.name).toSorted();
|
|
1439
|
+
const reportedTools = reported.tools.map((tool) => tool.name).toSorted();
|
|
1440
|
+
if (
|
|
1441
|
+
declaredTools.length !== reportedTools.length ||
|
|
1442
|
+
declaredTools.some((name, index) => name !== reportedTools[index])
|
|
1443
|
+
) {
|
|
1444
|
+
return `plugin "${pluginId}" tools do not match its declared tools (declared:${declaredTools.join(",")} reported:${reportedTools.join(",")})`;
|
|
1445
|
+
}
|
|
1446
|
+
const declaredHooks = [...descriptor.hooks].toSorted();
|
|
1447
|
+
const reportedHooks = [...reported.hooks].toSorted();
|
|
1448
|
+
if (
|
|
1449
|
+
declaredHooks.length !== reportedHooks.length ||
|
|
1450
|
+
declaredHooks.some((name, index) => name !== reportedHooks[index])
|
|
1451
|
+
) {
|
|
1452
|
+
return `plugin "${pluginId}" hooks do not match its declared hooks (declared:${declaredHooks.join(",")} reported:${reportedHooks.join(",")})`;
|
|
1453
|
+
}
|
|
1454
|
+
const declaredProvides = (descriptor.provides ?? [])
|
|
1455
|
+
.map((service) => service.name)
|
|
1456
|
+
.toSorted();
|
|
1457
|
+
const reportedProvides = reported.provides
|
|
1458
|
+
.map((service) => service.name)
|
|
1459
|
+
.toSorted();
|
|
1460
|
+
if (
|
|
1461
|
+
declaredProvides.length !== reportedProvides.length ||
|
|
1462
|
+
declaredProvides.some((name, index) => name !== reportedProvides[index])
|
|
1463
|
+
) {
|
|
1464
|
+
return `plugin "${pluginId}" services do not match its declared provides (declared:${declaredProvides.join(",")} reported:${reportedProvides.join(",")})`;
|
|
1465
|
+
}
|
|
1466
|
+
const declaredTriggers = (descriptor.triggers ?? [])
|
|
1467
|
+
.map((trigger) => trigger.name)
|
|
1468
|
+
.toSorted();
|
|
1469
|
+
const reportedTriggers = [...reported.triggers].toSorted();
|
|
1470
|
+
if (
|
|
1471
|
+
declaredTriggers.length !== reportedTriggers.length ||
|
|
1472
|
+
declaredTriggers.some((name, index) => name !== reportedTriggers[index])
|
|
1473
|
+
) {
|
|
1474
|
+
return `plugin "${pluginId}" triggers do not match its declared triggers (declared:${declaredTriggers.join(",")} reported:${reportedTriggers.join(",")})`;
|
|
1475
|
+
}
|
|
1476
|
+
// A card and the actions it owns, as one comparable line each: a press
|
|
1477
|
+
// names no card, so a module owning an action the descriptor puts on
|
|
1478
|
+
// another card — or on no card at all — would route a press to a handler
|
|
1479
|
+
// the descriptor never said owned it.
|
|
1480
|
+
const cardLine = (card: { id: string; actions: readonly string[] }) =>
|
|
1481
|
+
`${card.id}(${[...card.actions].toSorted().join("|")})`;
|
|
1482
|
+
const declaredCards = (descriptor.cards ?? [])
|
|
1483
|
+
.map((card) =>
|
|
1484
|
+
cardLine({
|
|
1485
|
+
id: card.id,
|
|
1486
|
+
actions: card.actions.map((action) => action.name),
|
|
1487
|
+
}),
|
|
1488
|
+
)
|
|
1489
|
+
.toSorted();
|
|
1490
|
+
const reportedCards = reported.cards.map(cardLine).toSorted();
|
|
1491
|
+
if (
|
|
1492
|
+
declaredCards.length !== reportedCards.length ||
|
|
1493
|
+
declaredCards.some((name, index) => name !== reportedCards[index])
|
|
1494
|
+
) {
|
|
1495
|
+
return `plugin "${pluginId}" cards do not match its declared cards (declared:${declaredCards.join(",")} reported:${reportedCards.join(",")})`;
|
|
1496
|
+
}
|
|
1497
|
+
const declaredViews = (descriptor.views ?? [])
|
|
1498
|
+
.map((view) => view.surfaceId)
|
|
1499
|
+
.toSorted();
|
|
1500
|
+
const reportedViews = [...reported.views].toSorted();
|
|
1501
|
+
if (
|
|
1502
|
+
declaredViews.length !== reportedViews.length ||
|
|
1503
|
+
declaredViews.some((name, index) => name !== reportedViews[index])
|
|
1504
|
+
) {
|
|
1505
|
+
return `plugin "${pluginId}" views do not match its declared views (declared:${declaredViews.join(",")} reported:${reportedViews.join(",")})`;
|
|
1506
|
+
}
|
|
1507
|
+
// A provider the descriptor declares and the module does not serve would
|
|
1508
|
+
// mount a contribution whose one method is missing; the reverse would run
|
|
1509
|
+
// provider code the descriptor, the card and the User never saw.
|
|
1510
|
+
const declaredProviders = (descriptor.modelProviders ?? [])
|
|
1511
|
+
.map((provider) => provider.id)
|
|
1512
|
+
.toSorted();
|
|
1513
|
+
const reportedProviders = [...reported.modelProviders].toSorted();
|
|
1514
|
+
if (
|
|
1515
|
+
declaredProviders.length !== reportedProviders.length ||
|
|
1516
|
+
declaredProviders.some((name, index) => name !== reportedProviders[index])
|
|
1517
|
+
) {
|
|
1518
|
+
return `plugin "${pluginId}" model providers do not match its declared model providers (declared:${declaredProviders.join(",")} reported:${reportedProviders.join(",")})`;
|
|
1519
|
+
}
|
|
1520
|
+
return undefined;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
private load(
|
|
1524
|
+
loaderId: string,
|
|
1525
|
+
resolved: readonly ResolvedPlugin[],
|
|
1526
|
+
): BotIsolateLoadedWorker {
|
|
1527
|
+
const limits = this.options.limits ?? BOT_ISOLATE_DEFAULT_LIMITS;
|
|
1528
|
+
// Nothing per Turn or per Bot: the worker is one per User, and a loader
|
|
1529
|
+
// id is served with the `env` it was first loaded with.
|
|
1530
|
+
const identity: IsolateIdentityV1 = {
|
|
1531
|
+
userId: this.options.userId,
|
|
1532
|
+
plugins: resolved.map(({ member }) => ({
|
|
1533
|
+
pluginId: member.packageId,
|
|
1534
|
+
...pluginIdentityV1(member),
|
|
1535
|
+
})),
|
|
1536
|
+
};
|
|
1537
|
+
return this.options.loader.get(loaderId, () =>
|
|
1538
|
+
Promise.resolve({
|
|
1539
|
+
compatibilityDate: this.options.compatibilityDate,
|
|
1540
|
+
mainModule: PLUGIN_WORKER_MAIN_MODULE,
|
|
1541
|
+
modules: pluginWorkerModuleMap(
|
|
1542
|
+
resolved.map(({ member, source }) => ({
|
|
1543
|
+
pluginId: member.packageId,
|
|
1544
|
+
source,
|
|
1545
|
+
})),
|
|
1546
|
+
),
|
|
1547
|
+
// The constitution's rule, made mechanical: no network except what
|
|
1548
|
+
// the egress loopback admits, and none at all without one.
|
|
1549
|
+
globalOutbound: (this.options.egress ?? null) as null,
|
|
1550
|
+
env: { IDENTITY: identity, CAPABILITIES: this.options.capabilities },
|
|
1551
|
+
limits,
|
|
1552
|
+
}),
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
private agentSnapshot(agent: LoopAgentRuntimeV1) {
|
|
1557
|
+
return {
|
|
1558
|
+
botId: agent.botId,
|
|
1559
|
+
agentId: agent.id,
|
|
1560
|
+
sessionId: agent.session.id,
|
|
1561
|
+
status: agent.status,
|
|
1562
|
+
} as const;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
private stepSnapshot(
|
|
1566
|
+
agent: LoopAgentRuntimeV1,
|
|
1567
|
+
turn: number,
|
|
1568
|
+
step: number,
|
|
1569
|
+
): LoopStepSnapshotV1 {
|
|
1570
|
+
return {
|
|
1571
|
+
...this.agentSnapshot(agent),
|
|
1572
|
+
compositionGenerationId: this.options.generationId,
|
|
1573
|
+
turn,
|
|
1574
|
+
step,
|
|
1575
|
+
turnType: this.options.turnType,
|
|
1576
|
+
...(this.options.subagentRole === undefined
|
|
1577
|
+
? {}
|
|
1578
|
+
: { subagentRole: this.options.subagentRole }),
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
private registerHook(
|
|
1583
|
+
entrypoint: PluginWorkerEntrypoint,
|
|
1584
|
+
enabled: readonly string[],
|
|
1585
|
+
declaring: readonly string[],
|
|
1586
|
+
event: BotIsolateHookEventNameV1,
|
|
1587
|
+
): () => void {
|
|
1588
|
+
const hooks = this.options.hooks;
|
|
1589
|
+
// Every hook lets the app's own policy run first and then offers the
|
|
1590
|
+
// Plugins the result, fenced to this Bot and this generation.
|
|
1591
|
+
switch (event) {
|
|
1592
|
+
case "system-prompt/assemble":
|
|
1593
|
+
return hooks.add({
|
|
1594
|
+
assemblePrompt: async (context, next) => {
|
|
1595
|
+
const current = await next();
|
|
1596
|
+
return this.invokeHook(
|
|
1597
|
+
entrypoint,
|
|
1598
|
+
enabled,
|
|
1599
|
+
declaring,
|
|
1600
|
+
event,
|
|
1601
|
+
{ context: structuredClone(context), assembly: current },
|
|
1602
|
+
current,
|
|
1603
|
+
);
|
|
1604
|
+
},
|
|
1605
|
+
});
|
|
1606
|
+
case "agent/tool-exposure":
|
|
1607
|
+
return hooks.add({
|
|
1608
|
+
toolExposure: async (agent, _tools, turn, step, signal, next) => {
|
|
1609
|
+
const current = await next();
|
|
1610
|
+
if (agent.botId !== this.options.botId) return current;
|
|
1611
|
+
return this.invokeHook(
|
|
1612
|
+
entrypoint,
|
|
1613
|
+
enabled,
|
|
1614
|
+
declaring,
|
|
1615
|
+
event,
|
|
1616
|
+
{ step: this.stepSnapshot(agent, turn, step), tools: current },
|
|
1617
|
+
current,
|
|
1618
|
+
signal,
|
|
1619
|
+
);
|
|
1620
|
+
},
|
|
1621
|
+
});
|
|
1622
|
+
case "agent/request":
|
|
1623
|
+
return hooks.add({
|
|
1624
|
+
request: async (agent, _request, turn, step, signal, next) => {
|
|
1625
|
+
const current = await next();
|
|
1626
|
+
if (agent.botId !== this.options.botId) return current;
|
|
1627
|
+
return this.invokeHook(
|
|
1628
|
+
entrypoint,
|
|
1629
|
+
enabled,
|
|
1630
|
+
declaring,
|
|
1631
|
+
event,
|
|
1632
|
+
{ step: this.stepSnapshot(agent, turn, step), request: current },
|
|
1633
|
+
current,
|
|
1634
|
+
signal,
|
|
1635
|
+
);
|
|
1636
|
+
},
|
|
1637
|
+
});
|
|
1638
|
+
case "tools/pre-execute":
|
|
1639
|
+
return hooks.add({
|
|
1640
|
+
prepareTool: async (call, context, next) => {
|
|
1641
|
+
const current = await next();
|
|
1642
|
+
if (
|
|
1643
|
+
context.botId !== this.options.botId ||
|
|
1644
|
+
context.compositionGenerationId !== this.options.generationId
|
|
1645
|
+
) {
|
|
1646
|
+
return current;
|
|
1647
|
+
}
|
|
1648
|
+
return this.invokeHook(
|
|
1649
|
+
entrypoint,
|
|
1650
|
+
enabled,
|
|
1651
|
+
declaring,
|
|
1652
|
+
event,
|
|
1653
|
+
{
|
|
1654
|
+
call,
|
|
1655
|
+
context: loopToolExecutionContextSnapshotV1(context),
|
|
1656
|
+
preparation: current,
|
|
1657
|
+
},
|
|
1658
|
+
current,
|
|
1659
|
+
context.signal,
|
|
1660
|
+
);
|
|
1661
|
+
},
|
|
1662
|
+
});
|
|
1663
|
+
case "tools/post-execute":
|
|
1664
|
+
return hooks.add({
|
|
1665
|
+
toolResult: async (call, _result, context, next) => {
|
|
1666
|
+
const current = await next();
|
|
1667
|
+
if (
|
|
1668
|
+
context.botId !== this.options.botId ||
|
|
1669
|
+
context.compositionGenerationId !== this.options.generationId
|
|
1670
|
+
) {
|
|
1671
|
+
return current;
|
|
1672
|
+
}
|
|
1673
|
+
return this.invokeHook(
|
|
1674
|
+
entrypoint,
|
|
1675
|
+
enabled,
|
|
1676
|
+
declaring,
|
|
1677
|
+
event,
|
|
1678
|
+
{
|
|
1679
|
+
call,
|
|
1680
|
+
context: loopToolExecutionContextSnapshotV1(context),
|
|
1681
|
+
result: current,
|
|
1682
|
+
},
|
|
1683
|
+
current,
|
|
1684
|
+
context.signal,
|
|
1685
|
+
);
|
|
1686
|
+
},
|
|
1687
|
+
});
|
|
1688
|
+
case "agent/turn-stopping":
|
|
1689
|
+
return hooks.add({
|
|
1690
|
+
turnStopping: async (agent, turn) => {
|
|
1691
|
+
if (agent.botId !== this.options.botId) return;
|
|
1692
|
+
// A notification, not a waterfall: the Plugins are told the Turn is
|
|
1693
|
+
// settling and have nothing to replace. `invokeHook` still records
|
|
1694
|
+
// a failure and returns, so a slow or broken Plugin cannot hold up
|
|
1695
|
+
// settlement.
|
|
1696
|
+
await this.invokeHook(
|
|
1697
|
+
entrypoint,
|
|
1698
|
+
enabled,
|
|
1699
|
+
declaring,
|
|
1700
|
+
event,
|
|
1701
|
+
{ agent: this.agentSnapshot(agent), turn },
|
|
1702
|
+
undefined,
|
|
1703
|
+
);
|
|
1704
|
+
},
|
|
1705
|
+
});
|
|
1706
|
+
case "theme/assemble":
|
|
1707
|
+
// Not a loop event. `assembleTheme` on the active worker is the
|
|
1708
|
+
// only caller; registering a listener here would never fire.
|
|
1709
|
+
return () => {};
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
private async invokeHook<Event extends BotIsolateHookEventNameV1>(
|
|
1714
|
+
entrypoint: PluginWorkerEntrypoint,
|
|
1715
|
+
enabled: readonly string[],
|
|
1716
|
+
declaring: readonly string[],
|
|
1717
|
+
event: Event,
|
|
1718
|
+
payload: LoopEventPayloadMapV1[Event],
|
|
1719
|
+
original: LoopEventReturnMapV1[Event],
|
|
1720
|
+
signal?: AbortSignal,
|
|
1721
|
+
): Promise<LoopEventReturnMapV1[Event]> {
|
|
1722
|
+
const deadlineMs = Math.min(
|
|
1723
|
+
this.options.deadlineMs ?? BOT_ISOLATE_DEFAULT_DEADLINE_MS,
|
|
1724
|
+
ISOLATE_MAX_DEADLINE_MS - PLUGIN_WORKER_HOOK_RACE_MARGIN_MS,
|
|
1725
|
+
);
|
|
1726
|
+
try {
|
|
1727
|
+
const invocation: PluginWorkerHookInvocationV1<Event> = {
|
|
1728
|
+
schemaVersion: 1,
|
|
1729
|
+
event,
|
|
1730
|
+
payload: structuredClone(payload),
|
|
1731
|
+
botId: this.options.botId,
|
|
1732
|
+
sessionId: this.options.sessionId,
|
|
1733
|
+
runId: this.options.runId,
|
|
1734
|
+
turnId: this.options.turnId,
|
|
1735
|
+
generationId: this.options.generationId,
|
|
1736
|
+
deadlineMs,
|
|
1737
|
+
enabled: [...enabled],
|
|
1738
|
+
};
|
|
1739
|
+
const result = decodePluginWorkerHookResultV1(
|
|
1740
|
+
await raceDeadline(
|
|
1741
|
+
() => entrypoint.hook(invocation),
|
|
1742
|
+
deadlineMs + PLUGIN_WORKER_HOOK_RACE_MARGIN_MS,
|
|
1743
|
+
signal,
|
|
1744
|
+
),
|
|
1745
|
+
"plugin worker hook result",
|
|
1746
|
+
);
|
|
1747
|
+
for (const failure of result.failures) {
|
|
1748
|
+
await this.recordFailure(failure.pluginId, event, failure.reason);
|
|
1749
|
+
}
|
|
1750
|
+
if (result.status === "unchanged") return original;
|
|
1751
|
+
return decodeBotIsolateHookReplacementV1(
|
|
1752
|
+
event,
|
|
1753
|
+
result.replacement,
|
|
1754
|
+
original,
|
|
1755
|
+
);
|
|
1756
|
+
} catch (error) {
|
|
1757
|
+
if (error instanceof PluginFatalFailureError) throw error;
|
|
1758
|
+
// The worker as a whole did not answer in time, or answered with a value
|
|
1759
|
+
// the kernel cannot decode. The index names a Plugin it skipped itself;
|
|
1760
|
+
// here nothing says which one, so every Plugin that wraps this event is
|
|
1761
|
+
// charged — for one Plugin, exactly right, and for several, honest.
|
|
1762
|
+
const message = errorMessage(error);
|
|
1763
|
+
// `declaring` is built from `enabled` at commit, so it is a subset.
|
|
1764
|
+
for (const pluginId of declaring) {
|
|
1765
|
+
await this.recordFailure(pluginId, event, message);
|
|
1766
|
+
}
|
|
1767
|
+
return original;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
private async recordFailure(
|
|
1772
|
+
pluginId: string,
|
|
1773
|
+
event: BotIsolateHookEventNameV1,
|
|
1774
|
+
message: string,
|
|
1775
|
+
): Promise<void> {
|
|
1776
|
+
try {
|
|
1777
|
+
await this.options.recordHookFailure({
|
|
1778
|
+
packageId: pluginId,
|
|
1779
|
+
event,
|
|
1780
|
+
generationId: this.options.generationId,
|
|
1781
|
+
message: message.slice(0, 2_048),
|
|
1782
|
+
});
|
|
1783
|
+
} catch (error) {
|
|
1784
|
+
// Failure recording is itself an external durability boundary. A
|
|
1785
|
+
// broken hook still cannot wedge the loop if that boundary is down —
|
|
1786
|
+
// except when it answers that this Plugin cannot be skipped, which is
|
|
1787
|
+
// the Turn's verdict and not a recording failure at all.
|
|
1788
|
+
if (error instanceof PluginFatalFailureError) throw error;
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/**
|
|
1793
|
+
* One card's tool. The Bot sends the values; the kernel validates them
|
|
1794
|
+
* against the card's declared schema, mints the surface id unless the Bot
|
|
1795
|
+
* is updating a surface it already drew, has the Plugin draw the surface,
|
|
1796
|
+
* and records the send on the Turn's log. The Plugin never names a surface,
|
|
1797
|
+
* which is what stops one Plugin's card drawing over another's.
|
|
1798
|
+
*/
|
|
1799
|
+
/**
|
|
1800
|
+
* One draw of one card, whoever asked for it.
|
|
1801
|
+
*
|
|
1802
|
+
* The card's tool is one caller; the Shell's own send seam is the other,
|
|
1803
|
+
* because the five first-party cards are locked Plugins and an old
|
|
1804
|
+
* `send_to_user` member is mapped onto one of them (ADR 0030 step 7). Both
|
|
1805
|
+
* go through this: the values are validated against the card's declared
|
|
1806
|
+
* schema, the surface id is the kernel's, the Plugin draws, and the send is
|
|
1807
|
+
* recorded by the app. Nothing about the first-party path is shorter than
|
|
1808
|
+
* the path a customisation takes.
|
|
1809
|
+
*/
|
|
1810
|
+
private async drawCard(
|
|
1811
|
+
pluginId: string,
|
|
1812
|
+
entrypoint: PluginWorkerEntrypoint,
|
|
1813
|
+
card: PluginCardV1,
|
|
1814
|
+
request: PluginCardDrawRequestV1,
|
|
1815
|
+
context: ToolExecutionContext,
|
|
1816
|
+
): Promise<PluginCardDrawOutcomeV1> {
|
|
1817
|
+
const sendCard = this.options.sendCard;
|
|
1818
|
+
if (!sendCard) {
|
|
1819
|
+
return {
|
|
1820
|
+
status: "refused",
|
|
1821
|
+
reason: "this host records no sends, so no card can be drawn",
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
const deadlineMs = Math.min(
|
|
1825
|
+
this.options.deadlineMs ?? BOT_ISOLATE_DEFAULT_DEADLINE_MS,
|
|
1826
|
+
ISOLATE_MAX_DEADLINE_MS,
|
|
1827
|
+
);
|
|
1828
|
+
const options = this.options;
|
|
1829
|
+
try {
|
|
1830
|
+
validateAgainstJsonSchemaV1(request.data, card.dataSchema, "data");
|
|
1831
|
+
} catch (error) {
|
|
1832
|
+
return { status: "refused", reason: errorMessage(error) };
|
|
1833
|
+
}
|
|
1834
|
+
// Only a surface this card itself minted may be named again. Without
|
|
1835
|
+
// this the model could hand over another Plugin's surface id and draw
|
|
1836
|
+
// over its card, because a card record carries no owner of its own.
|
|
1837
|
+
if (
|
|
1838
|
+
request.surfaceId !== undefined &&
|
|
1839
|
+
(!CARD_SURFACE_ID_V1.test(request.surfaceId) ||
|
|
1840
|
+
!request.surfaceId.startsWith(cardSurfacePrefixV1(pluginId, card.id)))
|
|
1841
|
+
) {
|
|
1842
|
+
return {
|
|
1843
|
+
status: "refused",
|
|
1844
|
+
reason: "surfaceId is not a surface this card drew",
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
const surfaceId =
|
|
1848
|
+
request.surfaceId ??
|
|
1849
|
+
(await mintedCardSurfaceIdV1(
|
|
1850
|
+
pluginId,
|
|
1851
|
+
card.id,
|
|
1852
|
+
context.sessionId,
|
|
1853
|
+
context.effectId,
|
|
1854
|
+
));
|
|
1855
|
+
const invocation: PluginWorkerRenderCardInvocationV1 = {
|
|
1856
|
+
schemaVersion: 1,
|
|
1857
|
+
pluginId,
|
|
1858
|
+
cardId: card.id,
|
|
1859
|
+
surfaceId,
|
|
1860
|
+
data: request.data,
|
|
1861
|
+
botId: options.botId,
|
|
1862
|
+
sessionId: context.sessionId,
|
|
1863
|
+
runId: options.runId,
|
|
1864
|
+
turnId: options.turnId,
|
|
1865
|
+
generationId: context.compositionGenerationId,
|
|
1866
|
+
deadlineMs,
|
|
1867
|
+
};
|
|
1868
|
+
// A draw that threw, overran, reached no worker or answered
|
|
1869
|
+
// undecodably is charged to the Plugin exactly as a press is; the
|
|
1870
|
+
// charge is beside the tool error, never instead of it.
|
|
1871
|
+
const chargeDraw = async (message: string): Promise<void> => {
|
|
1872
|
+
try {
|
|
1873
|
+
await options.recordCardFailure?.({
|
|
1874
|
+
pluginId,
|
|
1875
|
+
cardId: card.id,
|
|
1876
|
+
message,
|
|
1877
|
+
});
|
|
1878
|
+
} catch {
|
|
1879
|
+
// Recording a failure must not be what fails the draw.
|
|
1880
|
+
}
|
|
1881
|
+
};
|
|
1882
|
+
let rendered;
|
|
1883
|
+
try {
|
|
1884
|
+
rendered = decodePluginWorkerRenderCardResultV1(
|
|
1885
|
+
await raceDeadline(
|
|
1886
|
+
() => entrypoint.renderCard(invocation),
|
|
1887
|
+
deadlineMs,
|
|
1888
|
+
context.signal,
|
|
1889
|
+
),
|
|
1890
|
+
`plugin "${pluginId}" render card result`,
|
|
1891
|
+
);
|
|
1892
|
+
} catch (error) {
|
|
1893
|
+
const message = errorMessage(error);
|
|
1894
|
+
// A Turn the person stopped, or one that ran out of time, is not
|
|
1895
|
+
// the Plugin failing; only its own deadline overrun is.
|
|
1896
|
+
if (!(error instanceof RaceAbortedError)) await chargeDraw(message);
|
|
1897
|
+
return { status: "failed", reason: message };
|
|
1898
|
+
}
|
|
1899
|
+
if (rendered.status !== "rendered") {
|
|
1900
|
+
const reason = rendered.reason ?? "the plugin refused";
|
|
1901
|
+
// A draw that refused in as many words is not a draw that broke.
|
|
1902
|
+
if (rendered.deliberate !== true) await chargeDraw(reason);
|
|
1903
|
+
return { status: "dropped", reason };
|
|
1904
|
+
}
|
|
1905
|
+
const outcome = await sendCard({
|
|
1906
|
+
pluginId,
|
|
1907
|
+
cardId: card.id,
|
|
1908
|
+
surfaceId,
|
|
1909
|
+
...(rendered.covers === undefined ? {} : { covers: rendered.covers }),
|
|
1910
|
+
...(rendered.decision === undefined
|
|
1911
|
+
? {}
|
|
1912
|
+
: { decision: rendered.decision }),
|
|
1913
|
+
...(request.approvalIds === undefined
|
|
1914
|
+
? {}
|
|
1915
|
+
: { approvalIds: request.approvalIds }),
|
|
1916
|
+
...(request.secretRequest === undefined
|
|
1917
|
+
? {}
|
|
1918
|
+
: { secretRequest: request.secretRequest }),
|
|
1919
|
+
messages: rendered.messages,
|
|
1920
|
+
context,
|
|
1921
|
+
});
|
|
1922
|
+
if (outcome.status !== "sent") {
|
|
1923
|
+
return { status: "refused", reason: outcome.reason };
|
|
1924
|
+
}
|
|
1925
|
+
return { status: "drawn", surfaceId, approvals: outcome.approvals };
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
private cardDefinition(
|
|
1929
|
+
pluginId: string,
|
|
1930
|
+
entrypoint: PluginWorkerEntrypoint,
|
|
1931
|
+
card: PluginCardV1,
|
|
1932
|
+
): ToolDefinition | undefined {
|
|
1933
|
+
const sendCard = this.options.sendCard;
|
|
1934
|
+
if (!sendCard) return undefined;
|
|
1935
|
+
const name = pluginCardToolNameV1(pluginId, card.id);
|
|
1936
|
+
return {
|
|
1937
|
+
name,
|
|
1938
|
+
description: `${card.description} Draws the "${card.displayName}" card in the conversation. Pass the surfaceId of a card you already drew to update it in place; leave it out to draw a new one.`,
|
|
1939
|
+
inputSchema: {
|
|
1940
|
+
type: "object",
|
|
1941
|
+
properties: {
|
|
1942
|
+
data: structuredClone(card.dataSchema),
|
|
1943
|
+
surfaceId: {
|
|
1944
|
+
type: "string",
|
|
1945
|
+
description:
|
|
1946
|
+
"The surface of a card you already drew, to update it in place.",
|
|
1947
|
+
},
|
|
1948
|
+
},
|
|
1949
|
+
required: ["data"],
|
|
1950
|
+
additionalProperties: false,
|
|
1951
|
+
},
|
|
1952
|
+
namespace: pluginId,
|
|
1953
|
+
// A card is a bubble in the conversation, and two of them are read in
|
|
1954
|
+
// the order they landed in.
|
|
1955
|
+
orderedEffect: true,
|
|
1956
|
+
// Drawing a card is the Bot speaking; what a card may do is gated by
|
|
1957
|
+
// its own actions (ADR 0030).
|
|
1958
|
+
effect: "read",
|
|
1959
|
+
execute: async (
|
|
1960
|
+
input: unknown,
|
|
1961
|
+
context: ToolExecutionContext,
|
|
1962
|
+
): Promise<ToolExecutionResult> => {
|
|
1963
|
+
const request = (input ?? {}) as {
|
|
1964
|
+
data?: unknown;
|
|
1965
|
+
surfaceId?: unknown;
|
|
1966
|
+
};
|
|
1967
|
+
if (
|
|
1968
|
+
request.surfaceId !== undefined &&
|
|
1969
|
+
typeof request.surfaceId !== "string"
|
|
1970
|
+
) {
|
|
1971
|
+
return {
|
|
1972
|
+
content: `${name} was refused: surfaceId is not a surface this card drew`,
|
|
1973
|
+
isError: true,
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
const outcome = await this.drawCard(
|
|
1977
|
+
pluginId,
|
|
1978
|
+
entrypoint,
|
|
1979
|
+
card,
|
|
1980
|
+
{
|
|
1981
|
+
data: request.data as Record<string, unknown>,
|
|
1982
|
+
...(request.surfaceId === undefined
|
|
1983
|
+
? {}
|
|
1984
|
+
: { surfaceId: request.surfaceId }),
|
|
1985
|
+
},
|
|
1986
|
+
context,
|
|
1987
|
+
);
|
|
1988
|
+
if (outcome.status === "refused") {
|
|
1989
|
+
return {
|
|
1990
|
+
content: `${name} was refused: ${outcome.reason}`,
|
|
1991
|
+
isError: true,
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
if (outcome.status === "failed") {
|
|
1995
|
+
return {
|
|
1996
|
+
content: `${name} failed in its plugin: ${outcome.reason}`,
|
|
1997
|
+
isError: true,
|
|
1998
|
+
};
|
|
1999
|
+
}
|
|
2000
|
+
if (outcome.status === "dropped") {
|
|
2001
|
+
return {
|
|
2002
|
+
content: `${name} drew nothing: ${outcome.reason}`,
|
|
2003
|
+
isError: true,
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
if (outcome.approvals > 0) {
|
|
2007
|
+
return {
|
|
2008
|
+
content: `The "${card.displayName}" card is in the conversation as surface "${outcome.surfaceId}", asking the user to decide. This Turn is over; their decision arrives as input on a later Turn.`,
|
|
2009
|
+
isError: false,
|
|
2010
|
+
endsTurn: true,
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
return {
|
|
2014
|
+
content: `The "${card.displayName}" card is in the conversation as surface "${outcome.surfaceId}". Call ${name} again with that surfaceId to update it.`,
|
|
2015
|
+
isError: false,
|
|
2016
|
+
};
|
|
2017
|
+
},
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
private definition(
|
|
2022
|
+
pluginId: string,
|
|
2023
|
+
entrypoint: PluginWorkerEntrypoint,
|
|
2024
|
+
descriptor: IsolateToolDescriptorV1,
|
|
2025
|
+
): ToolDefinition {
|
|
2026
|
+
const deadlineMs = Math.min(
|
|
2027
|
+
this.options.deadlineMs ?? BOT_ISOLATE_DEFAULT_DEADLINE_MS,
|
|
2028
|
+
ISOLATE_MAX_DEADLINE_MS,
|
|
2029
|
+
);
|
|
2030
|
+
const options = this.options;
|
|
2031
|
+
return {
|
|
2032
|
+
...isolateToolSchemaV1(descriptor),
|
|
2033
|
+
namespace: pluginId,
|
|
2034
|
+
idempotent: descriptor.idempotent,
|
|
2035
|
+
...(descriptor.admission ? { admission: descriptor.admission } : {}),
|
|
2036
|
+
execute: async (
|
|
2037
|
+
input: unknown,
|
|
2038
|
+
context: ToolExecutionContext,
|
|
2039
|
+
): Promise<ToolExecutionResult> => {
|
|
2040
|
+
const invocation: PluginWorkerToolInvocationV1 = {
|
|
2041
|
+
schemaVersion: 1,
|
|
2042
|
+
pluginId,
|
|
2043
|
+
tool: descriptor.name,
|
|
2044
|
+
input: input ?? null,
|
|
2045
|
+
botId: options.botId,
|
|
2046
|
+
sessionId: context.sessionId,
|
|
2047
|
+
runId: options.runId,
|
|
2048
|
+
turnId: options.turnId,
|
|
2049
|
+
generationId: context.compositionGenerationId,
|
|
2050
|
+
deadlineMs,
|
|
2051
|
+
effectId: context.effectId,
|
|
2052
|
+
};
|
|
2053
|
+
const effects = options.toolEffects;
|
|
2054
|
+
effects?.set(context.effectId, pluginId);
|
|
2055
|
+
try {
|
|
2056
|
+
// `AbortSignal` cannot cross the RPC boundary, so the deadline is
|
|
2057
|
+
// carried in the invocation and raced again on this side.
|
|
2058
|
+
const raw = await raceDeadline(
|
|
2059
|
+
() => entrypoint.execute(invocation),
|
|
2060
|
+
deadlineMs,
|
|
2061
|
+
context.signal,
|
|
2062
|
+
);
|
|
2063
|
+
const result = decodeIsolateToolResultV1(
|
|
2064
|
+
raw,
|
|
2065
|
+
`plugin "${pluginId}" tool result`,
|
|
2066
|
+
);
|
|
2067
|
+
return { content: result.content, isError: result.isError };
|
|
2068
|
+
} catch (error) {
|
|
2069
|
+
return {
|
|
2070
|
+
content: `Tool "${descriptor.name}" failed in its plugin: ${errorMessage(error)}`,
|
|
2071
|
+
isError: true,
|
|
2072
|
+
};
|
|
2073
|
+
} finally {
|
|
2074
|
+
effects?.delete(context.effectId);
|
|
2075
|
+
}
|
|
2076
|
+
},
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
/**
|
|
2082
|
+
* How many UTF-8 bytes a fired trigger body is, or zero for anything that is
|
|
2083
|
+
* not a fire. The worker returns what the Plugin produced whole; the bound is
|
|
2084
|
+
* the Durable Object's, so the caller is told which Plugin overran it and by
|
|
2085
|
+
* what limit rather than reading a body truncated mid-sentence.
|
|
2086
|
+
*/
|
|
2087
|
+
function firedTextBytes(value: unknown): number {
|
|
2088
|
+
if (!value || typeof value !== "object") return 0;
|
|
2089
|
+
const result = value as { status?: unknown; text?: unknown };
|
|
2090
|
+
if (result.status !== "fire" || typeof result.text !== "string") return 0;
|
|
2091
|
+
return pluginWorkerUtf8LengthV1(result.text);
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
/** The one statement of what a caller is told when no live Plugin answers. */
|
|
2095
|
+
function droppedTrigger(
|
|
2096
|
+
pluginId: string,
|
|
2097
|
+
reason?: string,
|
|
2098
|
+
): PluginWorkerTriggerResultV1 {
|
|
2099
|
+
return {
|
|
2100
|
+
schemaVersion: 1,
|
|
2101
|
+
status: "drop",
|
|
2102
|
+
reason: (
|
|
2103
|
+
reason ?? `plugin "${pluginId}" did not mount in this generation`
|
|
2104
|
+
).slice(0, MAX_FAILURE_REASON_V1),
|
|
2105
|
+
};
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/** The Durable Object half of the deadline: a race the worker cannot escape. */
|
|
2109
|
+
export function raceDeadline<T>(
|
|
2110
|
+
work: () => Promise<T>,
|
|
2111
|
+
deadlineMs: number,
|
|
2112
|
+
signal?: AbortSignal,
|
|
2113
|
+
): Promise<T> {
|
|
2114
|
+
if (
|
|
2115
|
+
!Number.isSafeInteger(deadlineMs) ||
|
|
2116
|
+
deadlineMs <= 0 ||
|
|
2117
|
+
deadlineMs > ISOLATE_MAX_DEADLINE_MS
|
|
2118
|
+
) {
|
|
2119
|
+
return Promise.reject(
|
|
2120
|
+
new Error("isolate invocation deadline is out of range"),
|
|
2121
|
+
);
|
|
2122
|
+
}
|
|
2123
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
2124
|
+
let onAbort: (() => void) | undefined;
|
|
2125
|
+
const expiry = new Promise<never>((_resolve, reject) => {
|
|
2126
|
+
timer = setTimeout(
|
|
2127
|
+
() =>
|
|
2128
|
+
reject(
|
|
2129
|
+
new Error(
|
|
2130
|
+
`isolate invocation exceeded its deadline of ${deadlineMs}ms`,
|
|
2131
|
+
),
|
|
2132
|
+
),
|
|
2133
|
+
deadlineMs,
|
|
2134
|
+
);
|
|
2135
|
+
if (signal) {
|
|
2136
|
+
onAbort = () =>
|
|
2137
|
+
reject(
|
|
2138
|
+
new RaceAbortedError(
|
|
2139
|
+
signal.reason === undefined
|
|
2140
|
+
? "aborted"
|
|
2141
|
+
: errorMessage(signal.reason),
|
|
2142
|
+
),
|
|
2143
|
+
);
|
|
2144
|
+
if (signal.aborted) onAbort();
|
|
2145
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
2146
|
+
}
|
|
2147
|
+
});
|
|
2148
|
+
return Promise.race([Promise.resolve().then(work), expiry]).finally(() => {
|
|
2149
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
2150
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
2151
|
+
});
|
|
2152
|
+
}
|