@frockbot/plugin-subagents 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frockbot.json +33 -0
- package/package.json +38 -6
- package/src/agent.test.ts +480 -0
- package/src/agent.ts +1078 -0
- package/src/backend.ts +193 -0
- package/src/index.ts +8 -0
- package/src/manifest.ts +3 -0
- package/src/models.test.ts +175 -0
- package/src/models.ts +185 -0
- package/src/quota.test.ts +82 -0
- package/src/quota.ts +222 -0
- package/src/records.test.ts +245 -0
- package/src/records.ts +649 -0
- package/src/roles.test.ts +60 -0
- package/src/roles.ts +76 -0
- package/src/shared.ts +232 -0
- package/src/storage-keys.ts +148 -0
- package/src/store.test.ts +500 -0
- package/src/store.ts +695 -0
- package/src/testing.ts +58 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/agent.ts
ADDED
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
// The Subagents runtime Contribution: one tool, `Task`, and one prompt section.
|
|
2
|
+
//
|
|
3
|
+
// GrokBot's `Task` (docs/research/grokbot-computer.md l.468–472) dispatches a
|
|
4
|
+
// child agent that shares none of the parent's memory or transcript and hands
|
|
5
|
+
// its result back when it is done. Here that child is a *Turn*, not an agent:
|
|
6
|
+
// ADR 0017 runs it in a Subagent Durable Object of the same Bot that holds no
|
|
7
|
+
// authority, while this Bot's own Durable Object admits it, pins its
|
|
8
|
+
// Composition and model, and records its lifecycle and terminal result.
|
|
9
|
+
//
|
|
10
|
+
// Depth is one, and it is not a counter. `Task` declares
|
|
11
|
+
// `admission: {turnTypes: ["chat", "automation"]}`, so a `subagent` Turn is
|
|
12
|
+
// never offered the tool and there is no grandchild to bound.
|
|
13
|
+
//
|
|
14
|
+
// Nothing here is authority. The tool decodes the model's words, resolves a
|
|
15
|
+
// slug against the catalog this Turn was offered, and calls the host seam the
|
|
16
|
+
// Bot Durable Object supplied; every durable decision — the bounds, the record,
|
|
17
|
+
// the dispatch — is made behind that seam, where the storage is.
|
|
18
|
+
import type {
|
|
19
|
+
PromptSection,
|
|
20
|
+
ToolDefinition,
|
|
21
|
+
ToolExecutionContext,
|
|
22
|
+
ToolExecutionResult,
|
|
23
|
+
TurnTypeV1,
|
|
24
|
+
} from "@frockbot/kernel-contracts";
|
|
25
|
+
// Merges the Agent loop's event declarations into the cordis Context type.
|
|
26
|
+
import type {} from "@frockbot/kernel-agent-loop/agent";
|
|
27
|
+
import type { Plugin } from "cordis";
|
|
28
|
+
import manifest from "../frockbot.json" with { type: "json" };
|
|
29
|
+
import {
|
|
30
|
+
renderAvailableSubagentModelsPromptV1,
|
|
31
|
+
resolveSubagentModelV1,
|
|
32
|
+
type SubagentModelOptionV1,
|
|
33
|
+
} from "./models.js";
|
|
34
|
+
import {
|
|
35
|
+
DEFAULT_TASK_TYPE_V1,
|
|
36
|
+
isTaskIdV1,
|
|
37
|
+
SubagentDecodeError,
|
|
38
|
+
TASK_ATTACHMENT_LIMIT_V1,
|
|
39
|
+
TASK_ATTACHMENT_PATH_MAX_V1,
|
|
40
|
+
TASK_DESCRIPTION_MAX_V1,
|
|
41
|
+
TASK_ID_MAX_V1,
|
|
42
|
+
TASK_MESSAGE_MAX_V1,
|
|
43
|
+
TASK_PROMPT_MAX_BYTES_V1,
|
|
44
|
+
TASK_TYPES_V1,
|
|
45
|
+
utf8ByteLengthV1,
|
|
46
|
+
type TaskModelV1,
|
|
47
|
+
type TaskStatusV1,
|
|
48
|
+
type TaskTypeV1,
|
|
49
|
+
} from "./records.js";
|
|
50
|
+
|
|
51
|
+
export const TASK_TOOL_V1 = "Task";
|
|
52
|
+
export const TASK_CHECK_TOOL_V1 = "task_check";
|
|
53
|
+
export const TASK_MESSAGE_TOOL_V1 = "task_message";
|
|
54
|
+
export const TASK_STOP_TOOL_V1 = "task_stop";
|
|
55
|
+
export const TASK_RESUME_TOOL_V1 = "task_resume";
|
|
56
|
+
/** The manifest Capability the tool is contributed under. */
|
|
57
|
+
export const TASK_DISPATCH_CAPABILITY_V1 = "task-dispatch";
|
|
58
|
+
/** The manifest Capability the four lifecycle tools are contributed under. */
|
|
59
|
+
export const TASK_LIFECYCLE_CAPABILITY_V1 = "task-lifecycle";
|
|
60
|
+
/** The prompt section id, so a host can find it without knowing its text. */
|
|
61
|
+
export const SUBAGENT_MODELS_SECTION_V1 = "subagent-models";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The durable ceiling this Package's own manifest puts on the Capability, read
|
|
65
|
+
* back out of the manifest rather than restated here — the
|
|
66
|
+
* `shellAdmissionCeilingV1` pattern. A registration that drifts from the
|
|
67
|
+
* manifest is narrowed to the manifest, so the two cannot disagree.
|
|
68
|
+
*/
|
|
69
|
+
export function subagentsAdmissionCeilingV1(
|
|
70
|
+
capabilityId: string,
|
|
71
|
+
): readonly TurnTypeV1[] | undefined {
|
|
72
|
+
const capabilities = (
|
|
73
|
+
manifest as {
|
|
74
|
+
configuration?: {
|
|
75
|
+
capabilities?: Array<{
|
|
76
|
+
id: string;
|
|
77
|
+
admission?: { turnTypes: string[] };
|
|
78
|
+
}>;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
).configuration?.capabilities;
|
|
82
|
+
const turnTypes = capabilities?.find(
|
|
83
|
+
(candidate) => candidate.id === capabilityId,
|
|
84
|
+
)?.admission?.turnTypes;
|
|
85
|
+
if (!turnTypes) return undefined;
|
|
86
|
+
return turnTypes as readonly TurnTypeV1[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One queued `task_message` on its way into the child's next step. */
|
|
90
|
+
export interface PendingTaskMessageV1 {
|
|
91
|
+
seq: number;
|
|
92
|
+
message: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The message id one delivered `task_message` is recorded under.
|
|
97
|
+
*
|
|
98
|
+
* Derived from the task and the queue sequence, so the child's own Session
|
|
99
|
+
* says which message this was and a redelivery would be visible as a repeat
|
|
100
|
+
* rather than passing as a new instruction.
|
|
101
|
+
*/
|
|
102
|
+
export function taskMessageInputIdV1(taskId: string, seq: number): string {
|
|
103
|
+
return `task-msg:${taskId}:${seq}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* How one delivered message reads to the child. It is a message from the
|
|
108
|
+
* parent Bot, not from a user, and it says so: the child has no transcript to
|
|
109
|
+
* place it in and would otherwise read it as the start of a new conversation.
|
|
110
|
+
*/
|
|
111
|
+
export function taskMessageInputTextV1(message: string): string {
|
|
112
|
+
return `Your dispatcher sent you a message: ${message}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Folds the messages a child claimed into the step it is about to take.
|
|
117
|
+
*
|
|
118
|
+
* Pure, and separate from the middleware that calls it, because this is the
|
|
119
|
+
* whole of the delivery rule: seq order, one input per message, an id derived
|
|
120
|
+
* from the queue so a repeat would be visible as a repeat, and a step that was
|
|
121
|
+
* rejected stays rejected.
|
|
122
|
+
*/
|
|
123
|
+
export function foldPendingTaskMessagesV1(
|
|
124
|
+
decision:
|
|
125
|
+
| { kind: "enter"; inputs: { messageId: string; text: string }[] }
|
|
126
|
+
| {
|
|
127
|
+
kind: "reject";
|
|
128
|
+
reason: string;
|
|
129
|
+
},
|
|
130
|
+
pending: readonly PendingTaskMessageV1[],
|
|
131
|
+
taskId: string,
|
|
132
|
+
):
|
|
133
|
+
| { kind: "enter"; inputs: { messageId: string; text: string }[] }
|
|
134
|
+
| { kind: "reject"; reason: string } {
|
|
135
|
+
if (decision.kind !== "enter" || pending.length === 0) return decision;
|
|
136
|
+
return {
|
|
137
|
+
kind: "enter",
|
|
138
|
+
inputs: [
|
|
139
|
+
...decision.inputs,
|
|
140
|
+
...[...pending]
|
|
141
|
+
.sort((left, right) => left.seq - right.seq)
|
|
142
|
+
.map((entry) => ({
|
|
143
|
+
messageId: taskMessageInputIdV1(taskId, entry.seq),
|
|
144
|
+
text: taskMessageInputTextV1(entry.message),
|
|
145
|
+
})),
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** What one decoded `Task` call asks for. */
|
|
151
|
+
export interface TaskToolInputV1 {
|
|
152
|
+
description: string;
|
|
153
|
+
prompt: string;
|
|
154
|
+
type: TaskTypeV1;
|
|
155
|
+
background: boolean;
|
|
156
|
+
model?: string;
|
|
157
|
+
attachments: string[];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** What the host does with a resolved dispatch. */
|
|
161
|
+
export interface SubagentDispatchRequestV1 {
|
|
162
|
+
description: string;
|
|
163
|
+
prompt: string;
|
|
164
|
+
type: TaskTypeV1;
|
|
165
|
+
background: boolean;
|
|
166
|
+
model: TaskModelV1;
|
|
167
|
+
attachments: string[];
|
|
168
|
+
/** The parent Turn's effect identifier: the task's identity is derived from it. */
|
|
169
|
+
effectId: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export type SubagentDispatchOutcomeV1 =
|
|
173
|
+
| { status: "dispatched"; taskId: string; model: string }
|
|
174
|
+
/**
|
|
175
|
+
* A blocking dispatch whose child settled inside the window. The summary is
|
|
176
|
+
* the tool result, so a `background:false` Task reads like a call that
|
|
177
|
+
* returned rather than one that has to be checked on.
|
|
178
|
+
*/
|
|
179
|
+
| {
|
|
180
|
+
status: "settled";
|
|
181
|
+
taskId: string;
|
|
182
|
+
model: string;
|
|
183
|
+
taskStatus: TaskStatusV1;
|
|
184
|
+
summary?: string;
|
|
185
|
+
failure?: string;
|
|
186
|
+
}
|
|
187
|
+
| { status: "refused"; reason: string };
|
|
188
|
+
|
|
189
|
+
/** What one `task_check` answers. */
|
|
190
|
+
export type SubagentCheckOutcomeV1 =
|
|
191
|
+
| {
|
|
192
|
+
status: "known";
|
|
193
|
+
taskId: string;
|
|
194
|
+
taskType: TaskTypeV1;
|
|
195
|
+
description: string;
|
|
196
|
+
taskStatus: TaskStatusV1;
|
|
197
|
+
model: string;
|
|
198
|
+
summary?: string;
|
|
199
|
+
failure?: string;
|
|
200
|
+
queuedMessages: number;
|
|
201
|
+
}
|
|
202
|
+
| { status: "refused"; reason: string };
|
|
203
|
+
|
|
204
|
+
export type SubagentMessageOutcomeV1 =
|
|
205
|
+
| { status: "queued"; taskId: string; depth: number }
|
|
206
|
+
| { status: "refused"; reason: string };
|
|
207
|
+
|
|
208
|
+
export type SubagentStopOutcomeV1 =
|
|
209
|
+
{ status: "stopped"; taskId: string } | { status: "refused"; reason: string };
|
|
210
|
+
|
|
211
|
+
/** What one `task_resume` asks for: a new run in a finished task's child. */
|
|
212
|
+
export interface SubagentResumeRequestV1 {
|
|
213
|
+
resume: string;
|
|
214
|
+
prompt: string;
|
|
215
|
+
description?: string;
|
|
216
|
+
background: boolean;
|
|
217
|
+
effectId: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The host seam this Package receives. The Durable Object supplies it for one
|
|
222
|
+
* admitted Turn: without `writer` there is no Turn to attribute a dispatch to,
|
|
223
|
+
* and the tool is then not registered at all.
|
|
224
|
+
*/
|
|
225
|
+
export interface SubagentsRuntimeHostV1 {
|
|
226
|
+
botId: string;
|
|
227
|
+
writer?: { sessionId: string; turnId: string; runId: string };
|
|
228
|
+
/** The turn type this Turn was admitted as; the catalog is narrowed by it. */
|
|
229
|
+
turnType: TurnTypeV1;
|
|
230
|
+
/**
|
|
231
|
+
* The role a *child* Turn was admitted under, when this host is a Subagent
|
|
232
|
+
* Durable Object's. Absent in a parent Bot, which has no role.
|
|
233
|
+
*/
|
|
234
|
+
subagentRole?: TaskTypeV1;
|
|
235
|
+
/** The task this child is running, when this host is a child's. */
|
|
236
|
+
taskId?: string;
|
|
237
|
+
/**
|
|
238
|
+
* Claims the messages the parent has queued for this child, marking them
|
|
239
|
+
* delivered durably, and hands them back in `seq` order.
|
|
240
|
+
*
|
|
241
|
+
* Present only in a child: this is the seam that makes `task_message` mean
|
|
242
|
+
* something. GrokBot's `MessageSubagent` influences the *running* child, so
|
|
243
|
+
* a queue nobody reads is an empty queue — the child drains here on its way
|
|
244
|
+
* into each step of its Turn and folds what it gets into that step's inputs.
|
|
245
|
+
*/
|
|
246
|
+
drainMessages?(): Promise<readonly PendingTaskMessageV1[]>;
|
|
247
|
+
/** The models this Turn may dispatch onto, resolved from enabled Assignments. */
|
|
248
|
+
models(): readonly SubagentModelOptionV1[];
|
|
249
|
+
dispatch(
|
|
250
|
+
request: SubagentDispatchRequestV1,
|
|
251
|
+
): Promise<SubagentDispatchOutcomeV1>;
|
|
252
|
+
/** The four lifecycle seams. Every durable decision is behind them. */
|
|
253
|
+
check(taskId: string): Promise<SubagentCheckOutcomeV1>;
|
|
254
|
+
message(taskId: string, message: string): Promise<SubagentMessageOutcomeV1>;
|
|
255
|
+
stop(taskId: string): Promise<SubagentStopOutcomeV1>;
|
|
256
|
+
resume(request: SubagentResumeRequestV1): Promise<SubagentDispatchOutcomeV1>;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const TASK_INPUT_SCHEMA = {
|
|
260
|
+
type: "object",
|
|
261
|
+
properties: {
|
|
262
|
+
description: {
|
|
263
|
+
type: "string",
|
|
264
|
+
description:
|
|
265
|
+
"A short label for this task, shown to your user in the task list.",
|
|
266
|
+
},
|
|
267
|
+
prompt: {
|
|
268
|
+
type: "string",
|
|
269
|
+
description:
|
|
270
|
+
"The complete instruction the subagent runs. It starts blank: it cannot see this conversation, your memory, or anything you have not written here.",
|
|
271
|
+
},
|
|
272
|
+
type: {
|
|
273
|
+
type: "string",
|
|
274
|
+
enum: [...TASK_TYPES_V1],
|
|
275
|
+
description:
|
|
276
|
+
"The subagent's role, which fixes the tools it is offered. Defaults to executor.",
|
|
277
|
+
},
|
|
278
|
+
model: {
|
|
279
|
+
type: "string",
|
|
280
|
+
description:
|
|
281
|
+
"One slug from <available_subagent_models>. Omit it and the subagent runs on the model you are running on.",
|
|
282
|
+
},
|
|
283
|
+
background: {
|
|
284
|
+
type: "boolean",
|
|
285
|
+
description:
|
|
286
|
+
"Defaults to true. The subagent runs as its own Turn and you are notified when it finishes; do not poll for it.",
|
|
287
|
+
},
|
|
288
|
+
attachments: {
|
|
289
|
+
type: "array",
|
|
290
|
+
items: { type: "string" },
|
|
291
|
+
description:
|
|
292
|
+
"Workspace paths the subagent is given, at most four. Required by watchVideo.",
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
required: ["description", "prompt"],
|
|
296
|
+
additionalProperties: false,
|
|
297
|
+
} as const;
|
|
298
|
+
|
|
299
|
+
const TASK_DESCRIPTION = [
|
|
300
|
+
"Dispatch a subagent to do one self-contained piece of work.",
|
|
301
|
+
"It starts blank — it shares none of your memory and none of this transcript —",
|
|
302
|
+
"so `prompt` must be a complete brief, and its result reaches you as a summary and nothing more.",
|
|
303
|
+
"It runs as its own Turn, in the background by default: you are notified when it finishes, so do not poll.",
|
|
304
|
+
"A subagent cannot dispatch a subagent of its own.",
|
|
305
|
+
].join(" ");
|
|
306
|
+
|
|
307
|
+
export function decodeTaskToolInputV1(input: unknown): TaskToolInputV1 {
|
|
308
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
309
|
+
throw new SubagentDecodeError(`${TASK_TOOL_V1} input must be an object`);
|
|
310
|
+
}
|
|
311
|
+
const value = input as Record<string, unknown>;
|
|
312
|
+
const allowed = new Set([
|
|
313
|
+
"description",
|
|
314
|
+
"prompt",
|
|
315
|
+
"type",
|
|
316
|
+
"model",
|
|
317
|
+
"background",
|
|
318
|
+
"attachments",
|
|
319
|
+
]);
|
|
320
|
+
for (const key of Object.keys(value)) {
|
|
321
|
+
if (!allowed.has(key)) {
|
|
322
|
+
throw new SubagentDecodeError(
|
|
323
|
+
`${TASK_TOOL_V1} input has unknown field "${key}"`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const text = (name: string, maximum: number): string => {
|
|
328
|
+
const candidate = value[name];
|
|
329
|
+
if (typeof candidate !== "string" || candidate.trim().length === 0) {
|
|
330
|
+
throw new SubagentDecodeError(
|
|
331
|
+
`${TASK_TOOL_V1} ${name} must be a non-empty string`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
const trimmed = candidate.trim();
|
|
335
|
+
if (trimmed.length > maximum) {
|
|
336
|
+
throw new SubagentDecodeError(
|
|
337
|
+
`${TASK_TOOL_V1} ${name} must be at most ${maximum} characters`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
return trimmed;
|
|
341
|
+
};
|
|
342
|
+
const description = text("description", TASK_DESCRIPTION_MAX_V1);
|
|
343
|
+
const prompt = text("prompt", TASK_PROMPT_MAX_BYTES_V1);
|
|
344
|
+
// The prompt bound is stated in bytes, because that is what the child's Turn
|
|
345
|
+
// input is bounded in and a character count would let a multi-byte prompt
|
|
346
|
+
// through this door and be refused at the next one.
|
|
347
|
+
if (utf8ByteLengthV1(prompt) > TASK_PROMPT_MAX_BYTES_V1) {
|
|
348
|
+
throw new SubagentDecodeError(
|
|
349
|
+
`${TASK_TOOL_V1} prompt must be at most ${TASK_PROMPT_MAX_BYTES_V1} bytes`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
let type: TaskTypeV1 = DEFAULT_TASK_TYPE_V1;
|
|
353
|
+
if (value.type !== undefined) {
|
|
354
|
+
const named = TASK_TYPES_V1.find((known) => known === value.type);
|
|
355
|
+
if (!named) {
|
|
356
|
+
throw new SubagentDecodeError(
|
|
357
|
+
`${TASK_TOOL_V1} type must be one of ${TASK_TYPES_V1.join(", ")}`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
type = named;
|
|
361
|
+
}
|
|
362
|
+
if (value.background !== undefined && typeof value.background !== "boolean") {
|
|
363
|
+
throw new SubagentDecodeError(
|
|
364
|
+
`${TASK_TOOL_V1} background must be a boolean`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (value.model !== undefined && typeof value.model !== "string") {
|
|
368
|
+
throw new SubagentDecodeError(`${TASK_TOOL_V1} model must be a string`);
|
|
369
|
+
}
|
|
370
|
+
const attachments: string[] = [];
|
|
371
|
+
if (value.attachments !== undefined) {
|
|
372
|
+
if (!Array.isArray(value.attachments)) {
|
|
373
|
+
throw new SubagentDecodeError(
|
|
374
|
+
`${TASK_TOOL_V1} attachments must be an array`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (value.attachments.length > TASK_ATTACHMENT_LIMIT_V1) {
|
|
378
|
+
throw new SubagentDecodeError(
|
|
379
|
+
`${TASK_TOOL_V1} takes at most ${TASK_ATTACHMENT_LIMIT_V1} attachments`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
for (const entry of value.attachments) {
|
|
383
|
+
if (
|
|
384
|
+
typeof entry !== "string" ||
|
|
385
|
+
entry.trim().length === 0 ||
|
|
386
|
+
entry.trim().length > TASK_ATTACHMENT_PATH_MAX_V1
|
|
387
|
+
) {
|
|
388
|
+
throw new SubagentDecodeError(
|
|
389
|
+
`${TASK_TOOL_V1} attachment paths must be bounded non-empty strings`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
attachments.push(entry.trim());
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (type === "watchVideo" && attachments.length === 0) {
|
|
396
|
+
throw new SubagentDecodeError(
|
|
397
|
+
`${TASK_TOOL_V1} watchVideo needs at least one attachment to watch`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
description,
|
|
402
|
+
prompt,
|
|
403
|
+
type,
|
|
404
|
+
// GrokBot's default, and ours: a subagent that blocks its parent is the
|
|
405
|
+
// exception, not the rule.
|
|
406
|
+
background: value.background === undefined ? true : value.background,
|
|
407
|
+
...(value.model === undefined ? {} : { model: value.model }),
|
|
408
|
+
attachments,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function refusal(reason: string): ToolExecutionResult {
|
|
413
|
+
return { content: `${TASK_TOOL_V1} was refused: ${reason}`, isError: true };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The durable Session line one task lifecycle act leaves on the *parent*.
|
|
418
|
+
*
|
|
419
|
+
* The child's Session never enters the visible transcript (ADR 0017), so these
|
|
420
|
+
* events are the only thing the conversation says about a task, and the client
|
|
421
|
+
* draws the dispatch as a chip. Recording is best effort by construction: a
|
|
422
|
+
* line that could not be written must not turn a dispatch that happened into a
|
|
423
|
+
* tool error that says it did not.
|
|
424
|
+
*/
|
|
425
|
+
export interface SubagentEventRecorderV1 {
|
|
426
|
+
dispatched(event: {
|
|
427
|
+
occurrenceId: string;
|
|
428
|
+
taskId: string;
|
|
429
|
+
taskType: TaskTypeV1;
|
|
430
|
+
description: string;
|
|
431
|
+
model: string;
|
|
432
|
+
background: boolean;
|
|
433
|
+
}): void;
|
|
434
|
+
messaged(event: {
|
|
435
|
+
occurrenceId: string;
|
|
436
|
+
taskId: string;
|
|
437
|
+
message: string;
|
|
438
|
+
}): void;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function createTaskTool(
|
|
442
|
+
host: Pick<
|
|
443
|
+
SubagentsRuntimeHostV1,
|
|
444
|
+
"botId" | "turnType" | "models" | "dispatch"
|
|
445
|
+
> & {
|
|
446
|
+
writer: NonNullable<SubagentsRuntimeHostV1["writer"]>;
|
|
447
|
+
},
|
|
448
|
+
record?: SubagentEventRecorderV1,
|
|
449
|
+
): ToolDefinition {
|
|
450
|
+
return {
|
|
451
|
+
name: TASK_TOOL_V1,
|
|
452
|
+
description: TASK_DESCRIPTION,
|
|
453
|
+
inputSchema: structuredClone(TASK_INPUT_SCHEMA) as unknown as Record<
|
|
454
|
+
string,
|
|
455
|
+
unknown
|
|
456
|
+
>,
|
|
457
|
+
// A dispatch is not idempotent in the loop's sense — but it is idempotent
|
|
458
|
+
// on the effect identifier, which is what the host derives the task id
|
|
459
|
+
// from, so a reconciled call reads its own task back.
|
|
460
|
+
idempotent: false,
|
|
461
|
+
admission: { turnTypes: ["chat", "automation"] },
|
|
462
|
+
validate: (input: unknown) => {
|
|
463
|
+
try {
|
|
464
|
+
decodeTaskToolInputV1(input);
|
|
465
|
+
return true;
|
|
466
|
+
} catch {
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
execute: async (
|
|
471
|
+
input: unknown,
|
|
472
|
+
context: ToolExecutionContext,
|
|
473
|
+
): Promise<ToolExecutionResult> => {
|
|
474
|
+
let decoded: TaskToolInputV1;
|
|
475
|
+
try {
|
|
476
|
+
decoded = decodeTaskToolInputV1(input);
|
|
477
|
+
} catch (error) {
|
|
478
|
+
return refusal(error instanceof Error ? error.message : String(error));
|
|
479
|
+
}
|
|
480
|
+
const resolution = resolveSubagentModelV1(host.models(), decoded.model);
|
|
481
|
+
if (resolution.status === "refused") {
|
|
482
|
+
return refusal(resolution.reason);
|
|
483
|
+
}
|
|
484
|
+
let outcome: SubagentDispatchOutcomeV1;
|
|
485
|
+
try {
|
|
486
|
+
outcome = await host.dispatch({
|
|
487
|
+
description: decoded.description,
|
|
488
|
+
prompt: decoded.prompt,
|
|
489
|
+
type: decoded.type,
|
|
490
|
+
background: decoded.background,
|
|
491
|
+
model: resolution.model,
|
|
492
|
+
attachments: decoded.attachments,
|
|
493
|
+
effectId: context.effectId,
|
|
494
|
+
});
|
|
495
|
+
} catch (error) {
|
|
496
|
+
return refusal(error instanceof Error ? error.message : String(error));
|
|
497
|
+
}
|
|
498
|
+
if (outcome.status === "refused") return refusal(outcome.reason);
|
|
499
|
+
record?.dispatched({
|
|
500
|
+
occurrenceId: context.effectId,
|
|
501
|
+
taskId: outcome.taskId,
|
|
502
|
+
taskType: decoded.type,
|
|
503
|
+
description: decoded.description,
|
|
504
|
+
model: outcome.model,
|
|
505
|
+
background: decoded.background,
|
|
506
|
+
});
|
|
507
|
+
if (outcome.status === "settled") {
|
|
508
|
+
// A `background:false` dispatch whose child finished inside the
|
|
509
|
+
// blocking window: the summary is the tool result, so the Turn reads
|
|
510
|
+
// it as a call that returned rather than one to check on.
|
|
511
|
+
return {
|
|
512
|
+
content: `${decoded.type} subagent ${outcome.taskId} ${outcome.taskStatus}. ${settledSummaryV1(outcome)}`,
|
|
513
|
+
isError: outcome.taskStatus !== "completed",
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
content: [
|
|
518
|
+
`Dispatched ${decoded.type} subagent ${outcome.taskId} on ${outcome.model}.`,
|
|
519
|
+
"It runs as its own Turn and cannot see this conversation.",
|
|
520
|
+
decoded.background
|
|
521
|
+
? DO_NOT_POLL
|
|
522
|
+
: `It is still running, id ${outcome.taskId}; it continues in the background and ${DO_NOT_POLL.charAt(0).toLowerCase()}${DO_NOT_POLL.slice(1)}`,
|
|
523
|
+
].join(" "),
|
|
524
|
+
isError: false,
|
|
525
|
+
};
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// The lifecycle tools (`docs/research/grokbot-computer.md` l.415–418).
|
|
532
|
+
//
|
|
533
|
+
// All four are the same shape: decode the model's words, call the host seam,
|
|
534
|
+
// render the answer. None of them holds durable state, and none of them can
|
|
535
|
+
// widen what a task was admitted to do — a check reads, a message queues, a
|
|
536
|
+
// stop cancels, and a resume dispatches a new run under the *same* admission
|
|
537
|
+
// the first one was granted.
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
function taskIdArgument(value: unknown, tool: string): string {
|
|
541
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
542
|
+
throw new SubagentDecodeError(`${tool} taskId must be a non-empty string`);
|
|
543
|
+
}
|
|
544
|
+
const trimmed = value.trim();
|
|
545
|
+
if (trimmed.length > TASK_ID_MAX_V1 || !isTaskIdV1(trimmed)) {
|
|
546
|
+
throw new SubagentDecodeError(`${tool} taskId is not a task id`);
|
|
547
|
+
}
|
|
548
|
+
return trimmed;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function onlyKeys(
|
|
552
|
+
input: unknown,
|
|
553
|
+
required: readonly string[],
|
|
554
|
+
optional: readonly string[],
|
|
555
|
+
tool: string,
|
|
556
|
+
): Record<string, unknown> {
|
|
557
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
558
|
+
throw new SubagentDecodeError(`${tool} input must be an object`);
|
|
559
|
+
}
|
|
560
|
+
const value = input as Record<string, unknown>;
|
|
561
|
+
const allowed = new Set([...required, ...optional]);
|
|
562
|
+
for (const key of Object.keys(value)) {
|
|
563
|
+
if (!allowed.has(key)) {
|
|
564
|
+
throw new SubagentDecodeError(`${tool} input has unknown field "${key}"`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
for (const key of required) {
|
|
568
|
+
if (!Object.hasOwn(value, key)) {
|
|
569
|
+
throw new SubagentDecodeError(`${tool} input is missing "${key}"`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return value;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function decodeTaskCheckInputV1(input: unknown): { taskId: string } {
|
|
576
|
+
const value = onlyKeys(input, ["taskId"], [], TASK_CHECK_TOOL_V1);
|
|
577
|
+
return { taskId: taskIdArgument(value.taskId, TASK_CHECK_TOOL_V1) };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function decodeTaskMessageInputV1(input: unknown): {
|
|
581
|
+
taskId: string;
|
|
582
|
+
message: string;
|
|
583
|
+
} {
|
|
584
|
+
const value = onlyKeys(
|
|
585
|
+
input,
|
|
586
|
+
["taskId", "message"],
|
|
587
|
+
[],
|
|
588
|
+
TASK_MESSAGE_TOOL_V1,
|
|
589
|
+
);
|
|
590
|
+
if (
|
|
591
|
+
typeof value.message !== "string" ||
|
|
592
|
+
value.message.trim().length === 0 ||
|
|
593
|
+
value.message.trim().length > TASK_MESSAGE_MAX_V1
|
|
594
|
+
) {
|
|
595
|
+
throw new SubagentDecodeError(
|
|
596
|
+
`${TASK_MESSAGE_TOOL_V1} message must be a non-empty string of at most ${TASK_MESSAGE_MAX_V1} characters`,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
taskId: taskIdArgument(value.taskId, TASK_MESSAGE_TOOL_V1),
|
|
601
|
+
message: value.message.trim(),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export function decodeTaskStopInputV1(input: unknown): { taskId: string } {
|
|
606
|
+
const value = onlyKeys(input, ["taskId"], [], TASK_STOP_TOOL_V1);
|
|
607
|
+
return { taskId: taskIdArgument(value.taskId, TASK_STOP_TOOL_V1) };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export interface TaskResumeInputV1 {
|
|
611
|
+
resume: string;
|
|
612
|
+
prompt: string;
|
|
613
|
+
description?: string;
|
|
614
|
+
background: boolean;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* `resume` and `model` are mutually exclusive (l.472–474): the resumed run
|
|
619
|
+
* continues a Session that was already pinned to a binding, and naming a second
|
|
620
|
+
* model would silently change what the transcript was produced by. The refusal
|
|
621
|
+
* is here rather than at the host, because it is a statement about the tool's
|
|
622
|
+
* own arguments.
|
|
623
|
+
*/
|
|
624
|
+
export function decodeTaskResumeInputV1(input: unknown): TaskResumeInputV1 {
|
|
625
|
+
const value = onlyKeys(
|
|
626
|
+
input,
|
|
627
|
+
["resume", "prompt"],
|
|
628
|
+
["description", "background", "model"],
|
|
629
|
+
TASK_RESUME_TOOL_V1,
|
|
630
|
+
);
|
|
631
|
+
if (value.model !== undefined) {
|
|
632
|
+
throw new SubagentDecodeError(
|
|
633
|
+
`${TASK_RESUME_TOOL_V1} does not take a model: a resumed subagent continues on the model it was dispatched with`,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
const prompt =
|
|
637
|
+
typeof value.prompt === "string" ? value.prompt.trim() : undefined;
|
|
638
|
+
if (!prompt || prompt.length === 0) {
|
|
639
|
+
throw new SubagentDecodeError(
|
|
640
|
+
`${TASK_RESUME_TOOL_V1} prompt must be a non-empty string`,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
if (utf8ByteLengthV1(prompt) > TASK_PROMPT_MAX_BYTES_V1) {
|
|
644
|
+
throw new SubagentDecodeError(
|
|
645
|
+
`${TASK_RESUME_TOOL_V1} prompt must be at most ${TASK_PROMPT_MAX_BYTES_V1} bytes`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
if (
|
|
649
|
+
value.description !== undefined &&
|
|
650
|
+
(typeof value.description !== "string" ||
|
|
651
|
+
value.description.trim().length === 0 ||
|
|
652
|
+
value.description.trim().length > TASK_DESCRIPTION_MAX_V1)
|
|
653
|
+
) {
|
|
654
|
+
throw new SubagentDecodeError(
|
|
655
|
+
`${TASK_RESUME_TOOL_V1} description must be a bounded non-empty string`,
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
if (value.background !== undefined && typeof value.background !== "boolean") {
|
|
659
|
+
throw new SubagentDecodeError(
|
|
660
|
+
`${TASK_RESUME_TOOL_V1} background must be a boolean`,
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
return {
|
|
664
|
+
resume: taskIdArgument(value.resume, TASK_RESUME_TOOL_V1),
|
|
665
|
+
prompt,
|
|
666
|
+
...(value.description === undefined
|
|
667
|
+
? {}
|
|
668
|
+
: { description: (value.description as string).trim() }),
|
|
669
|
+
background: value.background === undefined ? true : value.background,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function toolRefusal(tool: string, reason: string): ToolExecutionResult {
|
|
674
|
+
return { content: `${tool} was refused: ${reason}`, isError: true };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** The line every dispatch answer ends on, so no turn learns to poll. */
|
|
678
|
+
const DO_NOT_POLL = "You are notified on completion; do not poll for it.";
|
|
679
|
+
|
|
680
|
+
function settledSummaryV1(outcome: {
|
|
681
|
+
taskStatus: TaskStatusV1;
|
|
682
|
+
summary?: string;
|
|
683
|
+
failure?: string;
|
|
684
|
+
}): string {
|
|
685
|
+
if (outcome.taskStatus === "completed") {
|
|
686
|
+
return outcome.summary ?? "It finished without leaving a summary.";
|
|
687
|
+
}
|
|
688
|
+
if (outcome.taskStatus === "stopped") {
|
|
689
|
+
return `It was stopped.${outcome.failure ? ` ${outcome.failure}` : ""}`;
|
|
690
|
+
}
|
|
691
|
+
return `It failed: ${outcome.failure ?? "no reason was recorded"}.`;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export function createTaskCheckTool(
|
|
695
|
+
host: Pick<SubagentsRuntimeHostV1, "check">,
|
|
696
|
+
): ToolDefinition {
|
|
697
|
+
return {
|
|
698
|
+
name: TASK_CHECK_TOOL_V1,
|
|
699
|
+
description: [
|
|
700
|
+
"Read the current state of one subagent you dispatched.",
|
|
701
|
+
"It answers with the task's status and its last summary.",
|
|
702
|
+
DO_NOT_POLL,
|
|
703
|
+
"Use this only when your user asks what a subagent is doing.",
|
|
704
|
+
].join(" "),
|
|
705
|
+
inputSchema: {
|
|
706
|
+
type: "object",
|
|
707
|
+
properties: {
|
|
708
|
+
taskId: {
|
|
709
|
+
type: "string",
|
|
710
|
+
description: "The id Task gave you when it dispatched the subagent.",
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
required: ["taskId"],
|
|
714
|
+
additionalProperties: false,
|
|
715
|
+
},
|
|
716
|
+
idempotent: true,
|
|
717
|
+
admission: { turnTypes: ["chat", "automation"] },
|
|
718
|
+
validate: (input: unknown) => {
|
|
719
|
+
try {
|
|
720
|
+
decodeTaskCheckInputV1(input);
|
|
721
|
+
return true;
|
|
722
|
+
} catch {
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
},
|
|
726
|
+
execute: async (input: unknown): Promise<ToolExecutionResult> => {
|
|
727
|
+
let taskId: string;
|
|
728
|
+
try {
|
|
729
|
+
taskId = decodeTaskCheckInputV1(input).taskId;
|
|
730
|
+
} catch (error) {
|
|
731
|
+
return toolRefusal(
|
|
732
|
+
TASK_CHECK_TOOL_V1,
|
|
733
|
+
error instanceof Error ? error.message : String(error),
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
const answer = await host.check(taskId);
|
|
737
|
+
if (answer.status === "refused") {
|
|
738
|
+
return toolRefusal(TASK_CHECK_TOOL_V1, answer.reason);
|
|
739
|
+
}
|
|
740
|
+
const lines = [
|
|
741
|
+
`${answer.taskType} subagent ${answer.taskId} ("${answer.description}") on ${answer.model} is ${answer.taskStatus}.`,
|
|
742
|
+
];
|
|
743
|
+
if (answer.summary) lines.push(`Last summary: ${answer.summary}`);
|
|
744
|
+
if (answer.failure) lines.push(`Failure: ${answer.failure}`);
|
|
745
|
+
if (answer.queuedMessages > 0) {
|
|
746
|
+
lines.push(`${answer.queuedMessages} of your messages are waiting.`);
|
|
747
|
+
}
|
|
748
|
+
if (answer.taskStatus === "queued" || answer.taskStatus === "running") {
|
|
749
|
+
lines.push(DO_NOT_POLL);
|
|
750
|
+
}
|
|
751
|
+
return { content: lines.join(" "), isError: false };
|
|
752
|
+
},
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export function createTaskMessageTool(
|
|
757
|
+
host: Pick<SubagentsRuntimeHostV1, "message">,
|
|
758
|
+
record?: SubagentEventRecorderV1,
|
|
759
|
+
): ToolDefinition {
|
|
760
|
+
return {
|
|
761
|
+
name: TASK_MESSAGE_TOOL_V1,
|
|
762
|
+
description: [
|
|
763
|
+
"Send one message to a subagent that is still running.",
|
|
764
|
+
"It is queued and read by the subagent; it is refused if the subagent is not running.",
|
|
765
|
+
].join(" "),
|
|
766
|
+
inputSchema: {
|
|
767
|
+
type: "object",
|
|
768
|
+
properties: {
|
|
769
|
+
taskId: { type: "string", description: "The subagent's task id." },
|
|
770
|
+
message: {
|
|
771
|
+
type: "string",
|
|
772
|
+
description:
|
|
773
|
+
"What to tell the subagent. It cannot see this conversation, so say everything it needs.",
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
required: ["taskId", "message"],
|
|
777
|
+
additionalProperties: false,
|
|
778
|
+
},
|
|
779
|
+
idempotent: false,
|
|
780
|
+
admission: { turnTypes: ["chat", "automation"] },
|
|
781
|
+
validate: (input: unknown) => {
|
|
782
|
+
try {
|
|
783
|
+
decodeTaskMessageInputV1(input);
|
|
784
|
+
return true;
|
|
785
|
+
} catch {
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
},
|
|
789
|
+
execute: async (
|
|
790
|
+
input: unknown,
|
|
791
|
+
context: ToolExecutionContext,
|
|
792
|
+
): Promise<ToolExecutionResult> => {
|
|
793
|
+
let decoded: { taskId: string; message: string };
|
|
794
|
+
try {
|
|
795
|
+
decoded = decodeTaskMessageInputV1(input);
|
|
796
|
+
} catch (error) {
|
|
797
|
+
return toolRefusal(
|
|
798
|
+
TASK_MESSAGE_TOOL_V1,
|
|
799
|
+
error instanceof Error ? error.message : String(error),
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
const answer = await host.message(decoded.taskId, decoded.message);
|
|
803
|
+
if (answer.status === "refused") {
|
|
804
|
+
return toolRefusal(TASK_MESSAGE_TOOL_V1, answer.reason);
|
|
805
|
+
}
|
|
806
|
+
record?.messaged({
|
|
807
|
+
occurrenceId: context.effectId,
|
|
808
|
+
taskId: answer.taskId,
|
|
809
|
+
message: decoded.message,
|
|
810
|
+
});
|
|
811
|
+
return {
|
|
812
|
+
content: `Queued your message for subagent ${answer.taskId}; ${answer.depth} are waiting. ${DO_NOT_POLL}`,
|
|
813
|
+
isError: false,
|
|
814
|
+
};
|
|
815
|
+
},
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export function createTaskStopTool(
|
|
820
|
+
host: Pick<SubagentsRuntimeHostV1, "stop">,
|
|
821
|
+
): ToolDefinition {
|
|
822
|
+
return {
|
|
823
|
+
name: TASK_STOP_TOOL_V1,
|
|
824
|
+
description: [
|
|
825
|
+
"Stop a subagent you dispatched. The cancellation is durable and final:",
|
|
826
|
+
"the subagent ends, its slot is released, and it cannot be restarted —",
|
|
827
|
+
"use task_resume to run a fresh instruction in the same subagent instead.",
|
|
828
|
+
].join(" "),
|
|
829
|
+
inputSchema: {
|
|
830
|
+
type: "object",
|
|
831
|
+
properties: {
|
|
832
|
+
taskId: { type: "string", description: "The subagent's task id." },
|
|
833
|
+
},
|
|
834
|
+
required: ["taskId"],
|
|
835
|
+
additionalProperties: false,
|
|
836
|
+
},
|
|
837
|
+
// Stopping the same task twice is stopping it once: the second call reads
|
|
838
|
+
// the recorded cancellation back.
|
|
839
|
+
idempotent: true,
|
|
840
|
+
admission: { turnTypes: ["chat", "automation"] },
|
|
841
|
+
validate: (input: unknown) => {
|
|
842
|
+
try {
|
|
843
|
+
decodeTaskStopInputV1(input);
|
|
844
|
+
return true;
|
|
845
|
+
} catch {
|
|
846
|
+
return false;
|
|
847
|
+
}
|
|
848
|
+
},
|
|
849
|
+
execute: async (input: unknown): Promise<ToolExecutionResult> => {
|
|
850
|
+
let taskId: string;
|
|
851
|
+
try {
|
|
852
|
+
taskId = decodeTaskStopInputV1(input).taskId;
|
|
853
|
+
} catch (error) {
|
|
854
|
+
return toolRefusal(
|
|
855
|
+
TASK_STOP_TOOL_V1,
|
|
856
|
+
error instanceof Error ? error.message : String(error),
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
const answer = await host.stop(taskId);
|
|
860
|
+
if (answer.status === "refused") {
|
|
861
|
+
return toolRefusal(TASK_STOP_TOOL_V1, answer.reason);
|
|
862
|
+
}
|
|
863
|
+
return {
|
|
864
|
+
content: `Stopped subagent ${answer.taskId}. The cancellation is durable and it will not run again.`,
|
|
865
|
+
isError: false,
|
|
866
|
+
};
|
|
867
|
+
},
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
export function createTaskResumeTool(
|
|
872
|
+
host: Pick<SubagentsRuntimeHostV1, "resume"> & {
|
|
873
|
+
writer: NonNullable<SubagentsRuntimeHostV1["writer"]>;
|
|
874
|
+
},
|
|
875
|
+
record?: SubagentEventRecorderV1,
|
|
876
|
+
): ToolDefinition {
|
|
877
|
+
return {
|
|
878
|
+
name: TASK_RESUME_TOOL_V1,
|
|
879
|
+
description: [
|
|
880
|
+
"Give a finished subagent a new instruction, in the same subagent it ran in before,",
|
|
881
|
+
"so it keeps everything it already learned. It is refused while the subagent is still running,",
|
|
882
|
+
"and it takes no model: a resumed subagent runs on the model it was dispatched with.",
|
|
883
|
+
DO_NOT_POLL,
|
|
884
|
+
].join(" "),
|
|
885
|
+
inputSchema: {
|
|
886
|
+
type: "object",
|
|
887
|
+
properties: {
|
|
888
|
+
resume: {
|
|
889
|
+
type: "string",
|
|
890
|
+
description: "The task id of a subagent that has finished.",
|
|
891
|
+
},
|
|
892
|
+
prompt: {
|
|
893
|
+
type: "string",
|
|
894
|
+
description:
|
|
895
|
+
"The new instruction. The subagent keeps its own prior transcript.",
|
|
896
|
+
},
|
|
897
|
+
description: {
|
|
898
|
+
type: "string",
|
|
899
|
+
description:
|
|
900
|
+
"A short label for this run, shown to your user. Defaults to the earlier one.",
|
|
901
|
+
},
|
|
902
|
+
background: {
|
|
903
|
+
type: "boolean",
|
|
904
|
+
description: "Defaults to true, exactly as it does on Task.",
|
|
905
|
+
},
|
|
906
|
+
},
|
|
907
|
+
required: ["resume", "prompt"],
|
|
908
|
+
additionalProperties: false,
|
|
909
|
+
},
|
|
910
|
+
idempotent: false,
|
|
911
|
+
admission: { turnTypes: ["chat", "automation"] },
|
|
912
|
+
validate: (input: unknown) => {
|
|
913
|
+
try {
|
|
914
|
+
decodeTaskResumeInputV1(input);
|
|
915
|
+
return true;
|
|
916
|
+
} catch {
|
|
917
|
+
return false;
|
|
918
|
+
}
|
|
919
|
+
},
|
|
920
|
+
execute: async (
|
|
921
|
+
input: unknown,
|
|
922
|
+
context: ToolExecutionContext,
|
|
923
|
+
): Promise<ToolExecutionResult> => {
|
|
924
|
+
let decoded: TaskResumeInputV1;
|
|
925
|
+
try {
|
|
926
|
+
decoded = decodeTaskResumeInputV1(input);
|
|
927
|
+
} catch (error) {
|
|
928
|
+
return toolRefusal(
|
|
929
|
+
TASK_RESUME_TOOL_V1,
|
|
930
|
+
error instanceof Error ? error.message : String(error),
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
let outcome: SubagentDispatchOutcomeV1;
|
|
934
|
+
try {
|
|
935
|
+
outcome = await host.resume({
|
|
936
|
+
resume: decoded.resume,
|
|
937
|
+
prompt: decoded.prompt,
|
|
938
|
+
...(decoded.description === undefined
|
|
939
|
+
? {}
|
|
940
|
+
: { description: decoded.description }),
|
|
941
|
+
background: decoded.background,
|
|
942
|
+
effectId: context.effectId,
|
|
943
|
+
});
|
|
944
|
+
} catch (error) {
|
|
945
|
+
return toolRefusal(
|
|
946
|
+
TASK_RESUME_TOOL_V1,
|
|
947
|
+
error instanceof Error ? error.message : String(error),
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
if (outcome.status === "refused") {
|
|
951
|
+
return toolRefusal(TASK_RESUME_TOOL_V1, outcome.reason);
|
|
952
|
+
}
|
|
953
|
+
record?.dispatched({
|
|
954
|
+
occurrenceId: context.effectId,
|
|
955
|
+
taskId: outcome.taskId,
|
|
956
|
+
taskType: "executor",
|
|
957
|
+
description: decoded.description ?? `Resumed ${decoded.resume}`,
|
|
958
|
+
model: outcome.model,
|
|
959
|
+
background: decoded.background,
|
|
960
|
+
});
|
|
961
|
+
if (outcome.status === "settled") {
|
|
962
|
+
return {
|
|
963
|
+
content: `Subagent ${outcome.taskId} resumed and ${outcome.taskStatus}. ${settledSummaryV1(outcome)}`,
|
|
964
|
+
isError: outcome.taskStatus !== "completed",
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
return {
|
|
968
|
+
content: `Resumed subagent ${decoded.resume} as ${outcome.taskId} on ${outcome.model}. ${DO_NOT_POLL}`,
|
|
969
|
+
isError: false,
|
|
970
|
+
};
|
|
971
|
+
},
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* The `<available_subagent_models>` section. It renders on every turn type,
|
|
977
|
+
* because a turn that cannot dispatch also has no slugs to show: the host
|
|
978
|
+
* narrows the catalog to one entry on an automation or subagent turn, and the
|
|
979
|
+
* section renders nothing at all when the catalog is empty.
|
|
980
|
+
*/
|
|
981
|
+
export function createSubagentModelsPromptSectionV1(
|
|
982
|
+
host: Pick<SubagentsRuntimeHostV1, "models">,
|
|
983
|
+
): PromptSection {
|
|
984
|
+
return {
|
|
985
|
+
id: SUBAGENT_MODELS_SECTION_V1,
|
|
986
|
+
order: 95,
|
|
987
|
+
render: () => renderAvailableSubagentModelsPromptV1(host.models()),
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* The runtime Contribution. The prompt section is registered whenever the
|
|
993
|
+
* Package is mounted; the tool only when the host supplies Bot provenance,
|
|
994
|
+
* because a dispatch with no Turn to attribute it to is a dispatch with no
|
|
995
|
+
* writer.
|
|
996
|
+
*/
|
|
997
|
+
export function createSubagentsRuntimePlugin(
|
|
998
|
+
host: SubagentsRuntimeHostV1,
|
|
999
|
+
): Plugin.Function {
|
|
1000
|
+
const plugin: Plugin.Function = (ctx) => {
|
|
1001
|
+
// The Turn ordinal and step a task event is recorded under. The Agent loop
|
|
1002
|
+
// announces them; a tool context does not carry them, so they are caught
|
|
1003
|
+
// where the loop already says so — the `plugin-computer` pattern.
|
|
1004
|
+
let currentTurn = 1;
|
|
1005
|
+
let currentStep = 1;
|
|
1006
|
+
const disposers: Array<() => void> = [
|
|
1007
|
+
ctx.systemPrompt.register(createSubagentModelsPromptSectionV1(host)),
|
|
1008
|
+
ctx.on("agent/pre-step", async (_agent, _inputs, turn, step, next) => {
|
|
1009
|
+
currentTurn = turn;
|
|
1010
|
+
currentStep = step;
|
|
1011
|
+
const decision = await next();
|
|
1012
|
+
// Delivery, not merely queueing. The claim is durable and marks what
|
|
1013
|
+
// it took, so a step that is retried after the claim reads the marks
|
|
1014
|
+
// back and does not hand the model the same instruction twice; the
|
|
1015
|
+
// loop records each folded input as a `user/message` on the child's
|
|
1016
|
+
// own Session, which is where "exactly once" is finally visible.
|
|
1017
|
+
if (decision.kind !== "enter" || !host.drainMessages) return decision;
|
|
1018
|
+
let pending: readonly PendingTaskMessageV1[];
|
|
1019
|
+
try {
|
|
1020
|
+
pending = await host.drainMessages();
|
|
1021
|
+
} catch {
|
|
1022
|
+
// A parent that cannot be reached has not lost the message: it is
|
|
1023
|
+
// still queued, undelivered, and the next step claims it.
|
|
1024
|
+
return decision;
|
|
1025
|
+
}
|
|
1026
|
+
return foldPendingTaskMessagesV1(
|
|
1027
|
+
decision,
|
|
1028
|
+
pending,
|
|
1029
|
+
host.taskId ?? "task",
|
|
1030
|
+
);
|
|
1031
|
+
}),
|
|
1032
|
+
];
|
|
1033
|
+
const writer = host.writer;
|
|
1034
|
+
if (writer) {
|
|
1035
|
+
const dispatchCeiling = subagentsAdmissionCeilingV1(
|
|
1036
|
+
TASK_DISPATCH_CAPABILITY_V1,
|
|
1037
|
+
);
|
|
1038
|
+
const lifecycleCeiling = subagentsAdmissionCeilingV1(
|
|
1039
|
+
TASK_LIFECYCLE_CAPABILITY_V1,
|
|
1040
|
+
);
|
|
1041
|
+
const append = (event: Record<string, unknown> & { type: string }) => {
|
|
1042
|
+
const session = ctx.sessions.get(writer.sessionId);
|
|
1043
|
+
if (!session || session.disposed) return;
|
|
1044
|
+
session.append({
|
|
1045
|
+
turn: Math.max(1, currentTurn),
|
|
1046
|
+
step: Math.max(1, currentStep),
|
|
1047
|
+
...event,
|
|
1048
|
+
} as never);
|
|
1049
|
+
};
|
|
1050
|
+
const record: SubagentEventRecorderV1 = {
|
|
1051
|
+
dispatched: (event) => append({ type: "task/dispatched", ...event }),
|
|
1052
|
+
messaged: (event) => append({ type: "task/message", ...event }),
|
|
1053
|
+
};
|
|
1054
|
+
disposers.push(
|
|
1055
|
+
ctx.tools.register(
|
|
1056
|
+
createTaskTool({ ...host, writer }, record),
|
|
1057
|
+
dispatchCeiling ? { admissionCeiling: dispatchCeiling } : undefined,
|
|
1058
|
+
),
|
|
1059
|
+
);
|
|
1060
|
+
const lifecycleOptions = lifecycleCeiling
|
|
1061
|
+
? { admissionCeiling: lifecycleCeiling }
|
|
1062
|
+
: undefined;
|
|
1063
|
+
for (const tool of [
|
|
1064
|
+
createTaskCheckTool(host),
|
|
1065
|
+
createTaskMessageTool(host, record),
|
|
1066
|
+
createTaskStopTool(host),
|
|
1067
|
+
createTaskResumeTool({ ...host, writer }, record),
|
|
1068
|
+
]) {
|
|
1069
|
+
disposers.push(ctx.tools.register(tool, lifecycleOptions));
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return () => {
|
|
1073
|
+
for (const dispose of disposers.toReversed()) dispose();
|
|
1074
|
+
};
|
|
1075
|
+
};
|
|
1076
|
+
plugin.inject = ["tools", "systemPrompt", "sessions"];
|
|
1077
|
+
return plugin;
|
|
1078
|
+
}
|