@narumitw/pi-subagents 0.52.0 → 0.53.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/README.md +79 -6
- package/package.json +1 -1
- package/src/agents.ts +5 -0
- package/src/automation-contract.ts +709 -0
- package/src/automation-planner.ts +65 -0
- package/src/automation.ts +580 -0
- package/src/execution-plan.ts +1 -1
- package/src/subagents.ts +2 -0
- package/src/workflow-plan-compiler.ts +618 -0
- package/src/workflow-plan-patch.ts +636 -0
- package/src/workflow-planning-benchmark.ts +95 -0
- package/src/workflow-ui.ts +2 -2
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
4
|
+
import { type Static, Type } from "typebox";
|
|
5
|
+
import { MAX_SUBAGENT_TIMEOUT_MS } from "./limits.js";
|
|
6
|
+
|
|
7
|
+
export const AUTOMATION_REQUEST_VERSION = "pi-subagents:automation-request:v1" as const;
|
|
8
|
+
export const WORKFLOW_PLAN_VERSION = "pi-subagents:workflow-plan:v1" as const;
|
|
9
|
+
export const WORKFLOW_PLAN_PATCH_VERSION = "pi-subagents:workflow-plan-patch:v1" as const;
|
|
10
|
+
export const MAX_AUTOMATION_TASKS = 8;
|
|
11
|
+
export const MAX_AUTOMATION_REVISIONS = 3;
|
|
12
|
+
export const MAX_AUTOMATION_TEXT_BYTES = 16 * 1024;
|
|
13
|
+
export const MAX_AUTOMATION_ITEMS = 20;
|
|
14
|
+
const MAX_ITEM_BYTES = 4 * 1024;
|
|
15
|
+
const MAX_ITEMS = MAX_AUTOMATION_ITEMS;
|
|
16
|
+
const MAX_PATH_BYTES = 4 * 1024;
|
|
17
|
+
const MAX_BUDGET_TURNS = 1_000;
|
|
18
|
+
const MAX_BUDGET_TOOL_CALLS = 2_000;
|
|
19
|
+
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
|
|
20
|
+
const PLAN_ID_PATTERN = /^[a-f0-9]{64}$/u;
|
|
21
|
+
const PRIVATE_MARKER_PATTERN = /<\/?private(?:\s|>)|\[subagent-private\]/iu;
|
|
22
|
+
|
|
23
|
+
const SideEffectSchema = StringEnum(["read-only", "idempotent", "mutating"] as const);
|
|
24
|
+
const AuthorityRequirementSchema = StringEnum(["unspecified", "denied", "required"] as const);
|
|
25
|
+
const ItemSchema = Type.String({ minLength: 1, maxLength: MAX_ITEM_BYTES });
|
|
26
|
+
const ItemListSchema = Type.Array(ItemSchema, { maxItems: MAX_ITEMS });
|
|
27
|
+
const PathListSchema = Type.Array(Type.String({ minLength: 1, maxLength: MAX_PATH_BYTES }), {
|
|
28
|
+
maxItems: MAX_ITEMS,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const AggregateBudgetSchema = Type.Object(
|
|
32
|
+
{
|
|
33
|
+
timeoutMs: Type.Integer({ minimum: 1, maximum: MAX_SUBAGENT_TIMEOUT_MS }),
|
|
34
|
+
maxTurns: Type.Integer({ minimum: 1, maximum: MAX_BUDGET_TURNS }),
|
|
35
|
+
maxToolCalls: Type.Integer({ minimum: 1, maximum: MAX_BUDGET_TOOL_CALLS }),
|
|
36
|
+
maxTasks: Type.Integer({ minimum: 1, maximum: MAX_AUTOMATION_TASKS }),
|
|
37
|
+
maxRevisions: Type.Integer({ minimum: 0, maximum: MAX_AUTOMATION_REVISIONS }),
|
|
38
|
+
},
|
|
39
|
+
{ additionalProperties: false },
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const TaskBudgetSchema = Type.Object(
|
|
43
|
+
{
|
|
44
|
+
timeoutMs: Type.Integer({ minimum: 1, maximum: MAX_SUBAGENT_TIMEOUT_MS }),
|
|
45
|
+
maxTurns: Type.Integer({ minimum: 1, maximum: MAX_BUDGET_TURNS }),
|
|
46
|
+
maxToolCalls: Type.Integer({ minimum: 1, maximum: MAX_BUDGET_TOOL_CALLS }),
|
|
47
|
+
},
|
|
48
|
+
{ additionalProperties: false },
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
export const AutomationRequestSchema = Type.Object(
|
|
52
|
+
{
|
|
53
|
+
version: Type.Literal(AUTOMATION_REQUEST_VERSION),
|
|
54
|
+
objective: Type.String({ minLength: 1, maxLength: MAX_AUTOMATION_TEXT_BYTES }),
|
|
55
|
+
nonGoals: ItemListSchema,
|
|
56
|
+
requiredInputs: ItemListSchema,
|
|
57
|
+
acceptanceCriteria: Type.Array(ItemSchema, { minItems: 1, maxItems: MAX_ITEMS }),
|
|
58
|
+
requiredEvidence: ItemListSchema,
|
|
59
|
+
authorityCeiling: Type.Object(
|
|
60
|
+
{
|
|
61
|
+
capabilities: ItemListSchema,
|
|
62
|
+
tools: ItemListSchema,
|
|
63
|
+
readPaths: PathListSchema,
|
|
64
|
+
writePaths: PathListSchema,
|
|
65
|
+
network: AuthorityRequirementSchema,
|
|
66
|
+
secrets: AuthorityRequirementSchema,
|
|
67
|
+
sideEffectPolicy: SideEffectSchema,
|
|
68
|
+
},
|
|
69
|
+
{ additionalProperties: false },
|
|
70
|
+
),
|
|
71
|
+
aggregateBudget: AggregateBudgetSchema,
|
|
72
|
+
constraints: Type.Object(
|
|
73
|
+
{
|
|
74
|
+
contextPressure: StringEnum(["low", "medium", "high"] as const),
|
|
75
|
+
maxMutatingWidth: Type.Integer({ minimum: 1, maximum: 2 }),
|
|
76
|
+
requireVerification: Type.Boolean(),
|
|
77
|
+
workspaceMode: StringEnum(["shared", "worktree"] as const),
|
|
78
|
+
allowedAgents: Type.Optional(ItemListSchema),
|
|
79
|
+
},
|
|
80
|
+
{ additionalProperties: false },
|
|
81
|
+
),
|
|
82
|
+
},
|
|
83
|
+
{ additionalProperties: false },
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const ArtifactSchema = Type.Object(
|
|
87
|
+
{
|
|
88
|
+
id: Type.String({ minLength: 1, maxLength: 128 }),
|
|
89
|
+
kind: Type.String({ minLength: 1, maxLength: 128 }),
|
|
90
|
+
version: Type.String({ minLength: 1, maxLength: 128 }),
|
|
91
|
+
},
|
|
92
|
+
{ additionalProperties: false },
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
export const WorkflowPlanTaskSchema = Type.Object(
|
|
96
|
+
{
|
|
97
|
+
id: Type.String({ minLength: 1, maxLength: 128 }),
|
|
98
|
+
objective: Type.String({ minLength: 1, maxLength: MAX_AUTOMATION_TEXT_BYTES }),
|
|
99
|
+
dependsOn: Type.Array(Type.String({ minLength: 1, maxLength: 128 }), {
|
|
100
|
+
maxItems: MAX_AUTOMATION_TASKS,
|
|
101
|
+
}),
|
|
102
|
+
inputArtifacts: Type.Array(Type.String({ minLength: 1, maxLength: 128 }), {
|
|
103
|
+
maxItems: MAX_ITEMS,
|
|
104
|
+
}),
|
|
105
|
+
producesArtifacts: Type.Array(ArtifactSchema, { maxItems: MAX_ITEMS }),
|
|
106
|
+
sideEffectPolicy: SideEffectSchema,
|
|
107
|
+
readPaths: PathListSchema,
|
|
108
|
+
writePaths: PathListSchema,
|
|
109
|
+
ownershipKeys: ItemListSchema,
|
|
110
|
+
requiredCapabilities: ItemListSchema,
|
|
111
|
+
requiredTools: ItemListSchema,
|
|
112
|
+
requiredVerificationRole: Type.Optional(ItemSchema),
|
|
113
|
+
acceptanceCriteria: Type.Array(ItemSchema, { minItems: 1, maxItems: MAX_ITEMS }),
|
|
114
|
+
requiredEvidence: ItemListSchema,
|
|
115
|
+
integrationOwner: Type.Boolean(),
|
|
116
|
+
verifierFor: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
117
|
+
preferredCostHint: Type.Optional(StringEnum(["low", "medium", "high"] as const)),
|
|
118
|
+
preferredLatencyHint: Type.Optional(StringEnum(["low", "medium", "high"] as const)),
|
|
119
|
+
budget: TaskBudgetSchema,
|
|
120
|
+
guarantees: Type.Optional(
|
|
121
|
+
Type.Object(
|
|
122
|
+
{ network: AuthorityRequirementSchema, secrets: Type.Optional(AuthorityRequirementSchema) },
|
|
123
|
+
{ additionalProperties: false },
|
|
124
|
+
),
|
|
125
|
+
),
|
|
126
|
+
},
|
|
127
|
+
{ additionalProperties: false },
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
export const WorkflowPlanSchema = Type.Object(
|
|
131
|
+
{
|
|
132
|
+
version: Type.Literal(WORKFLOW_PLAN_VERSION),
|
|
133
|
+
requestVersion: Type.Literal(AUTOMATION_REQUEST_VERSION),
|
|
134
|
+
summary: Type.String({ minLength: 1, maxLength: MAX_AUTOMATION_TEXT_BYTES }),
|
|
135
|
+
missingInputs: ItemListSchema,
|
|
136
|
+
risks: ItemListSchema,
|
|
137
|
+
tasks: Type.Array(WorkflowPlanTaskSchema, {
|
|
138
|
+
minItems: 1,
|
|
139
|
+
maxItems: MAX_AUTOMATION_TASKS,
|
|
140
|
+
}),
|
|
141
|
+
},
|
|
142
|
+
{ additionalProperties: false },
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const PatchTaskSchema = WorkflowPlanTaskSchema;
|
|
146
|
+
const PatchOperationSchema = Type.Union([
|
|
147
|
+
Type.Object(
|
|
148
|
+
{ type: Type.Literal("add-task"), task: PatchTaskSchema },
|
|
149
|
+
{ additionalProperties: false },
|
|
150
|
+
),
|
|
151
|
+
Type.Object(
|
|
152
|
+
{
|
|
153
|
+
type: Type.Literal("replace-task"),
|
|
154
|
+
taskId: Type.String({ minLength: 1, maxLength: 128 }),
|
|
155
|
+
task: PatchTaskSchema,
|
|
156
|
+
},
|
|
157
|
+
{ additionalProperties: false },
|
|
158
|
+
),
|
|
159
|
+
Type.Object(
|
|
160
|
+
{
|
|
161
|
+
type: Type.Literal("add-dependency"),
|
|
162
|
+
taskId: Type.String({ minLength: 1, maxLength: 128 }),
|
|
163
|
+
dependsOn: Type.String({ minLength: 1, maxLength: 128 }),
|
|
164
|
+
},
|
|
165
|
+
{ additionalProperties: false },
|
|
166
|
+
),
|
|
167
|
+
Type.Object(
|
|
168
|
+
{
|
|
169
|
+
type: Type.Literal("cancel-task"),
|
|
170
|
+
taskId: Type.String({ minLength: 1, maxLength: 128 }),
|
|
171
|
+
},
|
|
172
|
+
{ additionalProperties: false },
|
|
173
|
+
),
|
|
174
|
+
Type.Object(
|
|
175
|
+
{
|
|
176
|
+
type: Type.Literal("request-verification"),
|
|
177
|
+
taskId: Type.String({ minLength: 1, maxLength: 128 }),
|
|
178
|
+
verifier: PatchTaskSchema,
|
|
179
|
+
},
|
|
180
|
+
{ additionalProperties: false },
|
|
181
|
+
),
|
|
182
|
+
Type.Object(
|
|
183
|
+
{
|
|
184
|
+
type: Type.Literal("invalidate-downstream"),
|
|
185
|
+
taskId: Type.String({ minLength: 1, maxLength: 128 }),
|
|
186
|
+
reason: ItemSchema,
|
|
187
|
+
},
|
|
188
|
+
{ additionalProperties: false },
|
|
189
|
+
),
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
export const WorkflowPlanPatchSchema = Type.Object(
|
|
193
|
+
{
|
|
194
|
+
version: Type.Literal(WORKFLOW_PLAN_PATCH_VERSION),
|
|
195
|
+
planId: Type.String({ pattern: "^[a-f0-9]{64}$" }),
|
|
196
|
+
workflowGeneration: Type.Integer({ minimum: 0 }),
|
|
197
|
+
reason: ItemSchema,
|
|
198
|
+
operations: Type.Array(PatchOperationSchema, { minItems: 1, maxItems: MAX_AUTOMATION_TASKS }),
|
|
199
|
+
},
|
|
200
|
+
{ additionalProperties: false },
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
export type AutomationRequest = Static<typeof AutomationRequestSchema>;
|
|
204
|
+
export type WorkflowPlanTask = Static<typeof WorkflowPlanTaskSchema>;
|
|
205
|
+
export type WorkflowPlan = Static<typeof WorkflowPlanSchema>;
|
|
206
|
+
export type WorkflowPlanPatch = Static<typeof WorkflowPlanPatchSchema>;
|
|
207
|
+
export type WorkflowPlanPatchOperation = WorkflowPlanPatch["operations"][number];
|
|
208
|
+
|
|
209
|
+
export function parseAutomationRequest(value: unknown): AutomationRequest {
|
|
210
|
+
const object = strictObject(value, "automation request");
|
|
211
|
+
assertOnlyKeys(object, [
|
|
212
|
+
"version",
|
|
213
|
+
"objective",
|
|
214
|
+
"nonGoals",
|
|
215
|
+
"requiredInputs",
|
|
216
|
+
"acceptanceCriteria",
|
|
217
|
+
"requiredEvidence",
|
|
218
|
+
"authorityCeiling",
|
|
219
|
+
"aggregateBudget",
|
|
220
|
+
"constraints",
|
|
221
|
+
]);
|
|
222
|
+
if (object.version !== AUTOMATION_REQUEST_VERSION) {
|
|
223
|
+
throw new Error("Unsupported automation request version");
|
|
224
|
+
}
|
|
225
|
+
const parsed = parseBySchemaShape(object, "automation request") as AutomationRequest;
|
|
226
|
+
validateRequest(parsed);
|
|
227
|
+
return structuredClone(parsed);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function parseWorkflowPlan(value: string | unknown): WorkflowPlan {
|
|
231
|
+
let decoded: unknown = value;
|
|
232
|
+
if (typeof value === "string") {
|
|
233
|
+
try {
|
|
234
|
+
decoded = JSON.parse(value);
|
|
235
|
+
} catch {
|
|
236
|
+
throw new Error("Workflow plan contains invalid JSON");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const object = strictObject(decoded, "workflow plan");
|
|
240
|
+
for (const key of ["planId", "workflowGeneration", "executionPlanId", "selectedAgent"]) {
|
|
241
|
+
if (key in object) throw new Error(`Workflow plan contains executor-owned field ${key}`);
|
|
242
|
+
}
|
|
243
|
+
assertOnlyKeys(object, [
|
|
244
|
+
"version",
|
|
245
|
+
"requestVersion",
|
|
246
|
+
"summary",
|
|
247
|
+
"missingInputs",
|
|
248
|
+
"risks",
|
|
249
|
+
"tasks",
|
|
250
|
+
]);
|
|
251
|
+
if (
|
|
252
|
+
object.version !== WORKFLOW_PLAN_VERSION ||
|
|
253
|
+
object.requestVersion !== AUTOMATION_REQUEST_VERSION
|
|
254
|
+
) {
|
|
255
|
+
throw new Error("Unsupported workflow plan version");
|
|
256
|
+
}
|
|
257
|
+
if (!Array.isArray(object.tasks) || object.tasks.length < 1) {
|
|
258
|
+
throw new Error("Workflow plan requires at least one task");
|
|
259
|
+
}
|
|
260
|
+
if (object.tasks.length > MAX_AUTOMATION_TASKS)
|
|
261
|
+
throw new Error("Workflow plan has too many tasks");
|
|
262
|
+
const parsed = parseBySchemaShape(object, "workflow plan") as WorkflowPlan;
|
|
263
|
+
validatePlan(parsed);
|
|
264
|
+
return structuredClone(parsed);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function parseWorkflowPlanPatch(value: string | unknown): WorkflowPlanPatch {
|
|
268
|
+
let decoded: unknown = value;
|
|
269
|
+
if (typeof value === "string") {
|
|
270
|
+
try {
|
|
271
|
+
decoded = JSON.parse(value);
|
|
272
|
+
} catch {
|
|
273
|
+
throw new Error("Workflow plan patch contains invalid JSON");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const object = strictObject(decoded, "workflow plan patch");
|
|
277
|
+
assertOnlyKeys(object, ["version", "planId", "workflowGeneration", "reason", "operations"]);
|
|
278
|
+
if (object.version !== WORKFLOW_PLAN_PATCH_VERSION) {
|
|
279
|
+
throw new Error("Unsupported workflow plan patch version");
|
|
280
|
+
}
|
|
281
|
+
if (!Number.isSafeInteger(object.workflowGeneration) || Number(object.workflowGeneration) < 0) {
|
|
282
|
+
throw new Error("Workflow plan patch has invalid generation");
|
|
283
|
+
}
|
|
284
|
+
if (typeof object.planId !== "string" || !PLAN_ID_PATTERN.test(object.planId)) {
|
|
285
|
+
throw new Error("Workflow plan patch has invalid plan identity");
|
|
286
|
+
}
|
|
287
|
+
const parsed = parseBySchemaShape(object, "workflow plan patch") as WorkflowPlanPatch;
|
|
288
|
+
for (const operation of parsed.operations) {
|
|
289
|
+
if (operation.type === "add-task" || operation.type === "replace-task") {
|
|
290
|
+
validateTaskSafety(operation.task);
|
|
291
|
+
} else if (operation.type === "request-verification") {
|
|
292
|
+
validateTaskSafety(operation.verifier);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
validateSafeValue(parsed, "workflow plan patch");
|
|
296
|
+
return structuredClone(parsed);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function workflowPlanIdentity(
|
|
300
|
+
plan: WorkflowPlan,
|
|
301
|
+
generation: number,
|
|
302
|
+
revision: number,
|
|
303
|
+
): string {
|
|
304
|
+
return createHash("sha256").update(JSON.stringify({ plan, generation, revision })).digest("hex");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function validateRequest(request: AutomationRequest): void {
|
|
308
|
+
for (const scope of [
|
|
309
|
+
...request.authorityCeiling.readPaths,
|
|
310
|
+
...request.authorityCeiling.writePaths,
|
|
311
|
+
]) {
|
|
312
|
+
validateRelativePath(scope);
|
|
313
|
+
}
|
|
314
|
+
validateSafeValue(request, "automation request");
|
|
315
|
+
if (
|
|
316
|
+
request.authorityCeiling.network !== "unspecified" ||
|
|
317
|
+
request.authorityCeiling.secrets !== "unspecified"
|
|
318
|
+
) {
|
|
319
|
+
throw new Error("Automation request asks for an unsupported network or secrets guarantee");
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function validatePlan(plan: WorkflowPlan): void {
|
|
324
|
+
const byId = new Map<string, WorkflowPlanTask>();
|
|
325
|
+
const artifactProducer = new Map<string, string>();
|
|
326
|
+
for (const task of plan.tasks) {
|
|
327
|
+
validateTaskSafety(task);
|
|
328
|
+
if (byId.has(task.id)) throw new Error(`Duplicate workflow task id ${task.id}`);
|
|
329
|
+
byId.set(task.id, task);
|
|
330
|
+
for (const artifact of task.producesArtifacts) {
|
|
331
|
+
if (artifactProducer.has(artifact.id))
|
|
332
|
+
throw new Error(`Duplicate artifact id ${artifact.id}`);
|
|
333
|
+
artifactProducer.set(artifact.id, task.id);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (const task of plan.tasks) {
|
|
337
|
+
for (const dependency of task.dependsOn) {
|
|
338
|
+
if (!ID_PATTERN.test(dependency))
|
|
339
|
+
throw new Error("Workflow task has an invalid dependency id");
|
|
340
|
+
if (!byId.has(dependency)) {
|
|
341
|
+
throw new Error(`Workflow task ${task.id} has a missing dependency`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (task.verifierFor && !byId.has(task.verifierFor)) {
|
|
345
|
+
throw new Error(`Workflow verifier ${task.id} targets missing task ${task.verifierFor}`);
|
|
346
|
+
}
|
|
347
|
+
for (const artifact of task.inputArtifacts) {
|
|
348
|
+
const producer = artifactProducer.get(artifact);
|
|
349
|
+
if (!producer || !task.dependsOn.includes(producer)) {
|
|
350
|
+
throw new Error(`Workflow task ${task.id} has missing artifact dependency ${artifact}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
assertAcyclic(plan.tasks);
|
|
355
|
+
assertOwnershipCompatible(plan.tasks);
|
|
356
|
+
validateSafeValue(plan, "workflow plan");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function validateTaskSafety(task: WorkflowPlanTask): void {
|
|
360
|
+
if (!ID_PATTERN.test(task.id)) throw new Error("Workflow task has an invalid id");
|
|
361
|
+
if (
|
|
362
|
+
task.dependsOn.some((id) => !ID_PATTERN.test(id)) ||
|
|
363
|
+
task.inputArtifacts.some((id) => !ID_PATTERN.test(id)) ||
|
|
364
|
+
(task.verifierFor !== undefined && !ID_PATTERN.test(task.verifierFor)) ||
|
|
365
|
+
task.producesArtifacts.some(
|
|
366
|
+
(artifact) =>
|
|
367
|
+
!ID_PATTERN.test(artifact.id) ||
|
|
368
|
+
!ID_PATTERN.test(artifact.kind) ||
|
|
369
|
+
!ID_PATTERN.test(artifact.version),
|
|
370
|
+
)
|
|
371
|
+
) {
|
|
372
|
+
throw new Error(`Workflow task ${task.id} has an invalid dependency or artifact identity`);
|
|
373
|
+
}
|
|
374
|
+
for (const scope of [...task.readPaths, ...task.writePaths]) validateRelativePath(scope);
|
|
375
|
+
if (task.sideEffectPolicy === "read-only" && task.writePaths.length > 0) {
|
|
376
|
+
throw new Error(`Read-only workflow task ${task.id} declares write paths`);
|
|
377
|
+
}
|
|
378
|
+
if (task.guarantees && Object.values(task.guarantees).some((value) => value !== "unspecified")) {
|
|
379
|
+
throw new Error(`Workflow task ${task.id} requests an unsupported guarantee`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function assertAcyclic(tasks: readonly WorkflowPlanTask[]): void {
|
|
384
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
385
|
+
const visiting = new Set<string>();
|
|
386
|
+
const visited = new Set<string>();
|
|
387
|
+
const visit = (id: string) => {
|
|
388
|
+
if (visiting.has(id)) throw new Error(`Workflow dependency cycle includes ${id}`);
|
|
389
|
+
if (visited.has(id)) return;
|
|
390
|
+
visiting.add(id);
|
|
391
|
+
for (const dependency of byId.get(id)?.dependsOn ?? []) visit(dependency);
|
|
392
|
+
visiting.delete(id);
|
|
393
|
+
visited.add(id);
|
|
394
|
+
};
|
|
395
|
+
for (const task of tasks) visit(task.id);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function assertOwnershipCompatible(tasks: readonly WorkflowPlanTask[]): void {
|
|
399
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
400
|
+
const dependsTransitively = (
|
|
401
|
+
taskId: string,
|
|
402
|
+
possibleAncestor: string,
|
|
403
|
+
seen = new Set<string>(),
|
|
404
|
+
): boolean => {
|
|
405
|
+
if (seen.has(taskId)) return false;
|
|
406
|
+
seen.add(taskId);
|
|
407
|
+
for (const dependency of byId.get(taskId)?.dependsOn ?? []) {
|
|
408
|
+
if (
|
|
409
|
+
dependency === possibleAncestor ||
|
|
410
|
+
dependsTransitively(dependency, possibleAncestor, seen)
|
|
411
|
+
) {
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return false;
|
|
416
|
+
};
|
|
417
|
+
for (let leftIndex = 0; leftIndex < tasks.length; leftIndex++) {
|
|
418
|
+
for (let rightIndex = leftIndex + 1; rightIndex < tasks.length; rightIndex++) {
|
|
419
|
+
const left = tasks[leftIndex];
|
|
420
|
+
const right = tasks[rightIndex];
|
|
421
|
+
if (
|
|
422
|
+
left.ownershipKeys.some((key) => right.ownershipKeys.includes(key)) &&
|
|
423
|
+
!dependsTransitively(left.id, right.id) &&
|
|
424
|
+
!dependsTransitively(right.id, left.id)
|
|
425
|
+
) {
|
|
426
|
+
throw new Error(`Workflow tasks ${left.id} and ${right.id} have conflicting ownership`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function parseBySchemaShape(value: Record<string, unknown>, label: string): unknown {
|
|
433
|
+
if (label === "automation request") {
|
|
434
|
+
validateRequiredObject(value.authorityCeiling, "authorityCeiling", [
|
|
435
|
+
"capabilities",
|
|
436
|
+
"tools",
|
|
437
|
+
"readPaths",
|
|
438
|
+
"writePaths",
|
|
439
|
+
"network",
|
|
440
|
+
"secrets",
|
|
441
|
+
"sideEffectPolicy",
|
|
442
|
+
]);
|
|
443
|
+
validateRequiredObject(value.aggregateBudget, "aggregateBudget", [
|
|
444
|
+
"timeoutMs",
|
|
445
|
+
"maxTurns",
|
|
446
|
+
"maxToolCalls",
|
|
447
|
+
"maxTasks",
|
|
448
|
+
"maxRevisions",
|
|
449
|
+
]);
|
|
450
|
+
validateRequiredObject(value.constraints, "constraints", [
|
|
451
|
+
"contextPressure",
|
|
452
|
+
"maxMutatingWidth",
|
|
453
|
+
"requireVerification",
|
|
454
|
+
"workspaceMode",
|
|
455
|
+
"allowedAgents",
|
|
456
|
+
]);
|
|
457
|
+
validateString(value.objective, "objective", MAX_AUTOMATION_TEXT_BYTES);
|
|
458
|
+
validateStringList(value.nonGoals, "nonGoals");
|
|
459
|
+
validateStringList(value.requiredInputs, "requiredInputs");
|
|
460
|
+
validateStringList(value.acceptanceCriteria, "acceptanceCriteria", true);
|
|
461
|
+
validateStringList(value.requiredEvidence, "requiredEvidence");
|
|
462
|
+
const ceiling = value.authorityCeiling as Record<string, unknown>;
|
|
463
|
+
validateStringList(ceiling.capabilities, "capabilities");
|
|
464
|
+
validateStringList(ceiling.tools, "tools");
|
|
465
|
+
validateStringList(ceiling.readPaths, "readPaths");
|
|
466
|
+
validateStringList(ceiling.writePaths, "writePaths");
|
|
467
|
+
if (!["unspecified", "denied", "required"].includes(String(ceiling.network)))
|
|
468
|
+
throw new Error("Invalid network authority ceiling");
|
|
469
|
+
if (!["unspecified", "denied", "required"].includes(String(ceiling.secrets)))
|
|
470
|
+
throw new Error("Invalid secrets authority ceiling");
|
|
471
|
+
if (!["read-only", "idempotent", "mutating"].includes(String(ceiling.sideEffectPolicy)))
|
|
472
|
+
throw new Error("Invalid side-effect authority ceiling");
|
|
473
|
+
const budget = value.aggregateBudget as Record<string, unknown>;
|
|
474
|
+
validateBudget(budget, true);
|
|
475
|
+
const constraints = value.constraints as Record<string, unknown>;
|
|
476
|
+
if (!["low", "medium", "high"].includes(String(constraints.contextPressure)))
|
|
477
|
+
throw new Error("Invalid context pressure");
|
|
478
|
+
if (
|
|
479
|
+
!Number.isSafeInteger(constraints.maxMutatingWidth) ||
|
|
480
|
+
Number(constraints.maxMutatingWidth) < 1 ||
|
|
481
|
+
Number(constraints.maxMutatingWidth) > 2
|
|
482
|
+
)
|
|
483
|
+
throw new Error("Invalid mutating width");
|
|
484
|
+
if (typeof constraints.requireVerification !== "boolean")
|
|
485
|
+
throw new Error("Invalid verification constraint");
|
|
486
|
+
if (!["shared", "worktree"].includes(String(constraints.workspaceMode)))
|
|
487
|
+
throw new Error("Invalid workspace mode");
|
|
488
|
+
if (constraints.allowedAgents !== undefined)
|
|
489
|
+
validateStringList(constraints.allowedAgents, "allowedAgents");
|
|
490
|
+
return value;
|
|
491
|
+
}
|
|
492
|
+
if (label === "workflow plan") {
|
|
493
|
+
validateString(value.summary, "summary", MAX_AUTOMATION_TEXT_BYTES);
|
|
494
|
+
validateStringList(value.missingInputs, "missingInputs");
|
|
495
|
+
validateStringList(value.risks, "risks");
|
|
496
|
+
for (const raw of value.tasks as unknown[]) validateTask(strictObject(raw, "workflow task"));
|
|
497
|
+
return value;
|
|
498
|
+
}
|
|
499
|
+
validateString(value.reason, "reason", MAX_ITEM_BYTES);
|
|
500
|
+
if (
|
|
501
|
+
!Array.isArray(value.operations) ||
|
|
502
|
+
value.operations.length < 1 ||
|
|
503
|
+
value.operations.length > MAX_AUTOMATION_TASKS
|
|
504
|
+
)
|
|
505
|
+
throw new Error("Invalid workflow plan patch operations");
|
|
506
|
+
for (const raw of value.operations) validatePatchOperation(strictObject(raw, "patch operation"));
|
|
507
|
+
return value;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function validateTask(task: Record<string, unknown>): void {
|
|
511
|
+
assertOnlyKeys(task, [
|
|
512
|
+
"id",
|
|
513
|
+
"objective",
|
|
514
|
+
"dependsOn",
|
|
515
|
+
"inputArtifacts",
|
|
516
|
+
"producesArtifacts",
|
|
517
|
+
"sideEffectPolicy",
|
|
518
|
+
"readPaths",
|
|
519
|
+
"writePaths",
|
|
520
|
+
"ownershipKeys",
|
|
521
|
+
"requiredCapabilities",
|
|
522
|
+
"requiredTools",
|
|
523
|
+
"requiredVerificationRole",
|
|
524
|
+
"acceptanceCriteria",
|
|
525
|
+
"requiredEvidence",
|
|
526
|
+
"integrationOwner",
|
|
527
|
+
"verifierFor",
|
|
528
|
+
"preferredCostHint",
|
|
529
|
+
"preferredLatencyHint",
|
|
530
|
+
"budget",
|
|
531
|
+
"guarantees",
|
|
532
|
+
]);
|
|
533
|
+
validateString(task.id, "task id", 128);
|
|
534
|
+
validateString(task.objective, "task objective", MAX_AUTOMATION_TEXT_BYTES);
|
|
535
|
+
for (const field of [
|
|
536
|
+
"dependsOn",
|
|
537
|
+
"inputArtifacts",
|
|
538
|
+
"readPaths",
|
|
539
|
+
"writePaths",
|
|
540
|
+
"ownershipKeys",
|
|
541
|
+
"requiredCapabilities",
|
|
542
|
+
"requiredTools",
|
|
543
|
+
"acceptanceCriteria",
|
|
544
|
+
"requiredEvidence",
|
|
545
|
+
] as const)
|
|
546
|
+
validateStringList(task[field], field, field === "acceptanceCriteria");
|
|
547
|
+
if (!Array.isArray(task.producesArtifacts) || task.producesArtifacts.length > MAX_ITEMS)
|
|
548
|
+
throw new Error("Invalid task artifacts");
|
|
549
|
+
for (const raw of task.producesArtifacts) {
|
|
550
|
+
const artifact = strictObject(raw, "artifact");
|
|
551
|
+
assertOnlyKeys(artifact, ["id", "kind", "version"]);
|
|
552
|
+
validateString(artifact.id, "artifact id", 128);
|
|
553
|
+
validateString(artifact.kind, "artifact kind", 128);
|
|
554
|
+
validateString(artifact.version, "artifact version", 128);
|
|
555
|
+
}
|
|
556
|
+
if (!["read-only", "idempotent", "mutating"].includes(String(task.sideEffectPolicy)))
|
|
557
|
+
throw new Error("Invalid task side-effect policy");
|
|
558
|
+
if (typeof task.integrationOwner !== "boolean") throw new Error("Invalid integration owner");
|
|
559
|
+
for (const field of ["requiredVerificationRole", "verifierFor"] as const)
|
|
560
|
+
if (task[field] !== undefined) validateString(task[field], field, MAX_ITEM_BYTES);
|
|
561
|
+
for (const field of ["preferredCostHint", "preferredLatencyHint"] as const)
|
|
562
|
+
if (task[field] !== undefined && !["low", "medium", "high"].includes(String(task[field])))
|
|
563
|
+
throw new Error(`Invalid ${field}`);
|
|
564
|
+
validateRequiredObject(task.budget, "task budget", ["timeoutMs", "maxTurns", "maxToolCalls"]);
|
|
565
|
+
validateBudget(task.budget as Record<string, unknown>, false);
|
|
566
|
+
if (task.guarantees !== undefined) {
|
|
567
|
+
validateRequiredObject(task.guarantees, "guarantees", ["network", "secrets"]);
|
|
568
|
+
const guarantees = task.guarantees as Record<string, unknown>;
|
|
569
|
+
for (const value of Object.values(guarantees))
|
|
570
|
+
if (!["unspecified", "denied", "required"].includes(String(value)))
|
|
571
|
+
throw new Error("Invalid task guarantee");
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function validatePatchOperation(operation: Record<string, unknown>): void {
|
|
576
|
+
const type = operation.type;
|
|
577
|
+
if (type === "add-task") {
|
|
578
|
+
assertOnlyKeys(operation, ["type", "task"]);
|
|
579
|
+
validateTask(strictObject(operation.task, "patch task"));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (type === "replace-task") {
|
|
583
|
+
assertOnlyKeys(operation, ["type", "taskId", "task"]);
|
|
584
|
+
validateString(operation.taskId, "taskId", 128);
|
|
585
|
+
validateTask(strictObject(operation.task, "patch task"));
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (type === "add-dependency") {
|
|
589
|
+
assertOnlyKeys(operation, ["type", "taskId", "dependsOn"]);
|
|
590
|
+
validateString(operation.taskId, "taskId", 128);
|
|
591
|
+
validateString(operation.dependsOn, "dependsOn", 128);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (type === "cancel-task") {
|
|
595
|
+
assertOnlyKeys(operation, ["type", "taskId"]);
|
|
596
|
+
validateString(operation.taskId, "taskId", 128);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (type === "request-verification") {
|
|
600
|
+
assertOnlyKeys(operation, ["type", "taskId", "verifier"]);
|
|
601
|
+
validateString(operation.taskId, "taskId", 128);
|
|
602
|
+
validateTask(strictObject(operation.verifier, "patch verifier"));
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (type === "invalidate-downstream") {
|
|
606
|
+
assertOnlyKeys(operation, ["type", "taskId", "reason"]);
|
|
607
|
+
validateString(operation.taskId, "taskId", 128);
|
|
608
|
+
validateString(operation.reason, "reason", MAX_ITEM_BYTES);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
throw new Error("Unsupported workflow plan patch operation");
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function validateBudget(budget: Record<string, unknown>, aggregate: boolean): void {
|
|
615
|
+
const limits: Record<string, number> = {
|
|
616
|
+
timeoutMs: MAX_SUBAGENT_TIMEOUT_MS,
|
|
617
|
+
maxTurns: MAX_BUDGET_TURNS,
|
|
618
|
+
maxToolCalls: MAX_BUDGET_TOOL_CALLS,
|
|
619
|
+
...(aggregate
|
|
620
|
+
? { maxTasks: MAX_AUTOMATION_TASKS, maxRevisions: MAX_AUTOMATION_REVISIONS }
|
|
621
|
+
: {}),
|
|
622
|
+
};
|
|
623
|
+
for (const [field, max] of Object.entries(limits)) {
|
|
624
|
+
const value = budget[field];
|
|
625
|
+
const minimum = field === "maxRevisions" ? 0 : 1;
|
|
626
|
+
if (!Number.isSafeInteger(value) || Number(value) < minimum || Number(value) > max)
|
|
627
|
+
throw new Error(`Invalid ${field} budget`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function validateRequiredObject(value: unknown, label: string, keys: readonly string[]): void {
|
|
632
|
+
const object = strictObject(value, label);
|
|
633
|
+
assertOnlyKeys(object, keys);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function validateStringList(value: unknown, label: string, requireOne = false): void {
|
|
637
|
+
if (!Array.isArray(value) || value.length > MAX_ITEMS || (requireOne && value.length < 1))
|
|
638
|
+
throw new Error(`Invalid ${label}`);
|
|
639
|
+
for (const item of value) validateString(item, label, MAX_ITEM_BYTES);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function validateString(value: unknown, label: string, maxBytes: number): void {
|
|
643
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(`Invalid ${label}`);
|
|
644
|
+
if (Buffer.byteLength(value, "utf8") > maxBytes) throw new Error(`${label} is too large`);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function validateSafeValue(value: unknown, label: string): void {
|
|
648
|
+
const visit = (candidate: unknown): void => {
|
|
649
|
+
if (typeof candidate === "string") {
|
|
650
|
+
if (containsTerminalControl(candidate)) {
|
|
651
|
+
throw new Error(`${label} contains terminal control bytes`);
|
|
652
|
+
}
|
|
653
|
+
if (PRIVATE_MARKER_PATTERN.test(candidate)) {
|
|
654
|
+
throw new Error(`${label} contains private data markers`);
|
|
655
|
+
}
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (Array.isArray(candidate)) {
|
|
659
|
+
for (const item of candidate) visit(item);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (candidate && typeof candidate === "object") {
|
|
663
|
+
for (const item of Object.values(candidate as Record<string, unknown>)) visit(item);
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
visit(value);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function validateRelativePath(value: string): void {
|
|
670
|
+
if (
|
|
671
|
+
Buffer.byteLength(value, "utf8") > MAX_PATH_BYTES ||
|
|
672
|
+
containsTerminalControl(value) ||
|
|
673
|
+
value.includes("\\") ||
|
|
674
|
+
path.posix.isAbsolute(value) ||
|
|
675
|
+
value.split("/").includes("..")
|
|
676
|
+
) {
|
|
677
|
+
throw new Error(`Invalid workflow path ${JSON.stringify(value.slice(0, 128))}`);
|
|
678
|
+
}
|
|
679
|
+
const normalized = path.posix.normalize(value.trim());
|
|
680
|
+
if (!normalized || normalized === ".." || normalized.startsWith("../"))
|
|
681
|
+
throw new Error("Invalid workflow path");
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function containsTerminalControl(value: string): boolean {
|
|
685
|
+
for (let index = 0; index < value.length; index++) {
|
|
686
|
+
const code = value.charCodeAt(index);
|
|
687
|
+
if (
|
|
688
|
+
code <= 0x08 ||
|
|
689
|
+
code === 0x0b ||
|
|
690
|
+
code === 0x0c ||
|
|
691
|
+
(code >= 0x0e && code <= 0x1f) ||
|
|
692
|
+
(code >= 0x7f && code <= 0x9f)
|
|
693
|
+
) {
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function strictObject(value: unknown, label: string): Record<string, unknown> {
|
|
701
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
702
|
+
throw new Error(`${label} must be an object`);
|
|
703
|
+
return value as Record<string, unknown>;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function assertOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): void {
|
|
707
|
+
const unexpected = Object.keys(value).find((key) => !allowed.includes(key));
|
|
708
|
+
if (unexpected) throw new Error("Unknown field in versioned automation contract");
|
|
709
|
+
}
|