@frockbot/plugin-subagents 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/records.ts ADDED
@@ -0,0 +1,649 @@
1
+ // The durable Subagent records, and their strict codecs.
2
+ //
3
+ // ADR 0017 splits the powers: the parent Bot Durable Object is the *authority*
4
+ // for a task — it mints the record, admits the dispatch, pins the Composition
5
+ // generation and the model binding, holds the bounds, and records the terminal
6
+ // outcome — and the Subagent Durable Object is only an *execution host* for the
7
+ // one `subagent` Turn the parent handed it. Everything in this module is
8
+ // therefore parent state.
9
+ //
10
+ // Every record is versioned and exact-field, decoded at the seam it crosses.
11
+ // There are no migrations: a record the current codec refuses is a visible
12
+ // failure rather than something to reshape.
13
+
14
+ /** The five subagent roles GrokBot declares (`docs/research/grokbot-computer.md` l.351–356). */
15
+ export const TASK_TYPES_V1 = [
16
+ "executor",
17
+ "browserUse",
18
+ "computerUse",
19
+ "watchVideo",
20
+ "videoReview",
21
+ ] as const;
22
+
23
+ export type TaskTypeV1 = (typeof TASK_TYPES_V1)[number];
24
+
25
+ /** `type` is optional on the tool; an omitted one is a general work subagent. */
26
+ export const DEFAULT_TASK_TYPE_V1: TaskTypeV1 = "executor";
27
+
28
+ /** The lifecycle one task record moves through. Three of the five are terminal. */
29
+ export const TASK_STATUSES_V1 = [
30
+ "queued",
31
+ "running",
32
+ "completed",
33
+ "failed",
34
+ "stopped",
35
+ ] as const;
36
+
37
+ export type TaskStatusV1 = (typeof TASK_STATUSES_V1)[number];
38
+
39
+ export const TASK_TERMINAL_STATUSES_V1: readonly TaskStatusV1[] = [
40
+ "completed",
41
+ "failed",
42
+ "stopped",
43
+ ];
44
+
45
+ export function isTerminalTaskStatusV1(status: TaskStatusV1): boolean {
46
+ return TASK_TERMINAL_STATUSES_V1.includes(status);
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Bounds. The plan's table, in one place, because every one of them is a
51
+ // refusal a Bot can read rather than a limit it discovers by being truncated.
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /** Concurrent tasks one Bot may hold, counted from its `task-active:*` keys. */
55
+ export const TASK_CONCURRENCY_PER_BOT_V1 = 4;
56
+ /** Concurrent tasks one User may hold, reserved in the User Durable Object. */
57
+ export const TASK_CONCURRENCY_PER_USER_V1 = 8;
58
+ /**
59
+ * How deep a subagent tree may go. It is one, and it is not a counter: `Task`
60
+ * declares `admission: {turnTypes: ["chat", "automation"]}`, so a `subagent`
61
+ * Turn is never offered the tool and a child can never dispatch a grandchild.
62
+ */
63
+ export const TASK_MAX_DEPTH_V1 = 1;
64
+ /** Longest prompt one task may carry, in UTF-8 bytes. */
65
+ export const TASK_PROMPT_MAX_BYTES_V1 = 32_768;
66
+ /** Most attachments one task may carry. */
67
+ export const TASK_ATTACHMENT_LIMIT_V1 = 4;
68
+ /** Most `task_message` payloads that may wait on a running task (G2 drains them). */
69
+ export const TASK_MESSAGE_QUEUE_LIMIT_V1 = 16;
70
+ /** How long a child Turn may live before its parent reconciles it. */
71
+ export const TASK_DEADLINE_MS_V1 = 30 * 60_000;
72
+ /**
73
+ * How long a `background:false` dispatch waits for its child before it gives
74
+ * up waiting — and *only* waiting: the task keeps running, the tool answers
75
+ * "still running, id <taskId>", and the completion reaches the Bot through the
76
+ * inbox exactly as a background task's does. A Turn is never blocked
77
+ * indefinitely (plan §2, flow 3).
78
+ */
79
+ export const TASK_BLOCKING_TIMEOUT_MS_V1 = 120_000;
80
+
81
+ /**
82
+ * How long the User-wide `desktop-gui` lease a `computerUse` task holds stays
83
+ * fresh at the Computer host without a renewal.
84
+ *
85
+ * It is the task's own lifetime plus a minute: a lease that lapsed while its
86
+ * task was still running would let a second `computerUse` subagent onto the
87
+ * same screen, and a lease that outlived a task whose Durable Object was
88
+ * destroyed would hold the desktop for ever. Bounded above by the host's own
89
+ * `controlMaxAgeSeconds` (3600).
90
+ */
91
+ export const TASK_DESKTOP_LEASE_MAX_AGE_SECONDS_V1 = 1_860;
92
+ /**
93
+ * How often that wait re-reads durable state.
94
+ *
95
+ * It is a *poll of the record*, never an await on the child's settle callback:
96
+ * the callback is an RPC back into the very object that is still inside the
97
+ * dispatching Turn, and awaiting it there is the reentrancy hazard G1 named.
98
+ */
99
+ export const TASK_BLOCKING_POLL_MS_V1 = 500;
100
+
101
+ export const TASK_DESCRIPTION_MAX_V1 = 200;
102
+ export const TASK_ID_MAX_V1 = 128;
103
+ export const TASK_SUMMARY_MAX_V1 = 8_000;
104
+ /** Longest one queued `task_message` payload may be. */
105
+ export const TASK_MESSAGE_MAX_V1 = 8_000;
106
+ export const TASK_ATTACHMENT_PATH_MAX_V1 = 512;
107
+
108
+ export class SubagentDecodeError extends Error {
109
+ override readonly name = "SubagentDecodeError";
110
+ }
111
+
112
+ const IDENTIFIER = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
113
+
114
+ export function isTaskIdV1(value: unknown): value is string {
115
+ return typeof value === "string" && IDENTIFIER.test(value);
116
+ }
117
+
118
+ const UTF8 = new TextEncoder();
119
+
120
+ function record(value: unknown, label: string): Record<string, unknown> {
121
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
122
+ throw new SubagentDecodeError(`${label} must be an object`);
123
+ }
124
+ return value as Record<string, unknown>;
125
+ }
126
+
127
+ /**
128
+ * Exact keys: an unknown field is refused rather than dropped, and a symbol or
129
+ * non-enumerable own property is a field this record does not have.
130
+ */
131
+ export function subagentExactKeys(
132
+ value: Record<string, unknown>,
133
+ required: readonly string[],
134
+ optional: readonly string[],
135
+ label: string,
136
+ ): void {
137
+ const allowed = new Set([...required, ...optional]);
138
+ const enumerable = Object.keys(value);
139
+ const own = Reflect.ownKeys(value);
140
+ if (own.length !== enumerable.length) {
141
+ throw new SubagentDecodeError(`${label} has a non-enumerable field`);
142
+ }
143
+ for (const key of enumerable) {
144
+ if (!allowed.has(key)) {
145
+ throw new SubagentDecodeError(`${label} has unknown field "${key}"`);
146
+ }
147
+ }
148
+ for (const key of required) {
149
+ if (!Object.hasOwn(value, key)) {
150
+ throw new SubagentDecodeError(`${label} is missing "${key}"`);
151
+ }
152
+ }
153
+ }
154
+
155
+ export function subagentText(
156
+ value: unknown,
157
+ maximum: number,
158
+ label: string,
159
+ ): string {
160
+ if (typeof value !== "string") {
161
+ throw new SubagentDecodeError(`${label} must be a string`);
162
+ }
163
+ const trimmed = value.trim();
164
+ if (trimmed.length === 0) {
165
+ throw new SubagentDecodeError(`${label} must not be empty`);
166
+ }
167
+ if (trimmed.length > maximum) {
168
+ throw new SubagentDecodeError(
169
+ `${label} must be at most ${maximum} characters`,
170
+ );
171
+ }
172
+ return trimmed;
173
+ }
174
+
175
+ export function subagentTimestamp(value: unknown, label: string): string {
176
+ if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
177
+ throw new SubagentDecodeError(`${label} must be an ISO-8601 timestamp`);
178
+ }
179
+ return value;
180
+ }
181
+
182
+ function subagentFlag(value: unknown, label: string): boolean {
183
+ if (typeof value !== "boolean") {
184
+ throw new SubagentDecodeError(`${label} must be a boolean`);
185
+ }
186
+ return value;
187
+ }
188
+
189
+ /**
190
+ * The model binding pinned into a task record.
191
+ *
192
+ * Structurally `IsolateModelBindingV1` (`plugin-shell/src/backend-isolate.ts`),
193
+ * restated here because a Package imports no other Package: the Shell hands one
194
+ * across the seam and this codec is what accepts it. Pinning it at dispatch is
195
+ * what fixes the child's `NormalizedModelRequest` at admission — the child never
196
+ * resolves a binding of its own.
197
+ */
198
+ export interface TaskModelBindingV1 {
199
+ assignmentId: string;
200
+ packageId: string;
201
+ capabilityId: string;
202
+ connectionId: string;
203
+ provider: string;
204
+ providerModelId: string;
205
+ connectionGeneration?: string;
206
+ catalogGeneration?: string;
207
+ }
208
+
209
+ export function decodeTaskModelBindingV1(
210
+ value: unknown,
211
+ label = "task model binding",
212
+ ): TaskModelBindingV1 {
213
+ const candidate = record(value, label);
214
+ subagentExactKeys(
215
+ candidate,
216
+ [
217
+ "assignmentId",
218
+ "packageId",
219
+ "capabilityId",
220
+ "connectionId",
221
+ "provider",
222
+ "providerModelId",
223
+ ],
224
+ ["connectionGeneration", "catalogGeneration"],
225
+ label,
226
+ );
227
+ return {
228
+ assignmentId: subagentText(
229
+ candidate.assignmentId,
230
+ 128,
231
+ `${label}.assignmentId`,
232
+ ),
233
+ packageId: subagentText(candidate.packageId, 128, `${label}.packageId`),
234
+ capabilityId: subagentText(
235
+ candidate.capabilityId,
236
+ 128,
237
+ `${label}.capabilityId`,
238
+ ),
239
+ connectionId: subagentText(
240
+ candidate.connectionId,
241
+ 128,
242
+ `${label}.connectionId`,
243
+ ),
244
+ provider: subagentText(candidate.provider, 128, `${label}.provider`),
245
+ providerModelId: subagentText(
246
+ candidate.providerModelId,
247
+ 256,
248
+ `${label}.providerModelId`,
249
+ ),
250
+ ...(candidate.connectionGeneration === undefined
251
+ ? {}
252
+ : {
253
+ connectionGeneration: subagentText(
254
+ candidate.connectionGeneration,
255
+ 256,
256
+ `${label}.connectionGeneration`,
257
+ ),
258
+ }),
259
+ ...(candidate.catalogGeneration === undefined
260
+ ? {}
261
+ : {
262
+ catalogGeneration: subagentText(
263
+ candidate.catalogGeneration,
264
+ 256,
265
+ `${label}.catalogGeneration`,
266
+ ),
267
+ }),
268
+ };
269
+ }
270
+
271
+ /** The model a task runs on: the durable binding, and the slug it was named by. */
272
+ export interface TaskModelV1 {
273
+ binding: TaskModelBindingV1;
274
+ slug: string;
275
+ }
276
+
277
+ export function decodeTaskModelV1(
278
+ value: unknown,
279
+ label = "task model",
280
+ ): TaskModelV1 {
281
+ const candidate = record(value, label);
282
+ subagentExactKeys(candidate, ["binding", "slug"], [], label);
283
+ return {
284
+ binding: decodeTaskModelBindingV1(candidate.binding, `${label}.binding`),
285
+ slug: subagentText(candidate.slug, 512, `${label}.slug`),
286
+ };
287
+ }
288
+
289
+ /** The parent Turn that dispatched a task, so the record is attributable. */
290
+ export interface TaskDispatchV1 {
291
+ runId: string;
292
+ turnId: string;
293
+ sessionId: string;
294
+ }
295
+
296
+ export function decodeTaskDispatchV1(
297
+ value: unknown,
298
+ label = "task dispatch",
299
+ ): TaskDispatchV1 {
300
+ const candidate = record(value, label);
301
+ subagentExactKeys(candidate, ["runId", "turnId", "sessionId"], [], label);
302
+ return {
303
+ runId: subagentText(candidate.runId, 128, `${label}.runId`),
304
+ turnId: subagentText(candidate.turnId, 256, `${label}.turnId`),
305
+ sessionId: subagentText(candidate.sessionId, 256, `${label}.sessionId`),
306
+ };
307
+ }
308
+
309
+ /** What a settled task hands back to its parent. */
310
+ export interface TaskOutcomeV1 {
311
+ status: Exclude<TaskStatusV1, "queued" | "running">;
312
+ settledAt: string;
313
+ summary?: string;
314
+ failure?: string;
315
+ }
316
+
317
+ export function decodeTaskOutcomeV1(
318
+ value: unknown,
319
+ label = "task outcome",
320
+ ): TaskOutcomeV1 {
321
+ const candidate = record(value, label);
322
+ subagentExactKeys(
323
+ candidate,
324
+ ["status", "settledAt"],
325
+ ["summary", "failure"],
326
+ label,
327
+ );
328
+ const status = TASK_TERMINAL_STATUSES_V1.find(
329
+ (known) => known === candidate.status,
330
+ );
331
+ if (!status) {
332
+ throw new SubagentDecodeError(`${label}.status is invalid`);
333
+ }
334
+ if (status === "completed" && candidate.failure !== undefined) {
335
+ throw new SubagentDecodeError(
336
+ `${label} completed and carries a failure at once`,
337
+ );
338
+ }
339
+ return {
340
+ status: status as TaskOutcomeV1["status"],
341
+ settledAt: subagentTimestamp(candidate.settledAt, `${label}.settledAt`),
342
+ ...(candidate.summary === undefined
343
+ ? {}
344
+ : {
345
+ summary: subagentText(
346
+ candidate.summary,
347
+ TASK_SUMMARY_MAX_V1,
348
+ `${label}.summary`,
349
+ ),
350
+ }),
351
+ ...(candidate.failure === undefined
352
+ ? {}
353
+ : {
354
+ failure: subagentText(
355
+ candidate.failure,
356
+ TASK_SUMMARY_MAX_V1,
357
+ `${label}.failure`,
358
+ ),
359
+ }),
360
+ };
361
+ }
362
+
363
+ /**
364
+ * One task, as the parent Bot Durable Object holds it.
365
+ *
366
+ * The prompt itself is *not* here: the record carries `promptDigest`, because
367
+ * the prompt is the child's input and the child's Session is where it lives.
368
+ * The parent keeps what it is the authority for — identity, admission, the
369
+ * pinned Composition and model, the bounds, the lifecycle, the outcome.
370
+ */
371
+ export interface TaskRecordV1 {
372
+ schemaVersion: 1;
373
+ taskId: string;
374
+ type: TaskTypeV1;
375
+ description: string;
376
+ promptDigest: string;
377
+ model: TaskModelV1;
378
+ compositionGenerationId: string;
379
+ background: boolean;
380
+ depth: number;
381
+ status: TaskStatusV1;
382
+ dispatch: TaskDispatchV1;
383
+ childSessionId: string;
384
+ attachments: string[];
385
+ resumedFrom?: string;
386
+ createdAt: string;
387
+ deadlineAt: string;
388
+ outcome?: TaskOutcomeV1;
389
+ }
390
+
391
+ function decodeTaskAttachmentsV1(value: unknown, label: string): string[] {
392
+ if (!Array.isArray(value)) {
393
+ throw new SubagentDecodeError(`${label} must be an array`);
394
+ }
395
+ if (value.length > TASK_ATTACHMENT_LIMIT_V1) {
396
+ throw new SubagentDecodeError(
397
+ `${label} must hold at most ${TASK_ATTACHMENT_LIMIT_V1} entries`,
398
+ );
399
+ }
400
+ return value.map((entry, index) =>
401
+ subagentText(entry, TASK_ATTACHMENT_PATH_MAX_V1, `${label}[${index}]`),
402
+ );
403
+ }
404
+
405
+ export function decodeTaskRecordV1(value: unknown): TaskRecordV1 {
406
+ const label = "task record";
407
+ const candidate = record(value, label);
408
+ subagentExactKeys(
409
+ candidate,
410
+ [
411
+ "schemaVersion",
412
+ "taskId",
413
+ "type",
414
+ "description",
415
+ "promptDigest",
416
+ "model",
417
+ "compositionGenerationId",
418
+ "background",
419
+ "depth",
420
+ "status",
421
+ "dispatch",
422
+ "childSessionId",
423
+ "attachments",
424
+ "createdAt",
425
+ "deadlineAt",
426
+ ],
427
+ ["resumedFrom", "outcome"],
428
+ label,
429
+ );
430
+ if (candidate.schemaVersion !== 1) {
431
+ throw new SubagentDecodeError(`${label} schemaVersion is unsupported`);
432
+ }
433
+ if (!isTaskIdV1(candidate.taskId)) {
434
+ throw new SubagentDecodeError(`${label} taskId is invalid`);
435
+ }
436
+ const type = TASK_TYPES_V1.find((known) => known === candidate.type);
437
+ if (!type) throw new SubagentDecodeError(`${label} type is invalid`);
438
+ const status = TASK_STATUSES_V1.find((known) => known === candidate.status);
439
+ if (!status) throw new SubagentDecodeError(`${label} status is invalid`);
440
+ if (
441
+ !Number.isSafeInteger(candidate.depth) ||
442
+ (candidate.depth as number) < 1 ||
443
+ (candidate.depth as number) > TASK_MAX_DEPTH_V1
444
+ ) {
445
+ throw new SubagentDecodeError(`${label} depth is invalid`);
446
+ }
447
+ const outcome =
448
+ candidate.outcome === undefined
449
+ ? undefined
450
+ : decodeTaskOutcomeV1(candidate.outcome, `${label}.outcome`);
451
+ // The lifecycle and the outcome are one fact recorded twice; a record that
452
+ // disagrees with itself is refused rather than read.
453
+ if (isTerminalTaskStatusV1(status) !== (outcome !== undefined)) {
454
+ throw new SubagentDecodeError(`${label} has inconsistent terminal state`);
455
+ }
456
+ if (outcome && outcome.status !== status) {
457
+ throw new SubagentDecodeError(`${label} outcome disagrees with its status`);
458
+ }
459
+ return {
460
+ schemaVersion: 1,
461
+ taskId: candidate.taskId,
462
+ type,
463
+ description: subagentText(
464
+ candidate.description,
465
+ TASK_DESCRIPTION_MAX_V1,
466
+ `${label} description`,
467
+ ),
468
+ promptDigest: subagentText(
469
+ candidate.promptDigest,
470
+ 128,
471
+ `${label} promptDigest`,
472
+ ),
473
+ model: decodeTaskModelV1(candidate.model, `${label}.model`),
474
+ compositionGenerationId: subagentText(
475
+ candidate.compositionGenerationId,
476
+ 256,
477
+ `${label} compositionGenerationId`,
478
+ ),
479
+ background: subagentFlag(candidate.background, `${label} background`),
480
+ depth: candidate.depth as number,
481
+ status,
482
+ dispatch: decodeTaskDispatchV1(candidate.dispatch, `${label}.dispatch`),
483
+ childSessionId: subagentText(
484
+ candidate.childSessionId,
485
+ 256,
486
+ `${label} childSessionId`,
487
+ ),
488
+ attachments: decodeTaskAttachmentsV1(
489
+ candidate.attachments,
490
+ `${label} attachments`,
491
+ ),
492
+ ...(candidate.resumedFrom === undefined
493
+ ? {}
494
+ : {
495
+ resumedFrom: subagentText(
496
+ candidate.resumedFrom,
497
+ TASK_ID_MAX_V1,
498
+ `${label} resumedFrom`,
499
+ ),
500
+ }),
501
+ createdAt: subagentTimestamp(candidate.createdAt, `${label} createdAt`),
502
+ deadlineAt: subagentTimestamp(candidate.deadlineAt, `${label} deadlineAt`),
503
+ ...(outcome === undefined ? {} : { outcome }),
504
+ };
505
+ }
506
+
507
+ /** One waiting `task_message`, bounded by {@link TASK_MESSAGE_QUEUE_LIMIT_V1}. */
508
+ export interface TaskMessageRecordV1 {
509
+ schemaVersion: 1;
510
+ taskId: string;
511
+ seq: number;
512
+ message: string;
513
+ createdAt: string;
514
+ /**
515
+ * When the child folded this message into a step of its own Turn. Present is
516
+ * "consumed": the queue is drained by *marking*, not by deleting, so a
517
+ * redelivery after an interrupted claim reads the mark back and hands the
518
+ * child nothing a second time.
519
+ */
520
+ deliveredAt?: string;
521
+ }
522
+
523
+ export function decodeTaskMessageRecordV1(value: unknown): TaskMessageRecordV1 {
524
+ const label = "task message";
525
+ const candidate = record(value, label);
526
+ subagentExactKeys(
527
+ candidate,
528
+ ["schemaVersion", "taskId", "seq", "message", "createdAt"],
529
+ ["deliveredAt"],
530
+ label,
531
+ );
532
+ if (candidate.schemaVersion !== 1) {
533
+ throw new SubagentDecodeError(`${label} schemaVersion is unsupported`);
534
+ }
535
+ if (!isTaskIdV1(candidate.taskId)) {
536
+ throw new SubagentDecodeError(`${label} taskId is invalid`);
537
+ }
538
+ if (!Number.isSafeInteger(candidate.seq) || (candidate.seq as number) < 0) {
539
+ throw new SubagentDecodeError(`${label} seq is invalid`);
540
+ }
541
+ return {
542
+ schemaVersion: 1,
543
+ taskId: candidate.taskId,
544
+ seq: candidate.seq as number,
545
+ message: subagentText(
546
+ candidate.message,
547
+ TASK_MESSAGE_MAX_V1,
548
+ `${label} message`,
549
+ ),
550
+ createdAt: subagentTimestamp(candidate.createdAt, `${label} createdAt`),
551
+ ...(candidate.deliveredAt === undefined
552
+ ? {}
553
+ : {
554
+ deliveredAt: subagentTimestamp(
555
+ candidate.deliveredAt,
556
+ `${label} deliveredAt`,
557
+ ),
558
+ }),
559
+ };
560
+ }
561
+
562
+ /**
563
+ * The intent record written before the desktop lease is acquired: the effect is
564
+ * named durably before the host call, so an interrupted dispatch is read back
565
+ * rather than repeated.
566
+ *
567
+ * The acquisition fields arrive *after* the host call succeeds, on the same
568
+ * key: a record with a `taskId` and no `expiresAt` is an intent whose effect
569
+ * may or may not have happened, and one with an `expiresAt` is a lease this
570
+ * Bot's task is holding until then.
571
+ */
572
+ export interface TaskDesktopLeaseIntentV1 {
573
+ schemaVersion: 1;
574
+ taskId: string;
575
+ scope: "desktop-gui";
576
+ recordedAt: string;
577
+ /** The lease owner the host serializes on; `task:<taskId>`. */
578
+ ownerId?: string;
579
+ /** When the host said the lease lapses, if the acquire landed. */
580
+ expiresAt?: string;
581
+ }
582
+
583
+ /**
584
+ * The lease owner one task holds the User-wide desktop under.
585
+ *
586
+ * It names the Bot as well as the task, because the desktop is shared across a
587
+ * User's Bots and a task id is only unique within one: two Bots dispatching on
588
+ * the same Turn ordinal mint the same task id, and an owner that could not tell
589
+ * them apart would hand the second one the first one's lease.
590
+ */
591
+ export function taskDesktopLeaseOwnerV1(botId: string, taskId: string): string {
592
+ return `task-${botId}-${taskId}`;
593
+ }
594
+
595
+ export function decodeTaskDesktopLeaseIntentV1(
596
+ value: unknown,
597
+ ): TaskDesktopLeaseIntentV1 {
598
+ const label = "task desktop lease intent";
599
+ const candidate = record(value, label);
600
+ subagentExactKeys(
601
+ candidate,
602
+ ["schemaVersion", "taskId", "scope", "recordedAt"],
603
+ ["ownerId", "expiresAt"],
604
+ label,
605
+ );
606
+ if (candidate.schemaVersion !== 1) {
607
+ throw new SubagentDecodeError(`${label} schemaVersion is unsupported`);
608
+ }
609
+ if (!isTaskIdV1(candidate.taskId)) {
610
+ throw new SubagentDecodeError(`${label} taskId is invalid`);
611
+ }
612
+ if (candidate.scope !== "desktop-gui") {
613
+ throw new SubagentDecodeError(`${label} scope is invalid`);
614
+ }
615
+ return {
616
+ schemaVersion: 1,
617
+ taskId: candidate.taskId,
618
+ scope: "desktop-gui",
619
+ recordedAt: subagentTimestamp(candidate.recordedAt, `${label} recordedAt`),
620
+ ...(candidate.ownerId === undefined
621
+ ? {}
622
+ : { ownerId: subagentText(candidate.ownerId, 256, `${label} ownerId`) }),
623
+ ...(candidate.expiresAt === undefined
624
+ ? {}
625
+ : {
626
+ expiresAt: subagentTimestamp(
627
+ candidate.expiresAt,
628
+ `${label} expiresAt`,
629
+ ),
630
+ }),
631
+ };
632
+ }
633
+
634
+ /** UTF-8 byte length, the unit every prompt bound in this Package is stated in. */
635
+ export function utf8ByteLengthV1(value: string): number {
636
+ return UTF8.encode(value).byteLength;
637
+ }
638
+
639
+ /**
640
+ * The digest a task record carries in place of the prompt. It is a content
641
+ * identity, never a secret: the prompt itself is the child's Session input.
642
+ */
643
+ export async function taskPromptDigestV1(prompt: string): Promise<string> {
644
+ const digest = await crypto.subtle.digest("SHA-256", UTF8.encode(prompt));
645
+ return [...new Uint8Array(digest)]
646
+ .map((byte) => byte.toString(16).padStart(2, "0"))
647
+ .join("")
648
+ .slice(0, 64);
649
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { TASK_TYPES_V1 } from "./records.js";
3
+ import {
4
+ SUBAGENT_ROLES_V1,
5
+ SUBAGENT_ROLE_SUMMARIES_V1,
6
+ SUBAGENT_TOOL_REACH_V1,
7
+ subagentRoleAdmitsV1,
8
+ type SubagentRoleV1,
9
+ type SubagentToolReachV1,
10
+ } from "./roles.js";
11
+
12
+ describe("the subagent role catalogs", () => {
13
+ test("names exactly the five roles a Task may ask for", () => {
14
+ expect(SUBAGENT_ROLES_V1).toEqual(TASK_TYPES_V1);
15
+ expect(Object.keys(SUBAGENT_ROLE_SUMMARIES_V1).sort()).toEqual(
16
+ [...TASK_TYPES_V1].sort(),
17
+ );
18
+ });
19
+
20
+ /**
21
+ * The table, exactly as `docs/research/grokbot-computer.md` l.351–356 states
22
+ * it: `executor` gets every work tool, `browserUse` gets the browser page
23
+ * tools and nothing else, `computerUse` gets the shell, the desktop and the
24
+ * browser, and the two video roles get what they were given to read and no
25
+ * Computer at all.
26
+ */
27
+ const table: Record<SubagentRoleV1, readonly SubagentToolReachV1[]> = {
28
+ executor: ["read", "handoff", "work", "browser", "desktop"],
29
+ browserUse: ["read", "handoff", "browser"],
30
+ computerUse: ["read", "handoff", "browser", "desktop"],
31
+ watchVideo: ["read", "handoff"],
32
+ videoReview: ["read", "handoff"],
33
+ };
34
+
35
+ for (const role of SUBAGENT_ROLES_V1) {
36
+ test(`${role} reaches exactly its catalog`, () => {
37
+ const reaches = Object.keys(
38
+ SUBAGENT_TOOL_REACH_V1,
39
+ ) as SubagentToolReachV1[];
40
+ const admitted = reaches.filter((reach) =>
41
+ subagentRoleAdmitsV1(role, reach),
42
+ );
43
+ expect(admitted.sort()).toEqual([...table[role]].sort());
44
+ });
45
+ }
46
+
47
+ test("no role reaches the Computer except executor and computerUse", () => {
48
+ for (const role of SUBAGENT_ROLES_V1) {
49
+ const desktop = subagentRoleAdmitsV1(role, "desktop");
50
+ expect(desktop).toBe(role === "executor" || role === "computerUse");
51
+ }
52
+ });
53
+
54
+ test("every role can read its attachments and hand its Turn back", () => {
55
+ for (const role of SUBAGENT_ROLES_V1) {
56
+ expect(subagentRoleAdmitsV1(role, "read")).toBe(true);
57
+ expect(subagentRoleAdmitsV1(role, "handoff")).toBe(true);
58
+ }
59
+ });
60
+ });