pi-subagents 0.27.0 → 0.29.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 +27 -0
- package/README.md +16 -15
- package/package.json +1 -1
- package/skills/pi-subagents/SKILL.md +3 -6
- package/src/agents/agent-management.ts +10 -6
- package/src/agents/agent-selection.ts +2 -0
- package/src/agents/agents.ts +303 -6
- package/src/agents/chain-serializer.ts +4 -9
- package/src/extension/doctor.ts +4 -3
- package/src/extension/fanout-child.ts +0 -1
- package/src/extension/index.ts +1 -4
- package/src/extension/schemas.ts +31 -28
- package/src/intercom/intercom-bridge.ts +11 -1
- package/src/runs/background/async-execution.ts +20 -7
- package/src/runs/background/run-status.ts +1 -7
- package/src/runs/background/subagent-runner.ts +73 -146
- package/src/runs/foreground/chain-execution.ts +61 -13
- package/src/runs/foreground/execution.ts +28 -172
- package/src/runs/foreground/subagent-executor.ts +25 -40
- package/src/runs/shared/acceptance.ts +605 -22
- package/src/runs/shared/completion-guard.ts +3 -26
- package/src/runs/shared/model-fallback.ts +38 -0
- package/src/runs/shared/parallel-utils.ts +6 -8
- package/src/runs/shared/subagent-prompt-runtime.ts +3 -2
- package/src/shared/atomic-json.ts +68 -11
- package/src/shared/settings.ts +1 -0
- package/src/shared/types.ts +8 -32
- package/src/shared/utils.ts +2 -1
- package/src/tui/render.ts +1 -11
- package/src/runs/shared/acceptance-contract.ts +0 -291
- package/src/runs/shared/acceptance-evaluation.ts +0 -221
- package/src/runs/shared/acceptance-finalization.ts +0 -161
- package/src/runs/shared/acceptance-reports.ts +0 -127
|
@@ -20,6 +20,44 @@ export function splitThinkingSuffix(model: string): { baseModel: string; thinkin
|
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** Sentinel model value requesting that a subagent inherit the parent session's model. */
|
|
24
|
+
export const INHERIT_MODEL = "inherit";
|
|
25
|
+
|
|
26
|
+
/** Minimal shape of the parent session's in-memory model (`ctx.model`). */
|
|
27
|
+
export interface ParentModel {
|
|
28
|
+
provider: string;
|
|
29
|
+
id: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the `--model` override passed to a spawned subagent.
|
|
34
|
+
*
|
|
35
|
+
* When no model is requested (`undefined`, `false`, empty, or the `"inherit"`
|
|
36
|
+
* sentinel), the child must inherit the parent session's *in-memory* model
|
|
37
|
+
* (`provider/id`) instead of being left to resolve its own model. Without an
|
|
38
|
+
* explicit `provider/id`, the child falls back to the global
|
|
39
|
+
* `~/.pi/agent/settings.json` default, which is shared across every open PI
|
|
40
|
+
* session — so a different session that last changed its model in the TUI would
|
|
41
|
+
* silently contaminate this session's subagents (see issue #266). Passing an
|
|
42
|
+
* explicit `provider/id` keeps each session's children isolated to that
|
|
43
|
+
* session's model.
|
|
44
|
+
*
|
|
45
|
+
* An explicitly requested model string is resolved via {@link resolveModelCandidate}.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveSubagentModelOverride(
|
|
48
|
+
requestedModel: string | boolean | undefined,
|
|
49
|
+
parentModel: ParentModel | undefined,
|
|
50
|
+
availableModels: AvailableModelInfo[] | undefined,
|
|
51
|
+
preferredProvider?: string,
|
|
52
|
+
): string | undefined {
|
|
53
|
+
const trimmed = typeof requestedModel === "string" ? requestedModel.trim() : "";
|
|
54
|
+
const explicit = trimmed && trimmed !== INHERIT_MODEL ? trimmed : undefined;
|
|
55
|
+
if (explicit === undefined) {
|
|
56
|
+
return parentModel ? `${parentModel.provider}/${parentModel.id}` : undefined;
|
|
57
|
+
}
|
|
58
|
+
return resolveModelCandidate(explicit, availableModels, preferredProvider);
|
|
59
|
+
}
|
|
60
|
+
|
|
23
61
|
export function resolveModelCandidate(
|
|
24
62
|
model: string | undefined,
|
|
25
63
|
availableModels: AvailableModelInfo[] | undefined,
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import type { DynamicCollectSpec, DynamicExpandSpec } from "../../shared/settings.ts";
|
|
2
|
-
import type { JsonSchemaObject, ResolvedAcceptanceConfig } from "../../shared/types.ts";
|
|
3
|
-
|
|
4
1
|
export interface RunnerSubagentStep {
|
|
5
2
|
agent: string;
|
|
6
3
|
task: string;
|
|
@@ -26,12 +23,12 @@ export interface RunnerSubagentStep {
|
|
|
26
23
|
sessionFile?: string;
|
|
27
24
|
maxSubagentDepth?: number;
|
|
28
25
|
structuredOutput?: {
|
|
29
|
-
schema: JsonSchemaObject;
|
|
26
|
+
schema: import("../../shared/types.ts").JsonSchemaObject;
|
|
30
27
|
schemaPath: string;
|
|
31
28
|
outputPath: string;
|
|
32
29
|
};
|
|
33
|
-
structuredOutputSchema?: JsonSchemaObject;
|
|
34
|
-
effectiveAcceptance?: ResolvedAcceptanceConfig;
|
|
30
|
+
structuredOutputSchema?: import("../../shared/types.ts").JsonSchemaObject;
|
|
31
|
+
effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
|
|
35
32
|
}
|
|
36
33
|
|
|
37
34
|
export interface ParallelStepGroup {
|
|
@@ -42,13 +39,14 @@ export interface ParallelStepGroup {
|
|
|
42
39
|
}
|
|
43
40
|
|
|
44
41
|
export interface DynamicRunnerGroup {
|
|
45
|
-
expand: DynamicExpandSpec;
|
|
42
|
+
expand: import("../../shared/settings.ts").DynamicExpandSpec;
|
|
46
43
|
parallel: RunnerSubagentStep;
|
|
47
|
-
collect: DynamicCollectSpec;
|
|
44
|
+
collect: import("../../shared/settings.ts").DynamicCollectSpec;
|
|
48
45
|
concurrency?: number;
|
|
49
46
|
failFast?: boolean;
|
|
50
47
|
phase?: string;
|
|
51
48
|
label?: string;
|
|
49
|
+
effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
|
|
52
50
|
}
|
|
53
51
|
|
|
54
52
|
export type RunnerStep = RunnerSubagentStep | ParallelStepGroup | DynamicRunnerGroup;
|
|
@@ -135,14 +135,15 @@ function stripAssistantSubagentToolCallBlocks(message: unknown): unknown | undef
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
export function stripParentOnlySubagentMessages(messages: unknown[]): unknown[] {
|
|
138
|
+
const preserveCurrentFanoutToolHistory = process.env[SUBAGENT_FANOUT_CHILD_ENV] === "1";
|
|
138
139
|
let changed = false;
|
|
139
140
|
const filtered: unknown[] = [];
|
|
140
141
|
for (const message of messages) {
|
|
141
|
-
if (isParentOnlySubagentMessage(message) || isSubagentToolResultMessage(message)) {
|
|
142
|
+
if (isParentOnlySubagentMessage(message) || (!preserveCurrentFanoutToolHistory && isSubagentToolResultMessage(message))) {
|
|
142
143
|
changed = true;
|
|
143
144
|
continue;
|
|
144
145
|
}
|
|
145
|
-
const stripped = stripAssistantSubagentToolCallBlocks(message);
|
|
146
|
+
const stripped = preserveCurrentFanoutToolHistory ? message : stripAssistantSubagentToolCallBlocks(message);
|
|
146
147
|
if (stripped === undefined) {
|
|
147
148
|
changed = true;
|
|
148
149
|
continue;
|
|
@@ -1,16 +1,73 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
4
|
+
type AtomicJsonFs = Pick<typeof fs, "mkdirSync" | "writeFileSync" | "renameSync" | "rmSync">;
|
|
5
|
+
|
|
6
|
+
type AtomicJsonWriterOptions = {
|
|
7
|
+
fs?: AtomicJsonFs;
|
|
8
|
+
now?: () => number;
|
|
9
|
+
pid?: number;
|
|
10
|
+
random?: () => number;
|
|
11
|
+
retryRenameErrors?: boolean;
|
|
12
|
+
retryDelaysMs?: readonly number[];
|
|
13
|
+
wait?: (delayMs: number) => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const DEFAULT_RENAME_RETRY_DELAYS_MS = [10, 25, 50, 100, 200] as const;
|
|
17
|
+
const RETRYABLE_RENAME_ERROR_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
|
|
18
|
+
|
|
19
|
+
function waitSync(delayMs: number): void {
|
|
20
|
+
const end = Date.now() + delayMs;
|
|
21
|
+
while (Date.now() < end) {
|
|
22
|
+
// writeAtomicJson is synchronous because callers often update status from sync callbacks.
|
|
15
23
|
}
|
|
16
24
|
}
|
|
25
|
+
|
|
26
|
+
function isRetryableRenameError(error: unknown): boolean {
|
|
27
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
28
|
+
return typeof code === "string" && RETRYABLE_RENAME_ERROR_CODES.has(code);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renameWithRetry(
|
|
32
|
+
fsImpl: AtomicJsonFs,
|
|
33
|
+
sourcePath: string,
|
|
34
|
+
targetPath: string,
|
|
35
|
+
retryDelaysMs: readonly number[],
|
|
36
|
+
wait: (delayMs: number) => void,
|
|
37
|
+
): void {
|
|
38
|
+
for (let attempt = 0; ; attempt++) {
|
|
39
|
+
try {
|
|
40
|
+
fsImpl.renameSync(sourcePath, targetPath);
|
|
41
|
+
return;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const delayMs = retryDelaysMs[attempt];
|
|
44
|
+
if (delayMs === undefined || !isRetryableRenameError(error)) throw error;
|
|
45
|
+
wait(delayMs);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createAtomicJsonWriter(options: AtomicJsonWriterOptions = {}): (filePath: string, payload: object) => void {
|
|
51
|
+
const fsImpl = options.fs ?? fs;
|
|
52
|
+
const now = options.now ?? Date.now;
|
|
53
|
+
const pid = options.pid ?? process.pid;
|
|
54
|
+
const random = options.random ?? Math.random;
|
|
55
|
+
const retryRenameErrors = options.retryRenameErrors ?? process.platform === "win32";
|
|
56
|
+
const retryDelaysMs = retryRenameErrors ? options.retryDelaysMs ?? DEFAULT_RENAME_RETRY_DELAYS_MS : [];
|
|
57
|
+
const wait = options.wait ?? waitSync;
|
|
58
|
+
return (filePath: string, payload: object): void => {
|
|
59
|
+
fsImpl.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
60
|
+
const tempPath = path.join(
|
|
61
|
+
path.dirname(filePath),
|
|
62
|
+
`.${path.basename(filePath)}.${pid}.${now()}.${random().toString(36).slice(2)}.tmp`,
|
|
63
|
+
);
|
|
64
|
+
try {
|
|
65
|
+
fsImpl.writeFileSync(tempPath, JSON.stringify(payload, null, 2), "utf-8");
|
|
66
|
+
renameWithRetry(fsImpl, tempPath, filePath, retryDelaysMs, wait);
|
|
67
|
+
} finally {
|
|
68
|
+
fsImpl.rmSync(tempPath, { force: true });
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const writeAtomicJson = createAtomicJsonWriter();
|
package/src/shared/settings.ts
CHANGED
package/src/shared/types.ts
CHANGED
|
@@ -239,7 +239,7 @@ export interface ModelAttempt {
|
|
|
239
239
|
usage?: Usage;
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
export type
|
|
242
|
+
export type AcceptanceLevel = "auto" | "none" | "attested" | "checked" | "verified" | "reviewed";
|
|
243
243
|
|
|
244
244
|
export type AcceptanceEvidenceKind =
|
|
245
245
|
| "changed-files"
|
|
@@ -275,15 +275,16 @@ export interface AcceptanceReviewGate {
|
|
|
275
275
|
}
|
|
276
276
|
|
|
277
277
|
export interface AcceptanceConfig {
|
|
278
|
+
level?: AcceptanceLevel;
|
|
278
279
|
criteria?: Array<string | AcceptanceGate>;
|
|
279
280
|
evidence?: AcceptanceEvidenceKind[];
|
|
280
281
|
verify?: AcceptanceVerifyCommand[];
|
|
281
|
-
review?: AcceptanceReviewGate;
|
|
282
|
+
review?: AcceptanceReviewGate | false;
|
|
282
283
|
stopRules?: string[];
|
|
283
|
-
|
|
284
|
+
reason?: string;
|
|
284
285
|
}
|
|
285
286
|
|
|
286
|
-
export type AcceptanceInput = AcceptanceConfig;
|
|
287
|
+
export type AcceptanceInput = AcceptanceLevel | false | AcceptanceConfig;
|
|
287
288
|
|
|
288
289
|
export interface ResolvedAcceptanceGate extends AcceptanceGate {
|
|
289
290
|
id: string;
|
|
@@ -293,18 +294,15 @@ export interface ResolvedAcceptanceGate extends AcceptanceGate {
|
|
|
293
294
|
}
|
|
294
295
|
|
|
295
296
|
export interface ResolvedAcceptanceConfig {
|
|
296
|
-
level:
|
|
297
|
+
level: Exclude<AcceptanceLevel, "auto">;
|
|
297
298
|
explicit: boolean;
|
|
298
299
|
inferredReason: string[];
|
|
299
300
|
criteria: ResolvedAcceptanceGate[];
|
|
300
301
|
evidence: AcceptanceEvidenceKind[];
|
|
301
302
|
verify: AcceptanceVerifyCommand[];
|
|
302
|
-
review?: AcceptanceReviewGate;
|
|
303
|
+
review?: AcceptanceReviewGate | false;
|
|
303
304
|
stopRules: string[];
|
|
304
|
-
|
|
305
|
-
mode: "none" | "self-review-loop";
|
|
306
|
-
maxTurns: number;
|
|
307
|
-
};
|
|
305
|
+
reason?: string;
|
|
308
306
|
}
|
|
309
307
|
|
|
310
308
|
export interface AcceptanceReport {
|
|
@@ -368,25 +366,6 @@ export type AcceptanceLedgerStatus =
|
|
|
368
366
|
| "accepted"
|
|
369
367
|
| "rejected";
|
|
370
368
|
|
|
371
|
-
export interface AcceptanceFinalizationTurn {
|
|
372
|
-
turn: number;
|
|
373
|
-
prompt: string;
|
|
374
|
-
status: AcceptanceLedgerStatus;
|
|
375
|
-
rawOutput?: string;
|
|
376
|
-
report?: AcceptanceReport;
|
|
377
|
-
parseError?: string;
|
|
378
|
-
runtimeChecks: AcceptanceRuntimeCheck[];
|
|
379
|
-
verifyRuns: AcceptanceVerifyResult[];
|
|
380
|
-
failureMessage?: string;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
export interface AcceptanceFinalizationLedger {
|
|
384
|
-
mode: "self-review-loop";
|
|
385
|
-
status: "not-run" | "completed" | "failed";
|
|
386
|
-
maxTurns: number;
|
|
387
|
-
turns: AcceptanceFinalizationTurn[];
|
|
388
|
-
}
|
|
389
|
-
|
|
390
369
|
export interface AcceptanceLedger {
|
|
391
370
|
status: AcceptanceLedgerStatus;
|
|
392
371
|
explicit: boolean;
|
|
@@ -395,12 +374,9 @@ export interface AcceptanceLedger {
|
|
|
395
374
|
criteria: ResolvedAcceptanceGate[];
|
|
396
375
|
childReport?: AcceptanceReport;
|
|
397
376
|
childReportParseError?: string;
|
|
398
|
-
initialChildReport?: AcceptanceReport;
|
|
399
|
-
initialChildReportParseError?: string;
|
|
400
377
|
runtimeChecks: AcceptanceRuntimeCheck[];
|
|
401
378
|
verifyRuns: AcceptanceVerifyResult[];
|
|
402
379
|
reviewResult?: AcceptanceReviewResult;
|
|
403
|
-
finalization?: AcceptanceFinalizationLedger;
|
|
404
380
|
parentDecision?: {
|
|
405
381
|
status: "accepted" | "rejected";
|
|
406
382
|
at: string;
|
package/src/shared/utils.ts
CHANGED
|
@@ -192,7 +192,8 @@ export function getFinalOutput(messages: Message[]): string {
|
|
|
192
192
|
const hasAssistantError = ("errorMessage" in msg && typeof msg.errorMessage === "string" && msg.errorMessage.length > 0)
|
|
193
193
|
|| ("stopReason" in msg && msg.stopReason === "error");
|
|
194
194
|
if (hasAssistantError) continue;
|
|
195
|
-
for (
|
|
195
|
+
for (let j = msg.content.length - 1; j >= 0; j--) {
|
|
196
|
+
const part = msg.content[j];
|
|
196
197
|
if (part.type === "text" && part.text.trim().length > 0) return part.text;
|
|
197
198
|
}
|
|
198
199
|
}
|
package/src/tui/render.ts
CHANGED
|
@@ -222,21 +222,11 @@ function firstOutputLine(text: string): string {
|
|
|
222
222
|
return text.split("\n").find((line) => line.trim())?.trim() ?? "";
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
function formatAcceptanceStatus(result: Details["results"][number]): string | undefined {
|
|
226
|
-
const acceptance = result.acceptance;
|
|
227
|
-
if (!acceptance?.status || acceptance.status === "not-required") return undefined;
|
|
228
|
-
const finalization = acceptance.finalization
|
|
229
|
-
? ` · finalization: ${acceptance.finalization.status} after ${acceptance.finalization.turns.length}/${acceptance.finalization.maxTurns} turns`
|
|
230
|
-
: "";
|
|
231
|
-
return `acceptance: ${acceptance.status}${finalization}`;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
225
|
function resultStatusLine(result: Details["results"][number], output: string): string {
|
|
235
226
|
if (result.detached) return result.detachedReason ? `Detached: ${result.detachedReason}` : "Detached";
|
|
236
227
|
if (result.interrupted) return "Paused";
|
|
237
228
|
if (result.exitCode !== 0) return `Error: ${result.error ?? (firstOutputLine(output) || `exit ${result.exitCode}`)}`;
|
|
238
|
-
|
|
239
|
-
if (acceptance) return `Done · ${acceptance}`;
|
|
229
|
+
if (result.acceptance?.status && result.acceptance.status !== "not-required") return `Done · acceptance: ${result.acceptance.status}`;
|
|
240
230
|
if (hasEmptyTextOutputWithoutOutputTarget(result.task, output)) return "Done (no text output)";
|
|
241
231
|
return "Done";
|
|
242
232
|
}
|
|
@@ -1,291 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
AcceptanceConfig,
|
|
3
|
-
AcceptanceEvidenceKind,
|
|
4
|
-
AcceptanceInput,
|
|
5
|
-
AcceptanceProvenanceLevel,
|
|
6
|
-
ResolvedAcceptanceConfig,
|
|
7
|
-
ResolvedAcceptanceGate,
|
|
8
|
-
SubagentRunMode,
|
|
9
|
-
} from "../../shared/types.ts";
|
|
10
|
-
|
|
11
|
-
const DEFAULT_FINALIZATION_MAX_TURNS = 3;
|
|
12
|
-
const MAX_FINALIZATION_TURNS = 10;
|
|
13
|
-
|
|
14
|
-
const VALID_EVIDENCE = new Set<AcceptanceEvidenceKind>([
|
|
15
|
-
"changed-files",
|
|
16
|
-
"tests-added",
|
|
17
|
-
"commands-run",
|
|
18
|
-
"validation-output",
|
|
19
|
-
"residual-risks",
|
|
20
|
-
"no-staged-files",
|
|
21
|
-
"diff-summary",
|
|
22
|
-
"review-findings",
|
|
23
|
-
"manual-notes",
|
|
24
|
-
]);
|
|
25
|
-
|
|
26
|
-
const ACCEPTANCE_KEYS = new Set([
|
|
27
|
-
"criteria",
|
|
28
|
-
"evidence",
|
|
29
|
-
"verify",
|
|
30
|
-
"review",
|
|
31
|
-
"stopRules",
|
|
32
|
-
"maxFinalizationTurns",
|
|
33
|
-
]);
|
|
34
|
-
|
|
35
|
-
const REMOVED_ACCEPTANCE_KEYS = new Set(["level", "finalization", "reason"]);
|
|
36
|
-
|
|
37
|
-
function hasArrayItems(value: unknown): boolean {
|
|
38
|
-
return Array.isArray(value) && value.length > 0;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"): string[] {
|
|
42
|
-
const errors: string[] = [];
|
|
43
|
-
if (input === undefined) return errors;
|
|
44
|
-
if (input === false || typeof input === "string") {
|
|
45
|
-
errors.push(`${pathLabel} must be an object. Public acceptance levels and false disables are no longer supported.`);
|
|
46
|
-
return errors;
|
|
47
|
-
}
|
|
48
|
-
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
49
|
-
errors.push(`${pathLabel} must be an object.`);
|
|
50
|
-
return errors;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const value = input as Record<string, unknown>;
|
|
54
|
-
if (Object.hasOwn(value, "level")) {
|
|
55
|
-
errors.push(`${pathLabel}.level is no longer supported; configure criteria, evidence, verify, and review directly.`);
|
|
56
|
-
}
|
|
57
|
-
if (Object.hasOwn(value, "finalization")) {
|
|
58
|
-
errors.push(`${pathLabel}.finalization is not supported; acceptance contracts always run the self-review loop.`);
|
|
59
|
-
}
|
|
60
|
-
if (Object.hasOwn(value, "reason")) {
|
|
61
|
-
errors.push(`${pathLabel}.reason is not supported because acceptance is disabled by omitting the field.`);
|
|
62
|
-
}
|
|
63
|
-
for (const key of Object.keys(value)) {
|
|
64
|
-
if (!ACCEPTANCE_KEYS.has(key) && !REMOVED_ACCEPTANCE_KEYS.has(key)) errors.push(`${pathLabel}.${key} is not supported.`);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (value.criteria !== undefined) {
|
|
68
|
-
if (!Array.isArray(value.criteria)) {
|
|
69
|
-
errors.push(`${pathLabel}.criteria must be an array.`);
|
|
70
|
-
} else {
|
|
71
|
-
for (const [index, criterion] of value.criteria.entries()) {
|
|
72
|
-
if (typeof criterion === "string") {
|
|
73
|
-
if (!criterion.trim()) errors.push(`${pathLabel}.criteria[${index}] must not be empty.`);
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
if (!criterion || typeof criterion !== "object" || Array.isArray(criterion)) {
|
|
77
|
-
errors.push(`${pathLabel}.criteria[${index}] must be a string or object.`);
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
const item = criterion as Record<string, unknown>;
|
|
81
|
-
if (typeof item.id !== "string" || !item.id.trim()) errors.push(`${pathLabel}.criteria[${index}].id is required.`);
|
|
82
|
-
if (typeof item.must !== "string" || !item.must.trim()) errors.push(`${pathLabel}.criteria[${index}].must is required.`);
|
|
83
|
-
if (item.evidence !== undefined && !Array.isArray(item.evidence)) errors.push(`${pathLabel}.criteria[${index}].evidence must be an array.`);
|
|
84
|
-
if (Array.isArray(item.evidence)) {
|
|
85
|
-
for (const [evidenceIndex, evidence] of item.evidence.entries()) {
|
|
86
|
-
if (typeof evidence !== "string" || !VALID_EVIDENCE.has(evidence as AcceptanceEvidenceKind)) {
|
|
87
|
-
errors.push(`${pathLabel}.criteria[${index}].evidence[${evidenceIndex}] is not a supported evidence kind.`);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
if (item.severity !== undefined && item.severity !== "required" && item.severity !== "recommended") {
|
|
92
|
-
errors.push(`${pathLabel}.criteria[${index}].severity must be required or recommended.`);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (Array.isArray(value.evidence)) {
|
|
99
|
-
for (const [index, item] of value.evidence.entries()) {
|
|
100
|
-
if (typeof item !== "string" || !VALID_EVIDENCE.has(item as AcceptanceEvidenceKind)) {
|
|
101
|
-
errors.push(`${pathLabel}.evidence[${index}] is not a supported evidence kind.`);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
} else if (value.evidence !== undefined) {
|
|
105
|
-
errors.push(`${pathLabel}.evidence must be an array.`);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (value.verify !== undefined && !Array.isArray(value.verify)) errors.push(`${pathLabel}.verify must be an array.`);
|
|
109
|
-
if (Array.isArray(value.verify)) {
|
|
110
|
-
for (const [index, command] of value.verify.entries()) {
|
|
111
|
-
if (!command || typeof command !== "object" || Array.isArray(command)) {
|
|
112
|
-
errors.push(`${pathLabel}.verify[${index}] must be an object.`);
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
const cmd = command as Record<string, unknown>;
|
|
116
|
-
if (typeof cmd.id !== "string" || !cmd.id.trim()) errors.push(`${pathLabel}.verify[${index}].id is required.`);
|
|
117
|
-
if (typeof cmd.command !== "string" || !cmd.command.trim()) errors.push(`${pathLabel}.verify[${index}].command is required.`);
|
|
118
|
-
if (cmd.timeoutMs !== undefined && (!Number.isInteger(cmd.timeoutMs) || Number(cmd.timeoutMs) <= 0)) {
|
|
119
|
-
errors.push(`${pathLabel}.verify[${index}].timeoutMs must be a positive integer.`);
|
|
120
|
-
}
|
|
121
|
-
if (cmd.cwd !== undefined && typeof cmd.cwd !== "string") errors.push(`${pathLabel}.verify[${index}].cwd must be a string.`);
|
|
122
|
-
if (cmd.env !== undefined) {
|
|
123
|
-
if (!cmd.env || typeof cmd.env !== "object" || Array.isArray(cmd.env)) {
|
|
124
|
-
errors.push(`${pathLabel}.verify[${index}].env must be an object with string values.`);
|
|
125
|
-
} else {
|
|
126
|
-
for (const [key, envValue] of Object.entries(cmd.env as Record<string, unknown>)) {
|
|
127
|
-
if (typeof envValue !== "string") errors.push(`${pathLabel}.verify[${index}].env.${key} must be a string.`);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
if (cmd.allowFailure !== undefined && typeof cmd.allowFailure !== "boolean") errors.push(`${pathLabel}.verify[${index}].allowFailure must be a boolean.`);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (value.review !== undefined) {
|
|
136
|
-
if (!value.review || typeof value.review !== "object" || Array.isArray(value.review)) {
|
|
137
|
-
errors.push(`${pathLabel}.review must be an object.`);
|
|
138
|
-
} else {
|
|
139
|
-
const review = value.review as Record<string, unknown>;
|
|
140
|
-
if (review.agent !== undefined && typeof review.agent !== "string") errors.push(`${pathLabel}.review.agent must be a string.`);
|
|
141
|
-
if (review.focus !== undefined && typeof review.focus !== "string") errors.push(`${pathLabel}.review.focus must be a string.`);
|
|
142
|
-
if (review.required !== undefined && typeof review.required !== "boolean") errors.push(`${pathLabel}.review.required must be a boolean.`);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (value.stopRules !== undefined) {
|
|
147
|
-
if (!Array.isArray(value.stopRules)) {
|
|
148
|
-
errors.push(`${pathLabel}.stopRules must be an array.`);
|
|
149
|
-
} else {
|
|
150
|
-
for (const [index, rule] of value.stopRules.entries()) {
|
|
151
|
-
if (typeof rule !== "string" || !rule.trim()) errors.push(`${pathLabel}.stopRules[${index}] must be a non-empty string.`);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (value.maxFinalizationTurns !== undefined) {
|
|
157
|
-
if (!Number.isInteger(value.maxFinalizationTurns) || Number(value.maxFinalizationTurns) < 1 || Number(value.maxFinalizationTurns) > MAX_FINALIZATION_TURNS) {
|
|
158
|
-
errors.push(`${pathLabel}.maxFinalizationTurns must be an integer from 1 to ${MAX_FINALIZATION_TURNS}.`);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const hasContract = hasArrayItems(value.criteria)
|
|
163
|
-
|| hasArrayItems(value.evidence)
|
|
164
|
-
|| hasArrayItems(value.verify)
|
|
165
|
-
|| value.review !== undefined
|
|
166
|
-
|| hasArrayItems(value.stopRules);
|
|
167
|
-
if (!hasContract) {
|
|
168
|
-
errors.push(`${pathLabel} must include at least one of criteria, evidence, verify, review, or stopRules.`);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
return errors;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function normalizeCriteria(criteria: AcceptanceConfig["criteria"], evidence: AcceptanceEvidenceKind[]): ResolvedAcceptanceGate[] {
|
|
175
|
-
return (criteria ?? []).map((criterion, index) => {
|
|
176
|
-
if (typeof criterion === "string") {
|
|
177
|
-
return { id: `criterion-${index + 1}`, must: criterion, evidence, severity: "required" as const };
|
|
178
|
-
}
|
|
179
|
-
return {
|
|
180
|
-
id: criterion.id.trim(),
|
|
181
|
-
must: criterion.must,
|
|
182
|
-
evidence: criterion.evidence?.filter((item) => VALID_EVIDENCE.has(item)) ?? evidence,
|
|
183
|
-
severity: criterion.severity ?? "required",
|
|
184
|
-
};
|
|
185
|
-
}).filter((criterion) => criterion.must.trim());
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function deriveAcceptanceLevel(config: AcceptanceConfig): AcceptanceProvenanceLevel {
|
|
189
|
-
if (config.review) return "reviewed";
|
|
190
|
-
if ((config.verify?.length ?? 0) > 0) return "verified";
|
|
191
|
-
return "checked";
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
export function resolveEffectiveAcceptance(input: {
|
|
195
|
-
explicit?: AcceptanceInput;
|
|
196
|
-
agentName: string;
|
|
197
|
-
task?: string;
|
|
198
|
-
mode?: SubagentRunMode;
|
|
199
|
-
async?: boolean;
|
|
200
|
-
dynamic?: boolean;
|
|
201
|
-
dynamicGroup?: boolean;
|
|
202
|
-
}): ResolvedAcceptanceConfig {
|
|
203
|
-
if (input.explicit === undefined) {
|
|
204
|
-
return {
|
|
205
|
-
level: "none",
|
|
206
|
-
explicit: false,
|
|
207
|
-
inferredReason: ["acceptance not configured"],
|
|
208
|
-
criteria: [],
|
|
209
|
-
evidence: [],
|
|
210
|
-
verify: [],
|
|
211
|
-
stopRules: [],
|
|
212
|
-
finalization: { mode: "none", maxTurns: 0 },
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const validationErrors = validateAcceptanceInput(input.explicit);
|
|
217
|
-
if (validationErrors.length > 0) throw new Error(validationErrors.join(" "));
|
|
218
|
-
const explicit = input.explicit;
|
|
219
|
-
const evidence = [...new Set(explicit.evidence ?? [])];
|
|
220
|
-
const criteria = normalizeCriteria(explicit.criteria, evidence);
|
|
221
|
-
const verify = explicit.verify ?? [];
|
|
222
|
-
const stopRules = explicit.stopRules ?? [];
|
|
223
|
-
return {
|
|
224
|
-
level: deriveAcceptanceLevel(explicit),
|
|
225
|
-
explicit: true,
|
|
226
|
-
inferredReason: ["explicit acceptance contract"],
|
|
227
|
-
criteria,
|
|
228
|
-
evidence,
|
|
229
|
-
verify,
|
|
230
|
-
...(explicit.review ? { review: explicit.review } : {}),
|
|
231
|
-
stopRules,
|
|
232
|
-
finalization: { mode: "self-review-loop", maxTurns: explicit.maxFinalizationTurns ?? DEFAULT_FINALIZATION_MAX_TURNS },
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
export function shouldRunAcceptanceFinalization(acceptance: ResolvedAcceptanceConfig): boolean {
|
|
237
|
-
return acceptance.explicit && acceptance.finalization.mode === "self-review-loop" && acceptance.finalization.maxTurns > 0;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
export function acceptanceSelfReviewConfig(acceptance: ResolvedAcceptanceConfig): ResolvedAcceptanceConfig {
|
|
241
|
-
if (!acceptance.review && acceptance.verify.length === 0) return acceptance;
|
|
242
|
-
const { review: _review, verify: _verify, ...selfReview } = acceptance;
|
|
243
|
-
return {
|
|
244
|
-
...selfReview,
|
|
245
|
-
level: "checked",
|
|
246
|
-
verify: [],
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig): string {
|
|
251
|
-
if (acceptance.level === "none") return "";
|
|
252
|
-
const lines = [
|
|
253
|
-
"",
|
|
254
|
-
"## Acceptance Contract",
|
|
255
|
-
"Completion is not accepted from prose alone. End the initial response with a structured acceptance report.",
|
|
256
|
-
"After the initial response, the runtime will continue this same session for a bounded self-review/repair loop before accepting the run.",
|
|
257
|
-
"",
|
|
258
|
-
"Criteria:",
|
|
259
|
-
...(acceptance.criteria.length ? acceptance.criteria.map((criterion) => `- ${criterion.id}: ${criterion.must}`) : ["- No explicit criteria were configured; satisfy the requested task and the required evidence/checks below."]),
|
|
260
|
-
"",
|
|
261
|
-
`Required evidence: ${acceptance.evidence.join(", ") || "none explicitly requested"}`,
|
|
262
|
-
];
|
|
263
|
-
if (acceptance.verify.length > 0) {
|
|
264
|
-
lines.push("", "Runtime verification commands configured by parent:");
|
|
265
|
-
for (const command of acceptance.verify) lines.push(`- ${command.id}: ${command.command}`);
|
|
266
|
-
}
|
|
267
|
-
if (acceptance.review) {
|
|
268
|
-
lines.push("", `Independent review gate: ${acceptance.review.required === false ? "optional" : "required"}${acceptance.review.agent ? ` by ${acceptance.review.agent}` : ""}.`);
|
|
269
|
-
if (acceptance.review.focus) lines.push(`Review focus: ${acceptance.review.focus}`);
|
|
270
|
-
}
|
|
271
|
-
if (acceptance.stopRules.length > 0) {
|
|
272
|
-
lines.push("", "Stop rules:", ...acceptance.stopRules.map((rule) => `- ${rule}`));
|
|
273
|
-
}
|
|
274
|
-
lines.push(
|
|
275
|
-
"",
|
|
276
|
-
"Finish with a fenced JSON block tagged `acceptance-report` in this shape:",
|
|
277
|
-
"```acceptance-report",
|
|
278
|
-
JSON.stringify({
|
|
279
|
-
criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "specific proof" }],
|
|
280
|
-
changedFiles: [],
|
|
281
|
-
testsAddedOrUpdated: [],
|
|
282
|
-
commandsRun: [{ command: "command", result: "passed", summary: "short result" }],
|
|
283
|
-
validationOutput: [],
|
|
284
|
-
residualRisks: [],
|
|
285
|
-
noStagedFiles: true,
|
|
286
|
-
notes: "anything else the parent should know",
|
|
287
|
-
}, null, 2),
|
|
288
|
-
"```",
|
|
289
|
-
);
|
|
290
|
-
return lines.join("\n");
|
|
291
|
-
}
|