@frockbot/plugin-shell 0.1.2 → 0.1.4

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.
@@ -8,9 +8,10 @@
8
8
  // acquires its credential lease through the existing provider path before a
9
9
  // single byte leaves the account.
10
10
  import type {
11
+ IsolateCapabilityFailureV1,
11
12
  IsolateAuthorityRequestV1,
12
13
  IsolateCapabilityDescriptorV1,
13
- IsolateModelInvocationV1,
14
+ IsolateModelOutcomeV1,
14
15
  IsolatePendingDecisionV1,
15
16
  LlmStreamEvent,
16
17
  NormalizedModelRequest,
@@ -20,6 +21,7 @@ import {
20
21
  encodeIsolateModelEventLineV1,
21
22
  } from "@frockbot/kernel-contracts";
22
23
  import type { BotIsolateArtifactStore } from "@frockbot/kernel-composition/isolate";
24
+ import type { EnabledCapabilityV1 } from "@frockbot/configuration-core";
23
25
 
24
26
  /**
25
27
  * The compatibility date every Bot isolate is loaded with. Pinned beside the
@@ -36,8 +38,8 @@ export interface BotCapabilitiesPropsV1 {
36
38
  botId: string;
37
39
  generationId: string;
38
40
  packageId: string;
39
- /** Already resolved and filtered to enabled Assignments by the authority. */
40
- assignments: IsolateAssignmentV1[];
41
+ /** The User's enabled set, already resolved by the authority. */
42
+ capabilities: IsolateCapabilityV1[];
41
43
  }
42
44
 
43
45
  export const ISOLATE_DECISION_PREFIX = "isolate:decision:";
@@ -78,25 +80,17 @@ export interface IsolateCapabilityStore {
78
80
  list<T>(options: { prefix: string }): Promise<Map<string, T>>;
79
81
  }
80
82
 
81
- /** One enabled Assignment, already resolved the way `plugin-shell` resolves them. */
82
- export interface IsolateAssignmentV1 {
83
- assignmentId: string;
84
- packageId: string;
85
- capabilityId: string;
86
- kind: IsolateCapabilityDescriptorV1["kind"];
87
- connectionId?: string;
88
- providerModelId?: string;
89
- }
83
+ /** One account-wide Capability, already resolved from User enablement. */
84
+ export type IsolateCapabilityV1 = EnabledCapabilityV1;
90
85
 
91
86
  /**
92
- * The Bot's durable model binding, resolved by the authority from the Bot's
93
- * own configuration and the User's Connection — never from anything the Bot
87
+ * The Bot's effective model binding, resolved by the authority from generic
88
+ * Package settings and the User's Connection — never from anything the Bot
94
89
  * supplied. An `invokeModel` request is authorized only when it names exactly
95
90
  * this provider and this model, and it is forwarded carrying exactly this
96
91
  * binding.
97
92
  */
98
93
  export interface IsolateModelBindingV1 {
99
- assignmentId: string;
100
94
  packageId: string;
101
95
  capabilityId: string;
102
96
  connectionId: string;
@@ -106,6 +100,15 @@ export interface IsolateModelBindingV1 {
106
100
  catalogGeneration?: string;
107
101
  }
108
102
 
103
+ /** A configured model binding whose Connection cannot currently be used. */
104
+ export interface IsolateUnavailableModelBindingV1 {
105
+ provider?: string;
106
+ providerModelId: string;
107
+ }
108
+
109
+ export const ISOLATE_MODEL_UNAVAILABLE_MESSAGE =
110
+ "the enabled model binding is unavailable";
111
+
109
112
  /** The bound Bot-supplied correlation id: a field, never a key, and never unbounded. */
110
113
  export const MAX_ISOLATE_REQUEST_ID = 256;
111
114
 
@@ -122,14 +125,17 @@ export interface IsolateCapabilityHostOptions {
122
125
  botId: string;
123
126
  packageId: string;
124
127
  generationId: string;
125
- /** Assignment-derived and nothing else. */
126
- assignments: readonly IsolateAssignmentV1[];
128
+ /** User-enabled and nothing else. */
129
+ capabilities: readonly IsolateCapabilityV1[];
127
130
  /**
128
- * The one model binding this Bot durably holds, or absent when it holds
129
- * none. Absent means every model request is a pending decision.
131
+ * The one ready model binding resolved for this Bot. Absent without an
132
+ * unavailable binding means every model request is an authority-widening
133
+ * pending decision.
130
134
  */
131
135
  modelBinding?: IsolateModelBindingV1;
132
- /** Absent when the Bot has no enabled model Assignment at all. */
136
+ /** Present when the configured binding is held but its Connection is unavailable. */
137
+ unavailableModelBinding?: IsolateUnavailableModelBindingV1;
138
+ /** Present only while the ready binding can reach its provider path. */
133
139
  modelPath?: IsolateModelPath;
134
140
  now?(): Date;
135
141
  newId?(): string;
@@ -138,26 +144,24 @@ export interface IsolateCapabilityHostOptions {
138
144
  export interface IsolateCapabilityHost {
139
145
  list(): Promise<IsolateCapabilityDescriptorV1[]>;
140
146
  requestAuthority(request: unknown): Promise<IsolatePendingDecisionV1>;
141
- invokeModel(
142
- request: NormalizedModelRequest,
143
- ): Promise<IsolateModelInvocationV1>;
147
+ invokeModel(request: NormalizedModelRequest): Promise<IsolateModelOutcomeV1>;
144
148
  pendingDecisions(): Promise<IsolatePendingAuthorityDecisionV1[]>;
145
149
  recordedModelRequests(): Promise<IsolateModelRequestRecordV1[]>;
146
150
  }
147
151
 
148
152
  /**
149
- * The enabled model Assignment that can serve this request, if any.
153
+ * The enabled model Capability that can serve this request, if any.
150
154
  *
151
- * An Assignment authorizes exactly one Package, one Connection, and one
152
- * provider model: the Bot's durable binding. A request naming any other
153
- * provider or model resolves to nothing, whatever the Bot claims about it —
154
- * the Bot-supplied `modelBinding` is never read here or anywhere downstream.
155
+ * The authority resolves the effective model against one User-enabled
156
+ * Package, Connection, and Capability. A request naming any other provider or
157
+ * model resolves to nothing, whatever the Bot claims about it — the
158
+ * Bot-supplied `modelBinding` is never read here or anywhere downstream.
155
159
  */
156
- export function matchingModelAssignmentV1(
157
- assignments: readonly IsolateAssignmentV1[],
160
+ export function matchingModelCapabilityV1(
161
+ capabilities: readonly IsolateCapabilityV1[],
158
162
  binding: IsolateModelBindingV1 | undefined,
159
163
  request: NormalizedModelRequest,
160
- ): IsolateAssignmentV1 | undefined {
164
+ ): IsolateCapabilityV1 | undefined {
161
165
  if (!binding) return undefined;
162
166
  if (
163
167
  request.provider !== binding.provider ||
@@ -165,14 +169,12 @@ export function matchingModelAssignmentV1(
165
169
  ) {
166
170
  return undefined;
167
171
  }
168
- return assignments.find(
169
- (assignment) =>
170
- assignment.kind === "model" &&
171
- assignment.assignmentId === binding.assignmentId &&
172
- assignment.packageId === binding.packageId &&
173
- assignment.capabilityId === binding.capabilityId &&
174
- assignment.connectionId === binding.connectionId &&
175
- assignment.providerModelId === binding.providerModelId,
172
+ return capabilities.find(
173
+ (capability) =>
174
+ capability.kind === "model" &&
175
+ capability.packageId === binding.packageId &&
176
+ capability.capabilityId === binding.capabilityId &&
177
+ capability.connectionId === binding.connectionId,
176
178
  );
177
179
  }
178
180
 
@@ -208,9 +210,9 @@ export function createIsolateCapabilityHost(
208
210
  return {
209
211
  list(): Promise<IsolateCapabilityDescriptorV1[]> {
210
212
  return Promise.resolve(
211
- options.assignments.map((assignment) => ({
212
- capabilityId: assignment.capabilityId,
213
- kind: assignment.kind,
213
+ options.capabilities.map((capability) => ({
214
+ capabilityId: capability.capabilityId,
215
+ kind: capability.kind,
214
216
  })),
215
217
  );
216
218
  },
@@ -221,26 +223,44 @@ export function createIsolateCapabilityHost(
221
223
  const decoded: IsolateAuthorityRequestV1 =
222
224
  decodeIsolateAuthorityRequestV1(request);
223
225
  // Self-modification never widens authority, even when the capability is
224
- // already assigned: the answer is a decision the User makes.
226
+ // already enabled: the answer is a decision the User makes.
225
227
  return await recordDecision(decoded.capabilityId, decoded.reason);
226
228
  },
227
229
 
228
230
  async invokeModel(
229
231
  request: NormalizedModelRequest,
230
- ): Promise<IsolateModelInvocationV1> {
232
+ ): Promise<IsolateModelOutcomeV1> {
231
233
  if (request.requestId.length > MAX_ISOLATE_REQUEST_ID) {
232
234
  throw new Error("isolate model request requestId is not bounded");
233
235
  }
234
236
  const binding = options.modelBinding;
235
- const assignment = matchingModelAssignmentV1(
236
- options.assignments,
237
+ const capability = matchingModelCapabilityV1(
238
+ options.capabilities,
237
239
  binding,
238
240
  request,
239
241
  );
240
- if (!assignment || !binding || !options.modelPath) {
242
+ const unavailableBinding = options.unavailableModelBinding;
243
+ if (
244
+ unavailableBinding &&
245
+ request.model === unavailableBinding.providerModelId &&
246
+ (unavailableBinding.provider === undefined ||
247
+ request.provider === unavailableBinding.provider)
248
+ ) {
249
+ return {
250
+ status: "unavailable",
251
+ reason: ISOLATE_MODEL_UNAVAILABLE_MESSAGE,
252
+ } satisfies IsolateCapabilityFailureV1;
253
+ }
254
+ if (capability && binding && !options.modelPath) {
255
+ return {
256
+ status: "unavailable",
257
+ reason: ISOLATE_MODEL_UNAVAILABLE_MESSAGE,
258
+ } satisfies IsolateCapabilityFailureV1;
259
+ }
260
+ if (!capability || !binding || !options.modelPath) {
241
261
  return await recordDecision(
242
262
  `models:${request.provider}:${request.model}`,
243
- `Bot Package "${options.packageId}" asked to invoke a model with no matching enabled Assignment`,
263
+ `Bot Package "${options.packageId}" asked to invoke a model with no matching enabled Capability`,
244
264
  );
245
265
  }
246
266
  // The binding the provider path receives is the authority's, never the
@@ -269,7 +289,7 @@ export function createIsolateCapabilityHost(
269
289
  botId: options.botId,
270
290
  packageId: options.packageId,
271
291
  generationId: options.generationId,
272
- capabilityId: assignment.capabilityId,
292
+ capabilityId: capability.capabilityId,
273
293
  request: forwarded,
274
294
  recordedAt: now().toISOString(),
275
295
  };
@@ -346,31 +366,49 @@ export function isolateModelEventStreamV1(
346
366
  }
347
367
 
348
368
  /**
349
- * The content address of the bindings an isolate is loaded with: its
350
- * Assignments and the Composition generation whose `CAPABILITIES` stub is
351
- * baked into its `env`. A loader id is served from cache, so a Bot whose
352
- * Assignments change must get a different isolate rather than one that keeps a
353
- * revoked binding and a new generation must get a different isolate rather
354
- * than one whose `env` still names the generation it was first loaded under.
355
- * Both are bindings the isolate was granted, so both belong in this digest and
356
- * the loader id stays derived from the artifact set and the binding digest
357
- * alone.
369
+ * The content address of every binding baked into an isolate's `env`: User,
370
+ * Bot, and Composition generation from `IDENTITY` and the `CAPABILITIES` props,
371
+ * plus the User-enabled capability set. A loader id is served from cache, so
372
+ * any change must produce a different digest or a stale isolate will answer
373
+ * under the wrong identity or authority. Identical artifacts share an isolate
374
+ * only when all of these binding inputs are identical (AGENTS.md Package
375
+ * composition; ADR 0019).
358
376
  */
359
- export async function isolateBindingDigestV1(
360
- assignments: readonly IsolateAssignmentV1[],
361
- generationId: string,
362
- ): Promise<string> {
363
- const ordered = [...assignments]
364
- .map((assignment) => ({
365
- assignmentId: assignment.assignmentId,
366
- packageId: assignment.packageId,
367
- capabilityId: assignment.capabilityId,
368
- kind: assignment.kind,
369
- connectionId: assignment.connectionId ?? null,
370
- providerModelId: assignment.providerModelId ?? null,
377
+ export async function isolateBindingDigestV1(input: {
378
+ userId: string;
379
+ botId: string;
380
+ generationId: string;
381
+ capabilities: readonly IsolateCapabilityV1[];
382
+ }): Promise<string> {
383
+ const ordered = [...input.capabilities]
384
+ .map((capability) => ({
385
+ packageId: capability.packageId,
386
+ capabilityId: capability.capabilityId,
387
+ kind: capability.kind,
388
+ connectionId: capability.connectionId ?? null,
371
389
  }))
372
- .sort((left, right) => left.assignmentId.localeCompare(right.assignmentId));
373
- return await sha256Hex(JSON.stringify({ generationId, ordered }));
390
+ .sort(
391
+ (left, right) =>
392
+ compareIsolateIdentifierV1(left.packageId, right.packageId) ||
393
+ compareIsolateIdentifierV1(left.capabilityId, right.capabilityId) ||
394
+ compareIsolateIdentifierV1(left.kind, right.kind) ||
395
+ compareIsolateIdentifierV1(
396
+ left.connectionId ?? "",
397
+ right.connectionId ?? "",
398
+ ),
399
+ );
400
+ return await sha256Hex(
401
+ JSON.stringify({
402
+ userId: input.userId,
403
+ botId: input.botId,
404
+ generationId: input.generationId,
405
+ ordered,
406
+ }),
407
+ );
408
+ }
409
+
410
+ function compareIsolateIdentifierV1(left: string, right: string): number {
411
+ return left < right ? -1 : left > right ? 1 : 0;
374
412
  }
375
413
 
376
414
  async function sha256Hex(value: string): Promise<string> {
@@ -105,6 +105,11 @@ describe("Bot recovery", () => {
105
105
  revision: 1,
106
106
  profile: { name: "User" },
107
107
  packages: [
108
+ {
109
+ packageId: "custom-models",
110
+ version: "0.0.1",
111
+ state: "installed",
112
+ },
108
113
  {
109
114
  packageId: "provider-ollama-cloud",
110
115
  version: "0.0.1",
@@ -140,6 +145,10 @@ describe("Bot recovery", () => {
140
145
  },
141
146
  },
142
147
  ],
148
+ platformModel: {
149
+ connectionId: "ollama-1",
150
+ providerModelId: "glm-5.3-flash:cloud",
151
+ },
143
152
  };
144
153
  const leasedRequests: Array<Record<string, unknown>> = [];
145
154
  const settledEffects: string[] = [];
@@ -148,35 +157,6 @@ describe("Bot recovery", () => {
148
157
  readConfiguration: () => Promise.resolve(structuredClone(userSettings)),
149
158
  listBots: () =>
150
159
  Promise.resolve({ schemaVersion: 1 as const, revision: 0, bots: [] }),
151
- getConnection: () =>
152
- Promise.resolve(structuredClone(userSettings.connections[0])),
153
- executeConnectionDependency: (request: { action: string }) => {
154
- if (request.action === "claim") {
155
- return Promise.resolve({
156
- schemaVersion: 1 as const,
157
- status: "claimed" as const,
158
- });
159
- }
160
- if (request.action === "acknowledge") {
161
- return Promise.resolve({
162
- schemaVersion: 1 as const,
163
- status: "acknowledged" as const,
164
- });
165
- }
166
- if (request.action === "read") {
167
- return Promise.resolve({
168
- schemaVersion: 1 as const,
169
- status: "acknowledged" as const,
170
- });
171
- }
172
- return Promise.resolve({
173
- schemaVersion: 1 as const,
174
- status: "released" as const,
175
- });
176
- },
177
- claimConnectionDependency: () => Promise.resolve(true),
178
- acknowledgeConnectionDependency: () => Promise.resolve(true),
179
- compensateConnectionDependency: () => Promise.resolve(true),
180
160
  leaseModelCredential: (input: unknown) => {
181
161
  leasedRequests.push(input as Record<string, unknown>);
182
162
  const request = input as { effectId: string };
@@ -241,32 +221,8 @@ describe("Bot recovery", () => {
241
221
  const configured = host();
242
222
  await configured.materializeSettings(
243
223
  { userId: "user-1", botId: "primary" },
244
- {
245
- name: "Ollama Bot",
246
- model: {
247
- connectionId: "ollama-1",
248
- providerModelId: "glm-5.3-flash:cloud",
249
- },
250
- },
224
+ { name: "Ollama Bot" },
251
225
  );
252
- await configured.executeConfiguration({
253
- schemaVersion: 1,
254
- userId: "user-1",
255
- botId: "primary",
256
- command: {
257
- schemaVersion: 1,
258
- type: "bot/assign-capability",
259
- commandId: "assign-ollama-model",
260
- botId: "primary",
261
- expectedRevision: 0,
262
- assignment: {
263
- assignmentId: "ollama-model",
264
- packageId: "provider-ollama-cloud",
265
- capabilityId: "ollama-cloud-models",
266
- connectionId: "ollama-1",
267
- },
268
- },
269
- });
270
226
  const first = await host().run({
271
227
  userId: "user-1",
272
228
  botId: "primary",
@@ -1353,6 +1309,11 @@ describe("Bot recovery", () => {
1353
1309
  revision: 1,
1354
1310
  profile: { name: "User" },
1355
1311
  packages: [
1312
+ {
1313
+ packageId: "custom-models",
1314
+ version: "0.0.1",
1315
+ state: "installed",
1316
+ },
1356
1317
  {
1357
1318
  packageId: "provider-ollama-cloud",
1358
1319
  version: "0.0.1",
@@ -1371,23 +1332,12 @@ describe("Bot recovery", () => {
1371
1332
  safeMetadata: {},
1372
1333
  },
1373
1334
  ],
1374
- };
1375
- const settings = {
1376
- ...initializeBotSettingsV1("primary"),
1377
- model: {
1335
+ platformModel: {
1378
1336
  connectionId: "ollama-race",
1379
1337
  providerModelId: "model:cloud",
1380
1338
  },
1381
- assignments: [
1382
- {
1383
- assignmentId: "model-race",
1384
- packageId: "provider-ollama-cloud",
1385
- capabilityId: "ollama-cloud-models",
1386
- connectionId: "ollama-race",
1387
- state: "enabled" as const,
1388
- },
1389
- ],
1390
1339
  };
1340
+ const settings = initializeBotSettingsV1("primary");
1391
1341
  await storage.put("bot-configuration", settings);
1392
1342
  const contribution = createShellBotBackendContribution({
1393
1343
  state: { storage } as unknown as DurableObjectState,
@@ -1,7 +1,7 @@
1
1
  // The Bot Durable Object's half of the Routines seam.
2
2
  //
3
3
  // "The Bot's Durable Object is the authority for everything Bot-scoped: …
4
- // durable scheduling, Routines, Assignments." The Routines Package holds the
4
+ // durable scheduling, Routines, and Composition." The Routines Package holds the
5
5
  // records, the codecs, the command semantics and the scheduler; this module
6
6
  // supplies the two things the Package cannot own — the Durable Object's storage,
7
7
  // and the one call that admits a Turn.