@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/roles.ts ADDED
@@ -0,0 +1,76 @@
1
+ // The five subagent roles, and the tool reach each one has.
2
+ //
3
+ // A role is the *second* ceiling dimension on the tool catalog. The first is
4
+ // the turn type: a `subagent` Turn is never offered `Task`, `user_voice`, or
5
+ // anything else a Package declared chat-only. The second is the role: within
6
+ // the work tools a subagent Turn does reach, `browserUse` reaches the browser
7
+ // and not the shell, and `watchVideo` reaches neither.
8
+ //
9
+ // The kernel holds none of this. `ToolRegistry` treats a role exactly as it
10
+ // treats a turn type — an opaque string a registration may narrow itself by —
11
+ // and every table below is *declaration*: a Package says which roles its tool
12
+ // serves, in its own tool definition or its own manifest Capability, and the
13
+ // registry intersects the two. This module is where the first-party Packages'
14
+ // answers are written down together so they can be read as one catalog and
15
+ // tested as one table.
16
+ //
17
+ // Reference: `docs/research/grokbot-computer.md` l.351–356.
18
+
19
+ import { TASK_TYPES_V1, type TaskTypeV1 } from "./records.js";
20
+
21
+ /** A subagent role. The same five names a `Task` may name as its `type`. */
22
+ export type SubagentRoleV1 = TaskTypeV1;
23
+
24
+ /** Every role, in catalog order. */
25
+ export const SUBAGENT_ROLES_V1: readonly SubagentRoleV1[] = TASK_TYPES_V1;
26
+
27
+ /**
28
+ * What each role is for, in one line, as the `Task` tool describes it and as
29
+ * the task list shows it.
30
+ */
31
+ export const SUBAGENT_ROLE_SUMMARIES_V1: Record<SubagentRoleV1, string> = {
32
+ executor: "does general work with the Bot's full work toolset",
33
+ browserUse: "drives web pages through the browser, and nothing else",
34
+ computerUse: "drives the shared desktop: shell, screen, and browser",
35
+ watchVideo: "watches the attachments it was given and reports on them",
36
+ videoReview: "reviews the attachments it was given and reports on them",
37
+ };
38
+
39
+ /**
40
+ * The reach one tool declares, as the set of roles it is offered to.
41
+ *
42
+ * A Package writes the literal array in its own tool definition — these
43
+ * constants are the record of what the first-party answers *are*, not a
44
+ * runtime dependency: `plugin-computer` cannot import this Package, and should
45
+ * not have to, for the kernel to enforce the ceiling.
46
+ */
47
+ export const SUBAGENT_TOOL_REACH_V1 = {
48
+ /**
49
+ * Reading, and the attachments a task was handed. Every role, including the
50
+ * two video roles, whose whole job is to read what they were given.
51
+ */
52
+ read: SUBAGENT_ROLES_V1,
53
+ /** Handing the Turn back to the parent. Every role can finish. */
54
+ handoff: SUBAGENT_ROLES_V1,
55
+ /** General work tools: memory, skills, routines, MCP, the web, authoring. */
56
+ work: ["executor"],
57
+ /** Page-level browser control. */
58
+ browser: ["executor", "browserUse", "computerUse"],
59
+ /** The shell and the desktop screen: `computer_exec`, screenshots, processes. */
60
+ desktop: ["executor", "computerUse"],
61
+ } as const satisfies Record<string, readonly SubagentRoleV1[]>;
62
+
63
+ /** The reach names, for a table-driven test to walk. */
64
+ export type SubagentToolReachV1 = keyof typeof SUBAGENT_TOOL_REACH_V1;
65
+
66
+ /**
67
+ * Whether a tool of this reach is offered to this role — the same predicate
68
+ * `ToolRegistry` applies, restated over the reach names so the catalog can be
69
+ * asserted as a table rather than as a mounted runtime.
70
+ */
71
+ export function subagentRoleAdmitsV1(
72
+ role: SubagentRoleV1,
73
+ reach: SubagentToolReachV1,
74
+ ): boolean {
75
+ return (SUBAGENT_TOOL_REACH_V1[reach] as readonly string[]).includes(role);
76
+ }
package/src/shared.ts ADDED
@@ -0,0 +1,232 @@
1
+ // The DTOs the task surface crosses seams with, and their exact codecs.
2
+ //
3
+ // A view is projected from a `TaskRecordV1` and carries strictly less: no
4
+ // prompt, no binding secrets, no child transcript. A child Session never enters
5
+ // the visible transcript at all (ADR 0017 — the child is an execution host, and
6
+ // its Session is its own durable state), so this list is the only door onto a
7
+ // task and it is deliberately a narrow one.
8
+
9
+ import {
10
+ isTaskIdV1,
11
+ subagentExactKeys,
12
+ subagentText,
13
+ subagentTimestamp,
14
+ SubagentDecodeError,
15
+ TASK_DESCRIPTION_MAX_V1,
16
+ TASK_STATUSES_V1,
17
+ TASK_SUMMARY_MAX_V1,
18
+ TASK_TYPES_V1,
19
+ type TaskRecordV1,
20
+ type TaskStatusV1,
21
+ type TaskTypeV1,
22
+ } from "./records.js";
23
+
24
+ /** Most rows one task-list answer carries. */
25
+ export const TASK_LIST_LIMIT_V1 = 50;
26
+
27
+ export interface TaskViewV1 {
28
+ schemaVersion: 1;
29
+ taskId: string;
30
+ type: TaskTypeV1;
31
+ description: string;
32
+ status: TaskStatusV1;
33
+ model: string;
34
+ background: boolean;
35
+ createdAt: string;
36
+ deadlineAt: string;
37
+ settledAt?: string;
38
+ summary?: string;
39
+ failure?: string;
40
+ }
41
+
42
+ export interface TaskListViewV1 {
43
+ schemaVersion: 1;
44
+ botId: string;
45
+ active: number;
46
+ tasks: TaskViewV1[];
47
+ }
48
+
49
+ /** The view one durable record projects onto. Never the reverse. */
50
+ export function taskViewV1(record: TaskRecordV1): TaskViewV1 {
51
+ return {
52
+ schemaVersion: 1,
53
+ taskId: record.taskId,
54
+ type: record.type,
55
+ description: record.description,
56
+ status: record.status,
57
+ model: record.model.slug,
58
+ background: record.background,
59
+ createdAt: record.createdAt,
60
+ deadlineAt: record.deadlineAt,
61
+ ...(record.outcome === undefined
62
+ ? {}
63
+ : {
64
+ settledAt: record.outcome.settledAt,
65
+ ...(record.outcome.summary === undefined
66
+ ? {}
67
+ : { summary: record.outcome.summary }),
68
+ ...(record.outcome.failure === undefined
69
+ ? {}
70
+ : { failure: record.outcome.failure }),
71
+ }),
72
+ };
73
+ }
74
+
75
+ function record(value: unknown, label: string): Record<string, unknown> {
76
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
77
+ throw new SubagentDecodeError(`${label} must be an object`);
78
+ }
79
+ return value as Record<string, unknown>;
80
+ }
81
+
82
+ export function decodeTaskViewV1(
83
+ value: unknown,
84
+ label = "task view",
85
+ ): TaskViewV1 {
86
+ const candidate = record(value, label);
87
+ subagentExactKeys(
88
+ candidate,
89
+ [
90
+ "schemaVersion",
91
+ "taskId",
92
+ "type",
93
+ "description",
94
+ "status",
95
+ "model",
96
+ "background",
97
+ "createdAt",
98
+ "deadlineAt",
99
+ ],
100
+ ["settledAt", "summary", "failure"],
101
+ label,
102
+ );
103
+ if (candidate.schemaVersion !== 1) {
104
+ throw new SubagentDecodeError(`${label} schemaVersion is unsupported`);
105
+ }
106
+ if (!isTaskIdV1(candidate.taskId)) {
107
+ throw new SubagentDecodeError(`${label} taskId is invalid`);
108
+ }
109
+ const type = TASK_TYPES_V1.find((known) => known === candidate.type);
110
+ if (!type) throw new SubagentDecodeError(`${label} type is invalid`);
111
+ const status = TASK_STATUSES_V1.find((known) => known === candidate.status);
112
+ if (!status) throw new SubagentDecodeError(`${label} status is invalid`);
113
+ if (typeof candidate.background !== "boolean") {
114
+ throw new SubagentDecodeError(`${label} background must be a boolean`);
115
+ }
116
+ return {
117
+ schemaVersion: 1,
118
+ taskId: candidate.taskId,
119
+ type,
120
+ description: subagentText(
121
+ candidate.description,
122
+ TASK_DESCRIPTION_MAX_V1,
123
+ `${label}.description`,
124
+ ),
125
+ status,
126
+ model: subagentText(candidate.model, 512, `${label}.model`),
127
+ background: candidate.background,
128
+ createdAt: subagentTimestamp(candidate.createdAt, `${label}.createdAt`),
129
+ deadlineAt: subagentTimestamp(candidate.deadlineAt, `${label}.deadlineAt`),
130
+ ...(candidate.settledAt === undefined
131
+ ? {}
132
+ : {
133
+ settledAt: subagentTimestamp(
134
+ candidate.settledAt,
135
+ `${label}.settledAt`,
136
+ ),
137
+ }),
138
+ ...(candidate.summary === undefined
139
+ ? {}
140
+ : {
141
+ summary: subagentText(
142
+ candidate.summary,
143
+ TASK_SUMMARY_MAX_V1,
144
+ `${label}.summary`,
145
+ ),
146
+ }),
147
+ ...(candidate.failure === undefined
148
+ ? {}
149
+ : {
150
+ failure: subagentText(
151
+ candidate.failure,
152
+ TASK_SUMMARY_MAX_V1,
153
+ `${label}.failure`,
154
+ ),
155
+ }),
156
+ };
157
+ }
158
+
159
+ export function decodeTaskListViewV1(value: unknown): TaskListViewV1 {
160
+ const label = "task list";
161
+ const candidate = record(value, label);
162
+ subagentExactKeys(
163
+ candidate,
164
+ ["schemaVersion", "botId", "active", "tasks"],
165
+ [],
166
+ label,
167
+ );
168
+ if (candidate.schemaVersion !== 1) {
169
+ throw new SubagentDecodeError(`${label} schemaVersion is unsupported`);
170
+ }
171
+ if (
172
+ !Number.isSafeInteger(candidate.active) ||
173
+ (candidate.active as number) < 0
174
+ ) {
175
+ throw new SubagentDecodeError(`${label} active is invalid`);
176
+ }
177
+ if (!Array.isArray(candidate.tasks)) {
178
+ throw new SubagentDecodeError(`${label} tasks must be an array`);
179
+ }
180
+ if (candidate.tasks.length > TASK_LIST_LIMIT_V1) {
181
+ throw new SubagentDecodeError(`${label} carries too many tasks`);
182
+ }
183
+ return {
184
+ schemaVersion: 1,
185
+ botId: subagentText(candidate.botId, 128, `${label}.botId`),
186
+ active: candidate.active as number,
187
+ tasks: candidate.tasks.map((entry, index) =>
188
+ decodeTaskViewV1(entry, `${label}.tasks[${index}]`),
189
+ ),
190
+ };
191
+ }
192
+
193
+ /** The dispatch one `Task` call becomes, once the tool input has been decoded. */
194
+ export interface TaskDispatchReceiptV1 {
195
+ schemaVersion: 1;
196
+ status: "dispatched";
197
+ taskId: string;
198
+ model: string;
199
+ background: boolean;
200
+ }
201
+
202
+ export function decodeTaskDispatchReceiptV1(
203
+ value: unknown,
204
+ ): TaskDispatchReceiptV1 {
205
+ const label = "task dispatch receipt";
206
+ const candidate = record(value, label);
207
+ subagentExactKeys(
208
+ candidate,
209
+ ["schemaVersion", "status", "taskId", "model", "background"],
210
+ [],
211
+ label,
212
+ );
213
+ if (candidate.schemaVersion !== 1) {
214
+ throw new SubagentDecodeError(`${label} schemaVersion is unsupported`);
215
+ }
216
+ if (candidate.status !== "dispatched") {
217
+ throw new SubagentDecodeError(`${label} status is invalid`);
218
+ }
219
+ if (!isTaskIdV1(candidate.taskId)) {
220
+ throw new SubagentDecodeError(`${label} taskId is invalid`);
221
+ }
222
+ if (typeof candidate.background !== "boolean") {
223
+ throw new SubagentDecodeError(`${label} background must be a boolean`);
224
+ }
225
+ return {
226
+ schemaVersion: 1,
227
+ status: "dispatched",
228
+ taskId: candidate.taskId,
229
+ model: subagentText(candidate.model, 512, `${label}.model`),
230
+ background: candidate.background,
231
+ };
232
+ }
@@ -0,0 +1,148 @@
1
+ // The Bot Durable Object storage keys the Subagents Package owns.
2
+ //
3
+ // They live here rather than in `@frockbot/kernel-do` because the kernel
4
+ // imports no Package and holds no product policy; the Durable Object hands this
5
+ // Package a storage seam and this module decides what it writes under.
6
+ //
7
+ // Every key below is *parent* state. ADR 0017: the parent Bot Durable Object is
8
+ // the authority for a task — admission, bounds, leases, lifecycle, terminal
9
+ // outcome — and the Subagent Durable Object holds only its own Session.
10
+
11
+ import { TASK_ID_MAX_V1 } from "./records.js";
12
+
13
+ /** One `TaskRecordV1`. */
14
+ export const TASK_PREFIX = "task:";
15
+ /**
16
+ * The membership set that *is* the per-Bot concurrency counter. Written in the
17
+ * same transaction that writes the record, deleted in the one that settles it,
18
+ * so counting keys and reading records can never disagree.
19
+ */
20
+ export const TASK_ACTIVE_PREFIX = "task-active:";
21
+ /** One bounded queue of pending `task_message` payloads (G2 drains them). */
22
+ export const TASK_MESSAGE_PREFIX = "task-msg:";
23
+ /**
24
+ * The durable intent to cancel one task: written before the child is asked to
25
+ * stop, so an interrupted `task_stop` is read back rather than repeated, and
26
+ * deleted by the settle that makes the cancellation terminal.
27
+ */
28
+ export const TASK_STOP_PREFIX = "task-stop:";
29
+ /** The `computerUse` desktop-lease intent: the effect named before the host call. */
30
+ export const TASK_DESKTOP_LEASE_KEY = "task-lease:desktop";
31
+ /** The bounded reverse index the Bot-level task list reads, newest first. */
32
+ export const TASK_INDEX_PREFIX = "task-index:";
33
+ /** The task index's monotonic sequence, addressable by key alone. */
34
+ export const TASK_INDEX_CURSOR_KEY = "task-index-cursor";
35
+
36
+ /**
37
+ * The child Durable Object's own record of what it was handed. It is the only
38
+ * key in this module written in the *child* object, and it is deliberately not
39
+ * authority: it records the parent, the pinned Composition generation, and the
40
+ * pinned binding so a child that is asked what it is doing can answer without
41
+ * the parent telling it again.
42
+ */
43
+ export const TASK_CONTEXT_PREFIX = "task-context:";
44
+
45
+ /** Most index rows retained. Trimming loses an index row, never a task record. */
46
+ export const TASK_INDEX_LIMIT = 100;
47
+
48
+ const SEQUENCE_CEILING = 1_000_000_000;
49
+
50
+ function requireTaskId(taskId: string): string {
51
+ if (
52
+ typeof taskId !== "string" ||
53
+ taskId.length === 0 ||
54
+ taskId.length > TASK_ID_MAX_V1 ||
55
+ taskId.includes(":")
56
+ ) {
57
+ throw new Error("task id is invalid");
58
+ }
59
+ return taskId;
60
+ }
61
+
62
+ export function taskKeyV1(taskId: string): string {
63
+ return `${TASK_PREFIX}${requireTaskId(taskId)}`;
64
+ }
65
+
66
+ export function taskActiveKeyV1(taskId: string): string {
67
+ return `${TASK_ACTIVE_PREFIX}${requireTaskId(taskId)}`;
68
+ }
69
+
70
+ export function taskContextKeyV1(taskId: string): string {
71
+ return `${TASK_CONTEXT_PREFIX}${requireTaskId(taskId)}`;
72
+ }
73
+
74
+ export function taskStopKeyV1(taskId: string): string {
75
+ return `${TASK_STOP_PREFIX}${requireTaskId(taskId)}`;
76
+ }
77
+
78
+ export function taskMessagePrefixV1(taskId: string): string {
79
+ return `${TASK_MESSAGE_PREFIX}${requireTaskId(taskId)}:`;
80
+ }
81
+
82
+ /** Message keys ascend, so a prefix listing drains oldest first. */
83
+ export function taskMessageKeyV1(taskId: string, seq: number): string {
84
+ if (!Number.isSafeInteger(seq) || seq < 0 || seq >= SEQUENCE_CEILING) {
85
+ throw new Error("task message sequence is out of range");
86
+ }
87
+ return `${taskMessagePrefixV1(taskId)}${String(seq).padStart(10, "0")}`;
88
+ }
89
+
90
+ /**
91
+ * Index keys descend, so a prefix listing returns the newest task first without
92
+ * reading every record.
93
+ */
94
+ export function taskIndexKeyV1(seq: number): string {
95
+ if (!Number.isSafeInteger(seq) || seq < 0 || seq >= SEQUENCE_CEILING) {
96
+ throw new Error("task index sequence is out of range");
97
+ }
98
+ return `${TASK_INDEX_PREFIX}${String(SEQUENCE_CEILING - seq).padStart(10, "0")}`;
99
+ }
100
+
101
+ /** The sequence the next index row takes, given the keys already stored. */
102
+ export function nextTaskIndexSequenceV1(keys: readonly string[]): number {
103
+ let highest = -1;
104
+ for (const key of keys) {
105
+ const encoded = Number(key.slice(key.lastIndexOf(":") + 1));
106
+ if (!Number.isSafeInteger(encoded)) continue;
107
+ highest = Math.max(highest, SEQUENCE_CEILING - encoded);
108
+ }
109
+ return highest + 1;
110
+ }
111
+
112
+ /**
113
+ * The name of the Subagent Durable Object one task runs in.
114
+ *
115
+ * The same `BotState` class in the same `BOT_STATES` namespace, so there is no
116
+ * migration and no second identity: `#` cannot appear in a Bot id (it is
117
+ * outside `PUBLIC_IDENTIFIER_PATTERN`), so the suffix is unforgeable from any
118
+ * caller-supplied path segment and a Subagent object can never collide with a
119
+ * Bot object.
120
+ */
121
+ export function subagentDurableObjectNameV1(identity: {
122
+ userId: string;
123
+ botId: string;
124
+ taskId: string;
125
+ }): string {
126
+ return `${identity.userId}:${identity.botId}#task:${requireTaskId(identity.taskId)}`;
127
+ }
128
+
129
+ /** The Session a child Turn records its own events on. */
130
+ export function taskSessionIdV1(taskId: string): string {
131
+ return `task:${requireTaskId(taskId)}`;
132
+ }
133
+
134
+ /**
135
+ * The task whose Session and Subagent Durable Object a child Turn actually
136
+ * runs in — the *anchor*.
137
+ *
138
+ * For a first dispatch that is the task itself. For a resume it is the task
139
+ * that was resumed, because "the same child Durable Object and Session" is the
140
+ * whole point of resuming: the child picks its prior transcript up from its own
141
+ * cursor rather than starting blank a second time.
142
+ */
143
+ export function taskAnchorIdV1(childSessionId: string): string {
144
+ const anchor = childSessionId.startsWith("task:")
145
+ ? childSessionId.slice("task:".length)
146
+ : childSessionId;
147
+ return requireTaskId(anchor);
148
+ }