specpi 0.11.2 → 0.12.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/CHANGELOG.md +13 -1
- package/NPM_RELEASE.md +3 -1
- package/README.md +38 -28
- package/SECURITY_MODEL.md +30 -0
- package/THIRD_PARTY.md +14 -0
- package/docs/delegation/README.md +264 -0
- package/docs/delegation/design-protocol.md +382 -0
- package/docs/delegation/design.md +525 -0
- package/docs/delegation/evaluation.md +307 -0
- package/docs/delegation/protocol.md +271 -0
- package/docs/delegation/research.md +216 -0
- package/extensions/command-guard/index.ts +118 -33
- package/extensions/delegation/core.mjs +772 -0
- package/extensions/delegation/errors.mjs +8 -0
- package/extensions/delegation/extension.mjs +475 -0
- package/extensions/delegation/index.ts +9 -0
- package/extensions/delegation/managed-files.mjs +13 -0
- package/extensions/delegation/native.mjs +155 -0
- package/extensions/delegation/presentation.mjs +315 -0
- package/extensions/delegation/protocol.mjs +296 -0
- package/extensions/delegation/provider.mjs +689 -0
- package/extensions/delegation/snapshot.mjs +532 -0
- package/extensions/delegation/worker.mjs +218 -0
- package/extensions/workflow-controls/index.ts +5 -1
- package/package.json +4 -2
- package/scripts/check-package.mjs +21 -3
- package/scripts/check-pi-package.mjs +4 -0
- package/scripts/check-syntax.mjs +61 -0
- package/scripts/specpi.mjs +6 -0
- package/site/logo.svg +1 -9
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
|
|
3
|
+
// Worker text is untrusted terminal content. Never interpret its ANSI, links or controls.
|
|
4
|
+
const plain = (value) =>
|
|
5
|
+
stripVTControlCharacters(String(value ?? "").slice(0, 16000))
|
|
6
|
+
.replace(/[\p{Cc}\p{Cf}]/gu, " ")
|
|
7
|
+
.replace(/\s+/gu, " ")
|
|
8
|
+
.trim();
|
|
9
|
+
const duration = (ms) => {
|
|
10
|
+
const seconds = Math.floor(Math.max(0, ms) / 1000);
|
|
11
|
+
|
|
12
|
+
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const stateLabel = (job) => {
|
|
16
|
+
if (job.settling && !["running", "queued"].includes(job.state)) {
|
|
17
|
+
return ["complete", "partial", "needs_context"].includes(job.state) ? "finishing" : "stopping";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
{ complete: "ready for review", partial: "partial · needs review", needs_context: "needs context" }[
|
|
22
|
+
job.state
|
|
23
|
+
] ?? plain(job.state)
|
|
24
|
+
);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const stateColor = (job) => {
|
|
28
|
+
if (job.settling || job.state === "running") {
|
|
29
|
+
return "accent";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return job.state === "complete" ? "success" : "warning";
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function panelLines(view, width, theme, truncateToWidth) {
|
|
36
|
+
const fit = (text) => truncateToWidth(text, Math.max(1, width), "…");
|
|
37
|
+
const lines = [
|
|
38
|
+
fit(
|
|
39
|
+
theme.fg("accent", "Delegation") +
|
|
40
|
+
theme.fg(
|
|
41
|
+
"muted",
|
|
42
|
+
` · ${view.active}/${view.concurrency} workers · ${view.calls}/${view.callLimit} calls`,
|
|
43
|
+
),
|
|
44
|
+
),
|
|
45
|
+
];
|
|
46
|
+
for (const job of view.jobs) {
|
|
47
|
+
const state = width < 72 && job.state === "complete" && !job.settling ? "ready" : stateLabel(job);
|
|
48
|
+
const label = theme.fg(stateColor(job), state);
|
|
49
|
+
const identity = `${plain(job.id)} · ${plain(job.mode)}`;
|
|
50
|
+
const metrics = `${duration(job.elapsedMs)} · ${job.calls} model · ${job.tools} tools`;
|
|
51
|
+
if (width < 72) {
|
|
52
|
+
const name = truncateToWidth(identity, Math.max(1, width - state.length - 5), "…");
|
|
53
|
+
lines.push(fit(` ${name} · ${label}`), fit(theme.fg("dim", ` ${metrics}`)));
|
|
54
|
+
} else {
|
|
55
|
+
const name = truncateToWidth(identity, Math.max(1, width - state.length - metrics.length - 8), "…");
|
|
56
|
+
lines.push(fit(` ${name} · ${label}` + theme.fg("dim", ` · ${metrics}`)));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return lines;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One mounted component and one 1 Hz timer, only in a live TUI with occupied slots. */
|
|
64
|
+
export function createLivePanel(getView, { truncateToWidth }) {
|
|
65
|
+
const key = "specpi-delegation-workers";
|
|
66
|
+
let context;
|
|
67
|
+
let timer;
|
|
68
|
+
let tui;
|
|
69
|
+
let mounted = false;
|
|
70
|
+
let view;
|
|
71
|
+
const dispose = () => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
timer = undefined;
|
|
74
|
+
if (mounted) {
|
|
75
|
+
try {
|
|
76
|
+
context?.ui?.setWidget?.(key, undefined);
|
|
77
|
+
} catch {
|
|
78
|
+
// A closing terminal cannot interrupt session teardown.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
mounted = false;
|
|
83
|
+
tui = undefined;
|
|
84
|
+
context = undefined;
|
|
85
|
+
view = undefined;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const update = () => {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
timer = undefined;
|
|
91
|
+
if (context?.mode !== "tui" || !context?.ui?.setWidget) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
view = getView();
|
|
96
|
+
if (!view.jobs.length) {
|
|
97
|
+
if (mounted) {
|
|
98
|
+
context.ui.setWidget(key, undefined);
|
|
99
|
+
mounted = false;
|
|
100
|
+
tui = undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!mounted) {
|
|
107
|
+
mounted = true;
|
|
108
|
+
context.ui.setWidget(key, (activeTui, theme) => {
|
|
109
|
+
tui = activeTui;
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
invalidate() {},
|
|
113
|
+
render: (width) => (view ? panelLines(view, width, theme, truncateToWidth) : []),
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
} else {
|
|
117
|
+
tui?.requestRender();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (view.active > 0) {
|
|
121
|
+
timer = setTimeout(() => {
|
|
122
|
+
try {
|
|
123
|
+
update();
|
|
124
|
+
} catch {
|
|
125
|
+
// UI failure cannot escape an async timer or affect worker ownership.
|
|
126
|
+
}
|
|
127
|
+
}, 1000);
|
|
128
|
+
timer.unref?.();
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
update,
|
|
134
|
+
dispose,
|
|
135
|
+
bind(ctx) {
|
|
136
|
+
dispose();
|
|
137
|
+
context = ctx;
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function readableStatus(state) {
|
|
143
|
+
const mode = state.enabled ? "enabled" : state.updating ? "updating model" : state.requested ? "paused" : "off";
|
|
144
|
+
const lines = [
|
|
145
|
+
`Delegation ${mode} · ${state.active}/${state.limits.concurrency} workers active`,
|
|
146
|
+
`Process budget: ${state.sessionCalls}/${state.limits.sessionCalls} model calls · ${state.sessionBatches}/${state.limits.sessionBatches} batches used`,
|
|
147
|
+
`Command Guard: ${plain(state.guard)}`,
|
|
148
|
+
];
|
|
149
|
+
if (state.model) {
|
|
150
|
+
lines.push(
|
|
151
|
+
`Model: ${plain(state.model.provider)}/${plain(state.model.id)} · thinking ${plain(state.model.thinkingLevel ?? "configured")}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (state.pauseReason) {
|
|
156
|
+
lines.push(plain(state.pauseReason));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const batch of state.batches.filter((item) => !item.retired)) {
|
|
160
|
+
lines.push(`Batch ${plain(batch.batchId)}`);
|
|
161
|
+
for (const job of batch.jobs) {
|
|
162
|
+
lines.push(
|
|
163
|
+
` ${plain(job.jobId)} · ${job.disposition ? plain(job.disposition) : stateLabel(job)} · ${job.calls} model calls`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return lines.join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function readableLimits(state) {
|
|
172
|
+
const limits = Object.entries(state.limits).map(([name, value]) => {
|
|
173
|
+
const unit = name.endsWith("Bytes") ? " bytes" : name.endsWith("Ms") ? " ms" : "";
|
|
174
|
+
const label = name.replace(/(Bytes|Ms)$/u, "").replace(/[A-Z]/gu, (letter) => ` ${letter.toLowerCase()}`);
|
|
175
|
+
|
|
176
|
+
return ` ${label}: ${value}${unit}`;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return `${readableStatus(state)}\nFixed limits:\n${limits.join("\n")}\nBudgets do not reset on off/on or reload. Cancellation is best effort; no billing cap.`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function createToolRenderers({ truncateToWidth, wrapTextWithAnsi }) {
|
|
183
|
+
const component = (getLines) => {
|
|
184
|
+
let cachedWidth;
|
|
185
|
+
let cachedLines;
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
invalidate() {
|
|
189
|
+
cachedWidth = undefined;
|
|
190
|
+
},
|
|
191
|
+
render(width) {
|
|
192
|
+
if (width === cachedWidth) {
|
|
193
|
+
return cachedLines;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const lines = getLines().flatMap((line) => wrapTextWithAnsi(line, Math.max(1, width)));
|
|
197
|
+
const selected =
|
|
198
|
+
lines.length > 120 ? [...lines.slice(0, 119), "… more in the structured tool response"] : lines;
|
|
199
|
+
|
|
200
|
+
cachedWidth = width;
|
|
201
|
+
cachedLines = selected.map((line) => truncateToWidth(line, Math.max(1, width), "…"));
|
|
202
|
+
|
|
203
|
+
return cachedLines;
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const references = (items) =>
|
|
209
|
+
(items ?? []).map((item) => `${plain(item.sourceId)}:${item.lineStart}–${item.lineEnd}`).join(", ");
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
renderCall(args, theme) {
|
|
213
|
+
return component(() => {
|
|
214
|
+
const operation = plain(args?.operation || "preparing");
|
|
215
|
+
const jobs = Array.isArray(args?.packet?.jobs)
|
|
216
|
+
? args.packet.jobs
|
|
217
|
+
.slice(0, 2)
|
|
218
|
+
.map((job) => `${plain(job.id)} (${plain(job.mode)})`)
|
|
219
|
+
.join(", ")
|
|
220
|
+
: plain(args?.jobId);
|
|
221
|
+
|
|
222
|
+
return [
|
|
223
|
+
theme.fg("toolTitle", `Delegate · ${operation}`) + (jobs ? theme.fg("muted", ` · ${jobs}`) : ""),
|
|
224
|
+
];
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
228
|
+
return component(() => {
|
|
229
|
+
if (isPartial) {
|
|
230
|
+
return [theme.fg("muted", "Waiting for worker results…")];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (result.isError || result.details?.error) {
|
|
234
|
+
return [
|
|
235
|
+
theme.fg(
|
|
236
|
+
"error",
|
|
237
|
+
plain(result.content?.find((item) => item.type === "text")?.text ?? "Delegation failed"),
|
|
238
|
+
),
|
|
239
|
+
];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const data = result.details ?? {};
|
|
243
|
+
if (typeof data.enabled === "boolean") {
|
|
244
|
+
return readableStatus(data)
|
|
245
|
+
.split("\n")
|
|
246
|
+
.map((line) => theme.fg("muted", line));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (data.disposition) {
|
|
250
|
+
return [
|
|
251
|
+
theme.fg(
|
|
252
|
+
"muted",
|
|
253
|
+
`${plain(data.jobId)} · ${plain(data.disposition.decision)} · parent assessment`,
|
|
254
|
+
),
|
|
255
|
+
];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const lines = (data.jobs ?? []).map(
|
|
259
|
+
(job) =>
|
|
260
|
+
`${plain(job.jobId)} · ${job.disposition ? plain(job.disposition) : stateLabel(job)} · ${job.calls} model calls`,
|
|
261
|
+
);
|
|
262
|
+
for (const item of data.results ?? []) {
|
|
263
|
+
if (!item.result) {
|
|
264
|
+
if (item.error) {
|
|
265
|
+
lines.push(theme.fg("warning", `${plain(item.receipt?.jobId)}: ${plain(item.error)}`));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const report = item.result;
|
|
272
|
+
lines.push(
|
|
273
|
+
theme.fg(
|
|
274
|
+
"accent",
|
|
275
|
+
`${plain(item.receipt?.jobId)} · ${report.findings.length} findings · advisory`,
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
if (expanded) {
|
|
279
|
+
lines.push(plain(report.answer));
|
|
280
|
+
for (const finding of report.findings) {
|
|
281
|
+
lines.push(`${plain(finding.id)} [${plain(finding.confidence)}] ${plain(finding.claim)}`);
|
|
282
|
+
lines.push(theme.fg("dim", ` Evidence: ${references(finding.evidence) || "none"}`));
|
|
283
|
+
if (finding.contraryEvidence?.length) {
|
|
284
|
+
lines.push(theme.fg("dim", ` Contrary: ${references(finding.contraryEvidence)}`));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for (const requirement of report.requirements) {
|
|
289
|
+
lines.push(
|
|
290
|
+
theme.fg(
|
|
291
|
+
"dim",
|
|
292
|
+
`${plain(requirement.id)} · ${plain(requirement.status)} · ${references(requirement.evidence) || "no references"}`,
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for (const missing of report.missing) {
|
|
298
|
+
lines.push(theme.fg("warning", `Missing: ${plain(missing)}`));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (report.nextStep) {
|
|
302
|
+
lines.push(`Next: ${plain(report.nextStep)}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!expanded && data.results?.some((item) => item.result)) {
|
|
308
|
+
lines.push(theme.fg("dim", "Expand tool output for findings and evidence."));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return lines.length ? lines : [theme.fg("muted", "No new worker results.")];
|
|
312
|
+
});
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { DelegationError } from "./errors.mjs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
export const LIMITS = Object.freeze({
|
|
5
|
+
concurrency: 2,
|
|
6
|
+
sessionBatches: 4,
|
|
7
|
+
sessionCalls: 32,
|
|
8
|
+
batchJobs: 2,
|
|
9
|
+
batchCalls: 8,
|
|
10
|
+
jobCalls: 4,
|
|
11
|
+
jobMs: 120_000,
|
|
12
|
+
batchMs: 300_000,
|
|
13
|
+
contextBytes: 256 * 1024,
|
|
14
|
+
retainedResponseBytes: 256 * 1024,
|
|
15
|
+
toolBytes: 64 * 1024,
|
|
16
|
+
toolCalls: 12,
|
|
17
|
+
resultBytes: 16 * 1024,
|
|
18
|
+
outputTokens: 8192,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export function bytes(value) {
|
|
22
|
+
return Buffer.byteLength(typeof value === "string" ? value : JSON.stringify(value), "utf8");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function digest(value) {
|
|
26
|
+
const canonical = (item) => {
|
|
27
|
+
if (Array.isArray(item)) {
|
|
28
|
+
return item.map(canonical);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (item && typeof item === "object") {
|
|
32
|
+
return Object.fromEntries(
|
|
33
|
+
Object.keys(item)
|
|
34
|
+
.sort()
|
|
35
|
+
.map((key) => [key, canonical(item[key])]),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return item;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return createHash("sha256")
|
|
43
|
+
.update(JSON.stringify(canonical(value)))
|
|
44
|
+
.digest("hex");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function record(value, keys, required = keys) {
|
|
48
|
+
if (
|
|
49
|
+
!value ||
|
|
50
|
+
typeof value !== "object" ||
|
|
51
|
+
Array.isArray(value) ||
|
|
52
|
+
![Object.prototype, null].includes(Object.getPrototypeOf(value)) ||
|
|
53
|
+
Object.keys(value).some((key) => !keys.includes(key)) ||
|
|
54
|
+
required.some((key) => !Object.hasOwn(value, key))
|
|
55
|
+
) {
|
|
56
|
+
throw new DelegationError("Invalid closed delegation object");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function text(value, max = 4000, empty = false) {
|
|
63
|
+
if (typeof value !== "string" || (!empty && !value.trim()) || value.length > max || value.includes("\0")) {
|
|
64
|
+
throw new DelegationError("Invalid delegation text");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function identifier(value) {
|
|
71
|
+
if (typeof value !== "string" || !/^[a-zA-Z0-9_-]{1,80}$/u.test(value)) {
|
|
72
|
+
throw new DelegationError("Invalid delegation identifier");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function list(value, max, check) {
|
|
79
|
+
if (!Array.isArray(value) || value.length > max) {
|
|
80
|
+
throw new DelegationError("Invalid delegation list");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
value.forEach(check);
|
|
84
|
+
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function integer(value, min, max) {
|
|
89
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) {
|
|
90
|
+
throw new DelegationError("Invalid delegation integer");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validatePacketShape(packet) {
|
|
97
|
+
record(packet, ["objective", "requirements", "decisions", "nonGoals", "reason", "jobs"]);
|
|
98
|
+
text(packet.objective);
|
|
99
|
+
list(packet.requirements, 24, (item) => {
|
|
100
|
+
record(item, ["id", "text"]);
|
|
101
|
+
identifier(item.id);
|
|
102
|
+
text(item.text, 2000);
|
|
103
|
+
});
|
|
104
|
+
if (
|
|
105
|
+
!packet.requirements.length ||
|
|
106
|
+
new Set(packet.requirements.map((item) => item.id)).size !== packet.requirements.length
|
|
107
|
+
) {
|
|
108
|
+
throw new DelegationError("Requirements must have unique identifiers");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
list(packet.decisions, 24, (item) => text(item, 2000));
|
|
112
|
+
list(packet.nonGoals, 24, (item) => text(item, 2000));
|
|
113
|
+
record(packet.reason, ["benefit", "why", "parentWork"]);
|
|
114
|
+
if (!["independent_review", "parallel_analysis", "context_isolation"].includes(packet.reason.benefit)) {
|
|
115
|
+
throw new DelegationError("Delegation is limited to independent review or selected-source analysis");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
text(packet.reason.why, 2000);
|
|
119
|
+
text(packet.reason.parentWork, 2000, packet.reason.benefit !== "parallel_analysis");
|
|
120
|
+
list(packet.jobs, LIMITS.batchJobs, (job) => {
|
|
121
|
+
record(job, ["id", "mode", "question", "context", "sources", "requirements"]);
|
|
122
|
+
identifier(job.id);
|
|
123
|
+
if (!["review", "scout"].includes(job.mode)) {
|
|
124
|
+
throw new DelegationError("Unsupported delegation mode");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
text(job.question, 8000);
|
|
128
|
+
text(job.context, 128 * 1024, true);
|
|
129
|
+
list(job.sources, 200, (source) => text(source, 500));
|
|
130
|
+
list(job.requirements, packet.requirements.length, identifier);
|
|
131
|
+
if (
|
|
132
|
+
!job.requirements.length ||
|
|
133
|
+
new Set(job.requirements).size !== job.requirements.length ||
|
|
134
|
+
job.requirements.some((id) => !packet.requirements.some((requirement) => requirement.id === id))
|
|
135
|
+
) {
|
|
136
|
+
throw new DelegationError("Each worker needs a unique subset of the packet requirement IDs");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if ((packet.reason.benefit === "independent_review") !== (job.mode === "review")) {
|
|
140
|
+
throw new DelegationError("The worker mode must match the declared delegation benefit");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!job.context.trim() && !job.sources.length) {
|
|
144
|
+
throw new DelegationError("A worker needs a frozen artifact or selected evidence to examine");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (job.mode === "scout" && !job.sources.length) {
|
|
148
|
+
throw new DelegationError("A scout requires explicitly selected source files");
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
if (
|
|
152
|
+
!packet.jobs.length ||
|
|
153
|
+
new Set(packet.jobs.map((job) => job.id)).size !== packet.jobs.length ||
|
|
154
|
+
bytes(packet) > LIMITS.contextBytes
|
|
155
|
+
) {
|
|
156
|
+
throw new DelegationError("Delegation packet exceeds its bound or repeats job identifiers");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (new Set(packet.jobs.map((job) => job.question.trim().toLowerCase())).size !== packet.jobs.length) {
|
|
160
|
+
throw new DelegationError("Assign distinct worker questions instead of duplicate answers");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function validatePacket(packet) {
|
|
165
|
+
validatePacketShape(packet);
|
|
166
|
+
|
|
167
|
+
return structuredClone(packet);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const bindingKeys = ["batchId", "jobId", "attemptId", "packetDigest", "generation", "resultRevision"];
|
|
171
|
+
|
|
172
|
+
export function validateOperation(input) {
|
|
173
|
+
const operations = {
|
|
174
|
+
run: ["operation", "requestId", "packet"],
|
|
175
|
+
status: ["operation"],
|
|
176
|
+
collect: ["operation", "batchId", "afterCursor", "waitMs"],
|
|
177
|
+
follow_up: ["operation", "requestId", ...bindingKeys, "prompt"],
|
|
178
|
+
resolve: ["operation", "requestId", ...bindingKeys, "decision", "findings"],
|
|
179
|
+
cancel: ["operation", "requestId", "batchId", "jobId"],
|
|
180
|
+
};
|
|
181
|
+
if (!operations[input?.operation]) {
|
|
182
|
+
throw new DelegationError("Unsupported delegation operation");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const optional =
|
|
186
|
+
input.operation === "collect" ? ["afterCursor", "waitMs"] : input.operation === "cancel" ? ["jobId"] : [];
|
|
187
|
+
record(
|
|
188
|
+
input,
|
|
189
|
+
operations[input.operation],
|
|
190
|
+
operations[input.operation].filter((key) => !optional.includes(key)),
|
|
191
|
+
);
|
|
192
|
+
if (bytes(input) > LIMITS.contextBytes + 4096) {
|
|
193
|
+
throw new DelegationError("Delegation operation exceeds its bound");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const key of ["requestId", "batchId", "jobId", "attemptId"]) {
|
|
197
|
+
if (input[key] !== undefined) {
|
|
198
|
+
identifier(input[key]);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (input.operation === "run") {
|
|
203
|
+
validatePacketShape(input.packet);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (input.operation === "collect") {
|
|
207
|
+
integer(input.afterCursor ?? 0, 0, Number.MAX_SAFE_INTEGER);
|
|
208
|
+
integer(input.waitMs ?? 0, 0, 30_000);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (["follow_up", "resolve"].includes(input.operation)) {
|
|
212
|
+
if (!/^[a-f0-9]{64}$/u.test(input.packetDigest)) {
|
|
213
|
+
throw new DelegationError("Invalid packet digest");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
integer(input.generation, 0, Number.MAX_SAFE_INTEGER);
|
|
217
|
+
integer(input.resultRevision, 1, Number.MAX_SAFE_INTEGER);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (input.operation === "follow_up") {
|
|
221
|
+
text(input.prompt, 8000);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (input.operation === "resolve") {
|
|
225
|
+
if (!["accept", "discard", "needs_check"].includes(input.decision)) {
|
|
226
|
+
throw new DelegationError("Invalid parent disposition");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
list(input.findings, 8, (finding) => {
|
|
230
|
+
record(finding, ["id", "decision"]);
|
|
231
|
+
identifier(finding.id);
|
|
232
|
+
if (!["confirmed", "rejected", "needs_check"].includes(finding.decision)) {
|
|
233
|
+
throw new DelegationError("Invalid finding disposition");
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return structuredClone(input);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function validateResult(result, { requirements, sources }) {
|
|
242
|
+
record(result, ["status", "answer", "requirements", "findings", "missing", "nextStep"]);
|
|
243
|
+
if (bytes(result) > LIMITS.resultBytes || !["complete", "partial", "needs_context"].includes(result.status)) {
|
|
244
|
+
throw new DelegationError("Invalid worker result size or status");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
text(result.answer, 12_000, true);
|
|
248
|
+
text(result.nextStep, 2000, true);
|
|
249
|
+
list(result.missing, 16, (item) => text(item, 1000));
|
|
250
|
+
const sourceMap = new Map(sources.map((source) => [source.id, source]));
|
|
251
|
+
const evidence = (item) => {
|
|
252
|
+
record(item, ["sourceId", "lineStart", "lineEnd"]);
|
|
253
|
+
const source = sourceMap.get(item.sourceId);
|
|
254
|
+
if (!source) {
|
|
255
|
+
throw new DelegationError("Worker referenced an unknown source");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
integer(item.lineStart, 1, source.lineCount);
|
|
259
|
+
integer(item.lineEnd, item.lineStart, source.lineCount);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
list(result.requirements, requirements.length, (item) => {
|
|
263
|
+
record(item, ["id", "status", "evidence"]);
|
|
264
|
+
if (
|
|
265
|
+
!requirements.some((requirement) => requirement.id === item.id) ||
|
|
266
|
+
!["addressed", "unaddressed"].includes(item.status)
|
|
267
|
+
) {
|
|
268
|
+
throw new DelegationError("Worker requirement coverage is invalid");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
list(item.evidence, 8, evidence);
|
|
272
|
+
});
|
|
273
|
+
if (new Set(result.requirements.map((item) => item.id)).size !== requirements.length) {
|
|
274
|
+
throw new DelegationError("Worker must cover each requirement exactly once");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
list(result.findings, 8, (finding) => {
|
|
278
|
+
record(finding, ["id", "claim", "confidence", "evidence", "contraryEvidence"]);
|
|
279
|
+
identifier(finding.id);
|
|
280
|
+
text(finding.claim, 2000);
|
|
281
|
+
if (!["observed", "inferred", "unverified"].includes(finding.confidence)) {
|
|
282
|
+
throw new DelegationError("Invalid finding confidence");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
list(finding.evidence, 8, evidence);
|
|
286
|
+
list(finding.contraryEvidence, 8, evidence);
|
|
287
|
+
if (finding.confidence === "observed" && !finding.evidence.length) {
|
|
288
|
+
throw new DelegationError("Observed findings require a source reference");
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
if (new Set(result.findings.map((finding) => finding.id)).size !== result.findings.length) {
|
|
292
|
+
throw new DelegationError("Worker finding identifiers must be unique");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return structuredClone(result);
|
|
296
|
+
}
|