pi-subagents 0.60.0 → 0.61.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 +32 -0
- package/docs/agents.md +2 -2
- package/docs/configuration.md +9 -5
- package/docs/extension-api.md +14 -7
- package/docs/models.md +1 -1
- package/docs/observability.md +1 -1
- package/docs/tool-reference.md +12 -3
- package/docs/workflows.md +14 -13
- package/install.mjs +2 -1
- package/package.json +1 -1
- package/skills/pi-subagents/SKILL.md +6 -4
- package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
- package/skills/pi-subagents/references/execution-controls.md +42 -11
- package/skills/pi-subagents/references/multi-lane-orchestration.md +1 -1
- package/skills/pi-subagents/references/prompting-and-roles.md +4 -4
- package/skills/pi-subagents/references/review-and-validation.md +1 -1
- package/src/agents/agent-management.ts +102 -63
- package/src/agents/agents.ts +527 -221
- package/src/api/background-work.ts +7 -2
- package/src/api/external-runs.ts +67 -4
- package/src/api/preflight.ts +13 -8
- package/src/api/shared-types.ts +1 -0
- package/src/extension/index.ts +7 -4
- package/src/extension/public-execution.ts +47 -4
- package/src/extension/rpc.ts +62 -4
- package/src/extension/schemas.ts +11 -7
- package/src/extension/tool-description.ts +14 -18
- package/src/runs/background/async-execution.ts +51 -35
- package/src/runs/background/async-job-tracker.ts +62 -3
- package/src/runs/background/async-resume.ts +3 -1
- package/src/runs/background/async-status.ts +59 -9
- package/src/runs/background/auto-drain.ts +1 -1
- package/src/runs/background/fleet-view.ts +1 -1
- package/src/runs/background/result-watcher.ts +1 -1
- package/src/runs/background/resume-guidance.ts +1 -1
- package/src/runs/background/run-status.ts +2 -2
- package/src/runs/background/subagent-runner.ts +1 -2
- package/src/runs/background/subagent-wait.ts +20 -21
- package/src/runs/background/wait-completions.ts +1 -1
- package/src/runs/background/wait-tool.ts +24 -18
- package/src/runs/foreground/execution.ts +61 -2
- package/src/runs/foreground/subagent-executor.ts +178 -58
- package/src/runs/shared/acceptance.ts +43 -18
- package/src/runs/shared/host-step-status.ts +1 -0
- package/src/runs/shared/model-fallback.ts +61 -17
- package/src/runs/shared/permissions.ts +1 -1
- package/src/runs/shared/tool-timeout.ts +1 -1
- package/src/runs/shared/workflow-graph.ts +3 -2
- package/src/shared/types.ts +40 -3
- package/src/shared/workflow-child-permit.ts +91 -0
- package/src/slash/prompt-template-bridge.ts +37 -1
- package/src/slash/slash-commands.ts +18 -26
- package/src/tui/render.ts +95 -52
- package/src/workflows/scripted-workflow.ts +153 -4
- package/src/workflows/workflow-child-summary.ts +1 -1
- package/src/workflows/workflow-receipt.ts +41 -4
- package/src/workflows/workflow-resources.ts +150 -0
|
@@ -678,7 +678,15 @@ function parseAcceptanceReportBody(body: string): { report?: AcceptanceReport; e
|
|
|
678
678
|
return validateAcceptanceReport(parseReportJson(body));
|
|
679
679
|
}
|
|
680
680
|
|
|
681
|
-
|
|
681
|
+
interface AcceptanceReportParseResult {
|
|
682
|
+
report?: AcceptanceReport;
|
|
683
|
+
error?: string;
|
|
684
|
+
/** True when an acceptance-report signal was found but its envelope was invalid. */
|
|
685
|
+
malformed?: boolean;
|
|
686
|
+
sourcePath?: string;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function parseUnterminatedAcceptanceReportFence(output: string): AcceptanceReportParseResult {
|
|
682
690
|
const opener = /```acceptance[-_]report\b[^\n]*\n/gi.exec(output);
|
|
683
691
|
if (!opener) return {};
|
|
684
692
|
const bodyStart = opener.index + opener[0].length;
|
|
@@ -687,13 +695,13 @@ function parseUnterminatedAcceptanceReportFence(output: string): { report?: Acce
|
|
|
687
695
|
const validation = validateAcceptanceReport(JSON.parse(output.slice(bodyStart).trim()) as unknown);
|
|
688
696
|
return validation.report
|
|
689
697
|
? { report: validation.report }
|
|
690
|
-
: { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}
|
|
698
|
+
: { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}`, malformed: true };
|
|
691
699
|
} catch (error) {
|
|
692
|
-
return { error: `Failed to parse acceptance-report: ${error instanceof Error ? error.message : String(error)}
|
|
700
|
+
return { error: `Failed to parse acceptance-report: ${error instanceof Error ? error.message : String(error)}`, malformed: true };
|
|
693
701
|
}
|
|
694
702
|
}
|
|
695
703
|
|
|
696
|
-
function parseGenericJsonAcceptanceReportBody(body: string):
|
|
704
|
+
function parseGenericJsonAcceptanceReportBody(body: string): AcceptanceReportParseResult {
|
|
697
705
|
const parsed = parseReportJson(body);
|
|
698
706
|
const normalized = normalizeAcceptanceReportValue(parsed);
|
|
699
707
|
const hasCriteriaMarker = normalized.value !== null
|
|
@@ -704,12 +712,12 @@ function parseGenericJsonAcceptanceReportBody(body: string): { report?: Acceptan
|
|
|
704
712
|
const validation = validateAcceptanceReport(parsed);
|
|
705
713
|
return validation.report
|
|
706
714
|
? { report: validation.report }
|
|
707
|
-
: { error: `Invalid acceptance-report: ${validation.errors.join("; ")}
|
|
715
|
+
: { error: `Invalid acceptance-report: ${validation.errors.join("; ")}`, malformed: true };
|
|
708
716
|
}
|
|
709
717
|
|
|
710
718
|
export const ACCEPTANCE_REPORT_NOT_FOUND = "Structured acceptance report not found.";
|
|
711
719
|
|
|
712
|
-
export function parseAcceptanceReport(output: string):
|
|
720
|
+
export function parseAcceptanceReport(output: string): AcceptanceReportParseResult {
|
|
713
721
|
const explicitFencePresent = /```acceptance[-_]report\b/i.test(output);
|
|
714
722
|
const fenced = fencedBlocks(output, "acceptance[-_]report");
|
|
715
723
|
const parseErrors: string[] = [];
|
|
@@ -722,17 +730,17 @@ export function parseAcceptanceReport(output: string): { report?: AcceptanceRepo
|
|
|
722
730
|
parseErrors.push(error instanceof Error ? error.message : String(error));
|
|
723
731
|
}
|
|
724
732
|
}
|
|
725
|
-
if (parseErrors.length > 0) return { error: `Failed to parse acceptance-report: ${parseErrors.join("; ")}
|
|
733
|
+
if (parseErrors.length > 0) return { error: `Failed to parse acceptance-report: ${parseErrors.join("; ")}`, malformed: true };
|
|
726
734
|
if (explicitFencePresent) {
|
|
727
735
|
const recovered = parseUnterminatedAcceptanceReportFence(output);
|
|
728
736
|
if (recovered.report || recovered.error) return recovered;
|
|
729
|
-
return { error: "Failed to parse acceptance-report: Empty or unterminated acceptance-report fence." };
|
|
737
|
+
return { error: "Failed to parse acceptance-report: Empty or unterminated acceptance-report fence.", malformed: true };
|
|
730
738
|
}
|
|
731
739
|
for (const body of fencedBlocks(output, "(?:json|jsonc|json5)")) {
|
|
732
740
|
try {
|
|
733
741
|
const parsed = parseGenericJsonAcceptanceReportBody(body);
|
|
734
742
|
if (parsed.report) return { report: parsed.report };
|
|
735
|
-
if (parsed.error) return { error: `Failed to parse acceptance-report: ${parsed.error}
|
|
743
|
+
if (parsed.error) return { error: `Failed to parse acceptance-report: ${parsed.error}`, malformed: parsed.malformed };
|
|
736
744
|
} catch {
|
|
737
745
|
// Ignore unrelated malformed generic JSON. A recognizable report shape
|
|
738
746
|
// returns exact validation errors above instead of being mistaken for prose.
|
|
@@ -742,20 +750,20 @@ export function parseAcceptanceReport(output: string): { report?: AcceptanceRepo
|
|
|
742
750
|
if (markerIndex !== -1) {
|
|
743
751
|
const jsonStart = output.indexOf("{", markerIndex);
|
|
744
752
|
if (jsonStart === -1) {
|
|
745
|
-
return { error: "Failed to parse acceptance-report: Expected a JSON object after ACCEPTANCE_REPORT:." };
|
|
753
|
+
return { error: "Failed to parse acceptance-report: Expected a JSON object after ACCEPTANCE_REPORT:.", malformed: true };
|
|
746
754
|
}
|
|
747
755
|
const json = extractBalancedJson(output, jsonStart);
|
|
748
756
|
if (!json) {
|
|
749
|
-
return { error: "Failed to parse acceptance-report: Unterminated JSON object after ACCEPTANCE_REPORT:." };
|
|
757
|
+
return { error: "Failed to parse acceptance-report: Unterminated JSON object after ACCEPTANCE_REPORT:.", malformed: true };
|
|
750
758
|
}
|
|
751
759
|
try {
|
|
752
760
|
const parsed = JSON.parse(json) as unknown;
|
|
753
761
|
const validation = validateAcceptanceReport(parsed);
|
|
754
762
|
if (validation.report) return { report: validation.report };
|
|
755
|
-
return { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}
|
|
763
|
+
return { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}`, malformed: true };
|
|
756
764
|
} catch (error) {
|
|
757
765
|
const message = error instanceof Error ? error.message : String(error);
|
|
758
|
-
return { error: `Failed to parse acceptance-report: ${message}
|
|
766
|
+
return { error: `Failed to parse acceptance-report: ${message}`, malformed: true };
|
|
759
767
|
}
|
|
760
768
|
}
|
|
761
769
|
return { error: ACCEPTANCE_REPORT_NOT_FOUND };
|
|
@@ -764,14 +772,18 @@ export function parseAcceptanceReport(output: string): { report?: AcceptanceRepo
|
|
|
764
772
|
function parseAcceptanceReportSources(
|
|
765
773
|
output: string,
|
|
766
774
|
fileOutput: { content: string; path: string; authoritative?: boolean } | undefined,
|
|
767
|
-
):
|
|
775
|
+
): AcceptanceReportParseResult {
|
|
768
776
|
const fromText = () => parseAcceptanceReport(output);
|
|
769
777
|
const fromFile = () => {
|
|
770
778
|
if (!fileOutput) return { error: ACCEPTANCE_REPORT_NOT_FOUND };
|
|
771
779
|
const parsed = parseAcceptanceReport(fileOutput.content);
|
|
772
780
|
return parsed.report || parsed.error === ACCEPTANCE_REPORT_NOT_FOUND
|
|
773
781
|
? parsed
|
|
774
|
-
: {
|
|
782
|
+
: {
|
|
783
|
+
...parsed,
|
|
784
|
+
error: `${parsed.error} (in configured output ${fileOutput.path})`,
|
|
785
|
+
sourcePath: fileOutput.path,
|
|
786
|
+
};
|
|
775
787
|
};
|
|
776
788
|
const [primary, secondary] = fileOutput?.authoritative ? [fromFile, fromText] : [fromText, fromFile];
|
|
777
789
|
const first = primary();
|
|
@@ -1272,7 +1284,7 @@ export async function evaluateAcceptance(input: {
|
|
|
1272
1284
|
* be misattributed). Searched for the acceptance report; searched before
|
|
1273
1285
|
* the assistant output when `authoritative` (outputMode "file-only").
|
|
1274
1286
|
*/
|
|
1275
|
-
fileOutput?: { content: string; path: string; authoritative?: boolean };
|
|
1287
|
+
fileOutput?: { content: string; path: string; authoritative?: boolean; durable?: boolean };
|
|
1276
1288
|
report?: AcceptanceReport;
|
|
1277
1289
|
reportError?: string;
|
|
1278
1290
|
reviewResult?: AcceptanceReviewResult;
|
|
@@ -1296,16 +1308,25 @@ export async function evaluateAcceptance(input: {
|
|
|
1296
1308
|
};
|
|
1297
1309
|
if (acceptance.level === "none") return ledger;
|
|
1298
1310
|
|
|
1299
|
-
const parsed = input.reportError
|
|
1311
|
+
const parsed: AcceptanceReportParseResult = input.reportError
|
|
1300
1312
|
? { error: input.reportError }
|
|
1301
1313
|
: input.report
|
|
1302
1314
|
? (() => {
|
|
1303
1315
|
const validation = validateAcceptanceReport(input.report);
|
|
1304
1316
|
return validation.report
|
|
1305
1317
|
? { report: validation.report }
|
|
1306
|
-
: { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}
|
|
1318
|
+
: { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}`, malformed: true };
|
|
1307
1319
|
})()
|
|
1308
1320
|
: parseAcceptanceReportSources(input.output, input.fileOutput);
|
|
1321
|
+
const durableFileOutput = input.fileOutput?.authoritative && input.fileOutput.durable ? input.fileOutput : undefined;
|
|
1322
|
+
if (parsed.malformed && parsed.sourcePath && durableFileOutput) {
|
|
1323
|
+
ledger.recovery = {
|
|
1324
|
+
status: "available-for-review",
|
|
1325
|
+
reason: "acceptance-metadata-rejected",
|
|
1326
|
+
reportPath: parsed.sourcePath,
|
|
1327
|
+
reportHash: hash(durableFileOutput.content),
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1309
1330
|
const needsReport = acceptanceRequiresChildReport(acceptance);
|
|
1310
1331
|
if (parsed.report) {
|
|
1311
1332
|
ledger.childReport = parsed.report;
|
|
@@ -1314,6 +1335,10 @@ export async function evaluateAcceptance(input: {
|
|
|
1314
1335
|
} else if (!input.reportOptional || needsReport || parsed.error !== ACCEPTANCE_REPORT_NOT_FOUND) {
|
|
1315
1336
|
ledger.childReportParseError = parsed.error;
|
|
1316
1337
|
ledger.runtimeChecks.push({ id: "attestation", status: "failed", message: parsed.error ?? "Structured acceptance report missing." });
|
|
1338
|
+
if (ledger.recovery) {
|
|
1339
|
+
ledger.status = "rejected";
|
|
1340
|
+
ledger.evidenceStatus = "rejected";
|
|
1341
|
+
}
|
|
1317
1342
|
if (!input.reportOptional) {
|
|
1318
1343
|
ledger.status = "rejected";
|
|
1319
1344
|
ledger.evidenceStatus = "rejected";
|
|
@@ -144,6 +144,7 @@ export function validHostStepNodes(graph: WorkflowGraphSnapshot | undefined): Ho
|
|
|
144
144
|
export function assertWorkflowGraphHostSteps(graph: WorkflowGraphSnapshot | undefined, source = "status", expectedRunId?: string): void {
|
|
145
145
|
if (!graph) return;
|
|
146
146
|
if (expectedRunId !== undefined && graph.runId !== expectedRunId) throw new Error(`Invalid host step '${source}': workflowGraph.runId does not match the status run id.`);
|
|
147
|
+
if (!Array.isArray(graph.nodes)) throw new Error(`Invalid host step '${source}.workflowGraph': nodes must be an array.`);
|
|
147
148
|
const hostStepCount = graph.nodes.filter((node) => node.kind === "host-step").length;
|
|
148
149
|
if (hostStepCount > HOST_STEP_MAX_COUNT) throw new Error(`Invalid host step '${source}': workflowGraph contains more than ${HOST_STEP_MAX_COUNT} host steps.`);
|
|
149
150
|
const hostSteps: HostStepNodeV1[] = [];
|
|
@@ -321,15 +321,23 @@ export function resolveSubagentModelOverride(
|
|
|
321
321
|
const explicit = trimmed && trimmed !== INHERIT_MODEL ? trimmed : undefined;
|
|
322
322
|
if (!parentModel) throwForUnresolvedEnforcedInheritScope(options?.scope, explicit === undefined || options?.source === "inherited");
|
|
323
323
|
let resolved: string | undefined;
|
|
324
|
+
let resolvedFromRegistry = explicit === undefined;
|
|
324
325
|
if (explicit === undefined) {
|
|
325
326
|
resolved = parentModel ? `${parentModel.provider}/${parentModel.id}` : undefined;
|
|
326
327
|
} else {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
328
|
+
const candidate = resolveSubagentModelCandidate(explicit, availableModels, preferredProvider);
|
|
329
|
+
if (options?.source === "explicit") {
|
|
330
|
+
resolved = candidate ?? resolveRequiredSubagentModelCandidate(explicit, availableModels, preferredProvider);
|
|
331
|
+
throwForExplicitModelExclusion(resolved);
|
|
332
|
+
resolvedFromRegistry = true;
|
|
333
|
+
} else if (candidate) {
|
|
334
|
+
resolved = candidate;
|
|
335
|
+
resolvedFromRegistry = true;
|
|
336
|
+
} else {
|
|
337
|
+
resolved = explicit;
|
|
338
|
+
}
|
|
331
339
|
}
|
|
332
|
-
if (resolved && options?.scope) {
|
|
340
|
+
if (resolved && options?.scope && resolvedFromRegistry) {
|
|
333
341
|
const source: ModelSource = explicit === undefined ? "inherited" : (options.source ?? "inherited");
|
|
334
342
|
enforceModelScopes(resolved, options.scope, source, options.onWarn);
|
|
335
343
|
}
|
|
@@ -362,12 +370,33 @@ export function resolveEffectiveSubagentModel(
|
|
|
362
370
|
);
|
|
363
371
|
}
|
|
364
372
|
|
|
373
|
+
export type ModelOrigin = ModelSource | "configured";
|
|
374
|
+
|
|
365
375
|
export interface BuildModelCandidatesOptions {
|
|
366
376
|
/** Fallback models warn by default and throw when strict scope enforcement is enabled. */
|
|
367
377
|
scope?: ModelScopeCheckRule | ModelScopeCheckRule[];
|
|
368
378
|
onWarn?: (violation: ModelScopeViolation) => void;
|
|
369
379
|
/** The primary model came from the running parent session, not configuration. */
|
|
370
380
|
primaryModelFromParent?: boolean;
|
|
381
|
+
/** How the primary model was selected. Explicit stays strict and does not rotate to fallbacks. */
|
|
382
|
+
origin?: ModelOrigin;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const ZERO_USABLE_MODEL_CANDIDATES_ERROR =
|
|
386
|
+
"No usable subagent models remain after registry, scope, and cached-exclusion filtering.";
|
|
387
|
+
|
|
388
|
+
export function resolveModelOrigin(input: {
|
|
389
|
+
explicitModel?: string | boolean;
|
|
390
|
+
agentModel?: string | boolean;
|
|
391
|
+
parentModel?: ParentModel;
|
|
392
|
+
fromParent?: boolean;
|
|
393
|
+
storedOrigin?: ModelOrigin;
|
|
394
|
+
}): ModelOrigin {
|
|
395
|
+
if (input.storedOrigin) return input.storedOrigin;
|
|
396
|
+
if (input.fromParent) return "inherited";
|
|
397
|
+
if (inheritsParentModel(input.explicitModel, input.agentModel, input.parentModel)) return "inherited";
|
|
398
|
+
const trimmed = typeof input.explicitModel === "string" ? input.explicitModel.trim() : "";
|
|
399
|
+
return trimmed && trimmed !== INHERIT_MODEL ? "explicit" : "configured";
|
|
371
400
|
}
|
|
372
401
|
|
|
373
402
|
export function inheritsParentModel(
|
|
@@ -388,36 +417,51 @@ export function buildModelCandidates(
|
|
|
388
417
|
options?: BuildModelCandidatesOptions,
|
|
389
418
|
): string[] {
|
|
390
419
|
if (!primaryModel) throwForUnresolvedEnforcedInheritScope(options?.scope, true);
|
|
420
|
+
const origin = options?.origin ?? (options?.primaryModelFromParent ? "inherited" : "configured");
|
|
421
|
+
const scopes = configuredScopes(options?.scope);
|
|
422
|
+
const warnCachedExclusion = (candidate: string, exclusion: NonNullable<ReturnType<typeof findModelExclusion>>) => {
|
|
423
|
+
const reason = redactSecretValues((exclusion.reason ?? "runtime-failure").replace(/[\u0000-\u001f\u007f]+/g, " ")).slice(0, 240);
|
|
424
|
+
console.warn(`[pi-subagents] Skipping model '${candidate}' due to a cached exclusion (reason: ${reason}; expires: ${new Date(exclusion.expiresAt).toISOString()}).`);
|
|
425
|
+
};
|
|
426
|
+
if (origin === "explicit" && primaryModel) {
|
|
427
|
+
const normalized = resolveRequiredSubagentModelCandidate(primaryModel.trim(), availableModels, preferredProvider);
|
|
428
|
+
throwForExplicitModelExclusion(normalized);
|
|
429
|
+
enforceModelScopes(normalized, scopes, "explicit", options?.onWarn);
|
|
430
|
+
primaryModel = normalized;
|
|
431
|
+
}
|
|
391
432
|
const seen = new Set<string>();
|
|
392
433
|
const candidates: string[] = [];
|
|
393
434
|
const rawCandidates = [primaryModel, ...(fallbackModels ?? [])];
|
|
435
|
+
let skippedPrimary: string | undefined;
|
|
394
436
|
for (let index = 0; index < rawCandidates.length; index++) {
|
|
395
437
|
const raw = rawCandidates[index];
|
|
396
438
|
if (!raw) continue;
|
|
397
439
|
const model = raw.trim();
|
|
398
|
-
const normalized = index === 0
|
|
399
|
-
?
|
|
400
|
-
? model
|
|
401
|
-
: resolveRequiredSubagentModelCandidate(model, availableModels, preferredProvider)
|
|
440
|
+
const normalized = index === 0 && (origin === "inherited" || origin === "explicit" || options?.primaryModelFromParent)
|
|
441
|
+
? model
|
|
402
442
|
: resolveSubagentModelCandidate(model, availableModels, preferredProvider);
|
|
403
443
|
if (!normalized) {
|
|
404
|
-
|
|
444
|
+
if (index === 0) skippedPrimary = model;
|
|
445
|
+
else console.warn(`[pi-subagents] Skipping fallback model '${model}' because it is unavailable in this environment.`);
|
|
405
446
|
continue;
|
|
406
447
|
}
|
|
407
448
|
if (seen.has(normalized)) continue;
|
|
408
|
-
const scopes = configuredScopes(options?.scope);
|
|
409
449
|
if (index > 0 || scopes.some((scope) => scope.enforce === true && scope.strict === true)) {
|
|
410
450
|
enforceModelScopes(normalized, scopes, "inherited", options?.onWarn);
|
|
411
451
|
}
|
|
412
452
|
seen.add(normalized);
|
|
413
453
|
candidates.push(normalized);
|
|
414
454
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
}
|
|
455
|
+
const resolved = filterFallbackCandidates(candidates, { onExcluded: warnCachedExclusion });
|
|
456
|
+
if (resolved.length === 0) {
|
|
457
|
+
if (skippedPrimary) resolveRequiredSubagentModelCandidate(skippedPrimary, availableModels, preferredProvider);
|
|
458
|
+
if (candidates.length > 0) throw new Error(ZERO_USABLE_MODEL_CANDIDATES_ERROR);
|
|
459
|
+
return resolved;
|
|
460
|
+
}
|
|
461
|
+
if (skippedPrimary) {
|
|
462
|
+
console.warn(`[pi-subagents] Skipping primary model '${skippedPrimary}' because it is unavailable in this environment.`);
|
|
463
|
+
}
|
|
464
|
+
return resolved;
|
|
421
465
|
}
|
|
422
466
|
|
|
423
467
|
const RETRYABLE_MODEL_FAILURE_PATTERNS = [
|
|
@@ -7,7 +7,7 @@ export interface PermissionConfig { rules?: PermissionRules }
|
|
|
7
7
|
|
|
8
8
|
export const PERMISSION_POLICY_ENV = "PI_SUBAGENT_PERMISSION_POLICY";
|
|
9
9
|
export const PERMISSION_AUDIT_PATH_ENV = "PI_SUBAGENT_PERMISSION_AUDIT_PATH";
|
|
10
|
-
const INTERNAL_TOOLS = new Set(["contact_supervisor", "intercom", "subagent_wait", "structured_output"]);
|
|
10
|
+
const INTERNAL_TOOLS = new Set(["contact_supervisor", "intercom", "bg_wait", "subagent_wait", "structured_output"]);
|
|
11
11
|
const DECISIONS = new Set<PermissionDecision>(["allow", "ask", "deny"]);
|
|
12
12
|
const MAX_POLICY_BYTES = 16 * 1024;
|
|
13
13
|
const MAX_PREVIEW_BYTES = 2048;
|
|
@@ -16,7 +16,7 @@ export const DEFAULT_FAST_TOOL_TIMEOUT_TOOLS = new Set([
|
|
|
16
16
|
]);
|
|
17
17
|
|
|
18
18
|
/** Tools whose normal job can be to wait for a person or another run. */
|
|
19
|
-
export const TOOL_TIMEOUT_EXEMPT_TOOLS = new Set(["contact_supervisor", "intercom", "subagent_wait"]);
|
|
19
|
+
export const TOOL_TIMEOUT_EXEMPT_TOOLS = new Set(["contact_supervisor", "intercom", "bg_wait", "subagent_wait"]);
|
|
20
20
|
|
|
21
21
|
// Backward-compatible export name for existing callers/tests.
|
|
22
22
|
export const TOOL_TIMEOUT_ALLOWLIST = TOOL_TIMEOUT_EXEMPT_TOOLS;
|
|
@@ -15,7 +15,8 @@ export interface WorkflowGraphBuildInput {
|
|
|
15
15
|
|
|
16
16
|
/** Return displayable workflow stages while hiding structural parallel groups and host monitors. */
|
|
17
17
|
export function workflowGraphStageNodes(graph: WorkflowGraphSnapshot | undefined): WorkflowGraphNode[] {
|
|
18
|
-
|
|
18
|
+
const nodes = graph?.nodes;
|
|
19
|
+
if (!nodes?.length) return [];
|
|
19
20
|
const stages: WorkflowGraphNode[] = [];
|
|
20
21
|
const visit = (node: WorkflowGraphNode): void => {
|
|
21
22
|
if (node.kind === "parallel-group" || node.kind === "dynamic-parallel-group") {
|
|
@@ -24,7 +25,7 @@ export function workflowGraphStageNodes(graph: WorkflowGraphSnapshot | undefined
|
|
|
24
25
|
}
|
|
25
26
|
if (node.kind !== "host-step") stages.push(node);
|
|
26
27
|
};
|
|
27
|
-
for (const node of
|
|
28
|
+
for (const node of nodes) visit(node);
|
|
28
29
|
return stages;
|
|
29
30
|
}
|
|
30
31
|
|
package/src/shared/types.ts
CHANGED
|
@@ -143,6 +143,20 @@ export interface WorkflowRecoveryAction {
|
|
|
143
143
|
taskRequired: true;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Bounded, host-generated identity for a resolved pi-subagents workflow resource.
|
|
148
|
+
* Permission/policy extensions can use it to distinguish resolved content from
|
|
149
|
+
* raw workflow scripts. This audit projection never grants execution authority.
|
|
150
|
+
*/
|
|
151
|
+
export interface WorkflowResourceProvenanceV1 {
|
|
152
|
+
kind: "workflow";
|
|
153
|
+
name: string;
|
|
154
|
+
version: number;
|
|
155
|
+
invocation: "named";
|
|
156
|
+
expansion: "resolved";
|
|
157
|
+
id: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
146
160
|
/**
|
|
147
161
|
* Bounded, launch-declared workflow lane metadata. This is display and
|
|
148
162
|
* triage information only; capability ceilings, authorization, and cleanup
|
|
@@ -189,6 +203,7 @@ export type WorkflowReceiptEntry = WorkflowReceiptEntryResumability & {
|
|
|
189
203
|
requestedContext?: "fresh" | "fork";
|
|
190
204
|
resolvedContext?: "fresh" | "fork" | "mixed";
|
|
191
205
|
outputReference?: string;
|
|
206
|
+
acceptanceRecovery?: AcceptanceRecoveryMetadata;
|
|
192
207
|
externalAdapter?: ExternalCliReceiptMetadata;
|
|
193
208
|
continuation: { runIds: string[] };
|
|
194
209
|
};
|
|
@@ -199,6 +214,7 @@ export interface WorkflowReceipt {
|
|
|
199
214
|
state: WorkflowReceiptState;
|
|
200
215
|
createdAt: number;
|
|
201
216
|
entries: Record<string, WorkflowReceiptEntry>;
|
|
217
|
+
resource?: WorkflowResourceProvenanceV1;
|
|
202
218
|
hostSteps?: HostStepNodeV1[];
|
|
203
219
|
workflowChildren?: WorkflowChildSummaryV1;
|
|
204
220
|
workflowResolution?: WorkflowTerminalResolution;
|
|
@@ -330,7 +346,7 @@ export interface CompletionBatchConfig {
|
|
|
330
346
|
|
|
331
347
|
export interface WaitToolConfigObject {
|
|
332
348
|
enabled?: boolean;
|
|
333
|
-
/** Default blocking window for
|
|
349
|
+
/** Default blocking window for bg_wait calls that omit timeoutMs. */
|
|
334
350
|
defaultTimeoutMs?: number;
|
|
335
351
|
}
|
|
336
352
|
|
|
@@ -758,6 +774,7 @@ export interface SteeringRecoveryDescriptor {
|
|
|
758
774
|
model?: string;
|
|
759
775
|
modelProvider?: string;
|
|
760
776
|
modelOverrideFromParent?: boolean;
|
|
777
|
+
modelOrigin?: "explicit" | "inherited" | "configured";
|
|
761
778
|
fallbackModels?: string[];
|
|
762
779
|
fast?: boolean;
|
|
763
780
|
thinking?: string;
|
|
@@ -1086,6 +1103,18 @@ export type AcceptanceLedgerStatus =
|
|
|
1086
1103
|
| "reviewed"
|
|
1087
1104
|
| "accepted";
|
|
1088
1105
|
|
|
1106
|
+
/**
|
|
1107
|
+
* Durable child evidence that can be handed to a read-only review after the
|
|
1108
|
+
* acceptance envelope itself was rejected. This never upgrades acceptance;
|
|
1109
|
+
* the enclosing ledger remains rejected and the child remains unsuccessful.
|
|
1110
|
+
*/
|
|
1111
|
+
export interface AcceptanceRecoveryMetadata {
|
|
1112
|
+
status: "available-for-review";
|
|
1113
|
+
reason: "acceptance-metadata-rejected";
|
|
1114
|
+
reportPath: string;
|
|
1115
|
+
reportHash: string;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1089
1118
|
export interface AcceptanceLedger {
|
|
1090
1119
|
status: AcceptanceLedgerStatus;
|
|
1091
1120
|
evidenceStatus: AcceptanceEvidenceStatus;
|
|
@@ -1095,6 +1124,7 @@ export interface AcceptanceLedger {
|
|
|
1095
1124
|
criteria: ResolvedAcceptanceGate[];
|
|
1096
1125
|
childReport?: AcceptanceReport;
|
|
1097
1126
|
childReportParseError?: string;
|
|
1127
|
+
recovery?: AcceptanceRecoveryMetadata;
|
|
1098
1128
|
runtimeChecks: AcceptanceRuntimeCheck[];
|
|
1099
1129
|
verifyRuns: AcceptanceVerifyResult[];
|
|
1100
1130
|
reviewResult?: AcceptanceReviewResult;
|
|
@@ -1283,7 +1313,7 @@ export interface WaitCompletionChild {
|
|
|
1283
1313
|
}
|
|
1284
1314
|
|
|
1285
1315
|
/**
|
|
1286
|
-
* Terminal completion observed for a run a
|
|
1316
|
+
* Terminal completion observed for a run a bg_wait call covered. Carries run
|
|
1287
1317
|
* identity and the artifact trail; output text stays in the tool result content.
|
|
1288
1318
|
*/
|
|
1289
1319
|
export interface WaitCompletion {
|
|
@@ -1329,7 +1359,7 @@ export interface Details {
|
|
|
1329
1359
|
results: SingleResult[];
|
|
1330
1360
|
workflowChildren?: WorkflowChildSummaryV1;
|
|
1331
1361
|
/**
|
|
1332
|
-
* Terminal completion payloads for runs this
|
|
1362
|
+
* Terminal completion payloads for runs this bg_wait call observed
|
|
1333
1363
|
* finishing. Async completions travel as result files that are consumed and
|
|
1334
1364
|
* deleted after text delivery, so without this field their run and artifact
|
|
1335
1365
|
* identity never reaches tool_result details.
|
|
@@ -1401,6 +1431,7 @@ export interface Details {
|
|
|
1401
1431
|
mission?: MissionRecord;
|
|
1402
1432
|
workflow?: {
|
|
1403
1433
|
value?: unknown;
|
|
1434
|
+
resource?: WorkflowResourceProvenanceV1;
|
|
1404
1435
|
preflightWarnings?: string[];
|
|
1405
1436
|
trace: Array<{
|
|
1406
1437
|
operation: "run" | "status" | "steer" | "host";
|
|
@@ -2117,6 +2148,8 @@ export interface ActiveAsyncCapacitySnapshot {
|
|
|
2117
2148
|
export interface SubagentState {
|
|
2118
2149
|
baseCwd: string;
|
|
2119
2150
|
currentSessionId: string | null;
|
|
2151
|
+
/** Session for which active status projections were restored successfully. */
|
|
2152
|
+
statusProjectionSessionId?: string | null;
|
|
2120
2153
|
/** Reload-stable identity for this parent Pi process/window. */
|
|
2121
2154
|
completionOwnerId?: string;
|
|
2122
2155
|
/** Runtime-owned artifact resolution inputs used by Fleet transcript targeting. */
|
|
@@ -2283,6 +2316,8 @@ export interface RunSyncOptions {
|
|
|
2283
2316
|
allowIntercomDetach?: boolean;
|
|
2284
2317
|
intercomEvents?: IntercomEventBus;
|
|
2285
2318
|
onUpdate?: (r: import("@earendil-works/pi-agent-core").AgentToolResult<Details>) => void;
|
|
2319
|
+
/** Internal structured-delegation transport optimization: skip unchanged live snapshots. */
|
|
2320
|
+
suppressUnchangedDelegationUpdates?: boolean;
|
|
2286
2321
|
onControlEvent?: (event: ControlEvent) => void;
|
|
2287
2322
|
/** Exposes a non-terminating detach callback while the child is active. */
|
|
2288
2323
|
onDetachReady?: (detach: (reason?: string) => boolean) => void;
|
|
@@ -2318,6 +2353,8 @@ export interface RunSyncOptions {
|
|
|
2318
2353
|
fast?: boolean;
|
|
2319
2354
|
/** The override came from the running parent session, not configuration. */
|
|
2320
2355
|
modelOverrideFromParent?: boolean;
|
|
2356
|
+
/** How the launch model was selected: explicit per-call, configured agent primary, or inherited parent. */
|
|
2357
|
+
modelOrigin?: "explicit" | "inherited" | "configured";
|
|
2321
2358
|
/** LLM intent arbiter for the completion mutation guard (rescues read-only review runs). */
|
|
2322
2359
|
llmIntentArbiter?: import("../runs/shared/llm-intent-arbiter.ts").TaskMutationArbiter;
|
|
2323
2360
|
/** Override the agent's default thinking level for this run */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { stableJsonDigest } from "./launch-contract.ts";
|
|
2
|
+
import type { WorkflowResourceProvenanceV1 } from "./types.ts";
|
|
2
3
|
|
|
3
4
|
export interface WorkflowChildPermitInput {
|
|
4
5
|
issuerPackage: string;
|
|
@@ -114,3 +115,93 @@ export function workflowChildPermitConsumed(permit: WorkflowChildPermit): boolea
|
|
|
114
115
|
const state = records.get(permit as object)?.state;
|
|
115
116
|
return state === "claimed" || state === "consumed";
|
|
116
117
|
}
|
|
118
|
+
|
|
119
|
+
export interface WorkflowResourceHostAuthority {
|
|
120
|
+
keys: readonly string[];
|
|
121
|
+
commands: readonly string[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface WorkflowResourceAuthority {
|
|
125
|
+
host?: WorkflowResourceHostAuthority;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface WorkflowResourcePermitInput {
|
|
129
|
+
resourceName: string;
|
|
130
|
+
resourceVersion: number;
|
|
131
|
+
resourceId: string;
|
|
132
|
+
scriptDigest: string;
|
|
133
|
+
authority: WorkflowResourceAuthority;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface WorkflowResourcePermit {
|
|
137
|
+
readonly __workflowResourcePermit: unique symbol;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface WorkflowResourcePermitRecord {
|
|
141
|
+
resourceName: string;
|
|
142
|
+
resourceVersion: number;
|
|
143
|
+
resourceId: string;
|
|
144
|
+
scriptDigest: string;
|
|
145
|
+
authority: WorkflowResourceAuthority;
|
|
146
|
+
provenance: WorkflowResourceProvenanceV1;
|
|
147
|
+
state: "available" | "consumed";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const resourceRecords = new WeakMap<object, WorkflowResourcePermitRecord>();
|
|
151
|
+
|
|
152
|
+
function cloneWorkflowResourceAuthority(authority: WorkflowResourceAuthority): WorkflowResourceAuthority {
|
|
153
|
+
if (!authority || typeof authority !== "object" || Array.isArray(authority)) throw new Error("Workflow resource authority must be an object.");
|
|
154
|
+
if (authority.host === undefined) return Object.freeze({});
|
|
155
|
+
if (!authority.host || typeof authority.host !== "object" || Array.isArray(authority.host)) throw new Error("Workflow resource host authority must be an object.");
|
|
156
|
+
const { keys, commands } = authority.host;
|
|
157
|
+
if (!Array.isArray(keys) || keys.some((key) => typeof key !== "string" || !key.trim())) throw new Error("Workflow resource host authority keys must be non-empty strings.");
|
|
158
|
+
if (!Array.isArray(commands) || commands.some((command) => typeof command !== "string" || !command.trim())) throw new Error("Workflow resource host authority commands must be non-empty strings.");
|
|
159
|
+
return Object.freeze({ host: Object.freeze({ keys: Object.freeze([...keys]), commands: Object.freeze([...commands]) }) });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Package-internal permit for a workflow resource resolved by the extension. */
|
|
163
|
+
export function createWorkflowResourcePermit(input: WorkflowResourcePermitInput): WorkflowResourcePermit {
|
|
164
|
+
const resourceName = required(input.resourceName, "resourceName");
|
|
165
|
+
const resourceId = required(input.resourceId, "resourceId");
|
|
166
|
+
const scriptDigest = required(input.scriptDigest, "scriptDigest");
|
|
167
|
+
if (!Number.isInteger(input.resourceVersion) || input.resourceVersion < 1) throw new Error("resourceVersion must be a positive integer.");
|
|
168
|
+
const authority = cloneWorkflowResourceAuthority(input.authority);
|
|
169
|
+
const permit = Object.freeze(Object.create(null)) as WorkflowResourcePermit;
|
|
170
|
+
resourceRecords.set(permit as object, {
|
|
171
|
+
resourceName,
|
|
172
|
+
resourceVersion: input.resourceVersion,
|
|
173
|
+
resourceId,
|
|
174
|
+
scriptDigest,
|
|
175
|
+
authority,
|
|
176
|
+
provenance: Object.freeze({
|
|
177
|
+
kind: "workflow",
|
|
178
|
+
name: resourceName,
|
|
179
|
+
version: input.resourceVersion,
|
|
180
|
+
invocation: "named",
|
|
181
|
+
expansion: "resolved",
|
|
182
|
+
id: resourceId,
|
|
183
|
+
}),
|
|
184
|
+
state: "available",
|
|
185
|
+
});
|
|
186
|
+
return permit;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function consumeWorkflowResourcePermit(permit: WorkflowResourcePermit, script: string): { provenance: WorkflowResourceProvenanceV1; authority: WorkflowResourceAuthority } | string {
|
|
190
|
+
const record = resourceRecords.get(permit as object);
|
|
191
|
+
if (!record) return "Workflow resource permit is invalid.";
|
|
192
|
+
if (record.state !== "available") return "Workflow resource permit is already consumed.";
|
|
193
|
+
if (stableJsonDigest(script) !== record.scriptDigest) return "Workflow resource permit does not match the resolved workflow script.";
|
|
194
|
+
record.state = "consumed";
|
|
195
|
+
return { provenance: record.provenance, authority: record.authority };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Validate a host call against the authority attached to a consumed resource. */
|
|
199
|
+
export function authorizeWorkflowResourceHost(permit: WorkflowResourcePermit, key: string, command: string): string | undefined {
|
|
200
|
+
const record = resourceRecords.get(permit as object);
|
|
201
|
+
if (!record || record.state !== "consumed") return "Workflow resource authority is unavailable.";
|
|
202
|
+
const host = record.authority.host;
|
|
203
|
+
if (!host) return "runs.host is not allowed for this workflow resource.";
|
|
204
|
+
if (!host.keys.includes(key)) return `runs.host('${key}') is not allowed for workflow resource '${record.resourceName}'.`;
|
|
205
|
+
if (!host.commands.includes(command.trim())) return `The command for runs.host('${key}') is not allowed for workflow resource '${record.resourceName}'.`;
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type SubagentDelegationInvalidResponse,
|
|
8
8
|
type SubagentDelegationRequest,
|
|
9
9
|
type SubagentDelegationResponse,
|
|
10
|
+
type SubagentDelegationUpdate,
|
|
10
11
|
} from "../api/delegation.ts";
|
|
11
12
|
import { parseSubagentDelegationRequest } from "./delegation-request.ts";
|
|
12
13
|
import {
|
|
@@ -66,6 +67,37 @@ function validId(value: unknown): value is string {
|
|
|
66
67
|
return typeof value === "string" && value.trim().length > 0 && value.length <= 256 && !/[\r\n]/.test(value);
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
function sameStringArray(left: string[] | undefined, right: string[] | undefined): boolean {
|
|
71
|
+
if (left === right) return true;
|
|
72
|
+
if (!left || !right || left.length !== right.length) return false;
|
|
73
|
+
return left.every((value, index) => value === right[index]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sameRecentTools(
|
|
77
|
+
left: Array<{ tool: string; args: string }> | undefined,
|
|
78
|
+
right: Array<{ tool: string; args: string }> | undefined,
|
|
79
|
+
): boolean {
|
|
80
|
+
if (left === right) return true;
|
|
81
|
+
if (!left || !right || left.length !== right.length) return false;
|
|
82
|
+
return left.every((tool, index) => tool.tool === right[index]?.tool && tool.args === right[index]?.args);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Duration is a heartbeat clock, not delegation-visible progress; terminal usage remains authoritative. */
|
|
86
|
+
function sameStructuredDelegationUpdateProgress(left: SubagentDelegationUpdate, right: SubagentDelegationUpdate): boolean {
|
|
87
|
+
return left.requestId === right.requestId
|
|
88
|
+
&& left.ownerRunId === right.ownerRunId
|
|
89
|
+
&& left.nodeId === right.nodeId
|
|
90
|
+
&& left.runId === right.runId
|
|
91
|
+
&& left.currentTool === right.currentTool
|
|
92
|
+
&& left.currentToolArgs === right.currentToolArgs
|
|
93
|
+
&& left.recentOutput === right.recentOutput
|
|
94
|
+
&& sameStringArray(left.recentOutputLines, right.recentOutputLines)
|
|
95
|
+
&& sameRecentTools(left.recentTools, right.recentTools)
|
|
96
|
+
&& left.model === right.model
|
|
97
|
+
&& left.toolCount === right.toolCount
|
|
98
|
+
&& left.tokens === right.tokens;
|
|
99
|
+
}
|
|
100
|
+
|
|
69
101
|
export function registerPromptTemplateDelegationBridge<Ctx extends { cwd?: string }>(
|
|
70
102
|
options: PromptTemplateBridgeOptions<Ctx>,
|
|
71
103
|
): {
|
|
@@ -298,6 +330,7 @@ export function registerPromptTemplateDelegationBridge<Ctx extends { cwd?: strin
|
|
|
298
330
|
const executeRequest = structuredRequest && options.executeStructured
|
|
299
331
|
? options.executeStructured
|
|
300
332
|
: options.execute;
|
|
333
|
+
let lastStructuredUpdate: SubagentDelegationUpdate | undefined;
|
|
301
334
|
const result = await executeRequest(
|
|
302
335
|
requestId,
|
|
303
336
|
params,
|
|
@@ -307,7 +340,10 @@ export function registerPromptTemplateDelegationBridge<Ctx extends { cwd?: strin
|
|
|
307
340
|
if (key ? !ownsAttempt(key, controller) : !ownsLegacyRequest(requestId, controller)) return;
|
|
308
341
|
if (structuredRequest) {
|
|
309
342
|
const payload = toSubagentDelegationUpdate(structuredRequest, update);
|
|
310
|
-
if (payload
|
|
343
|
+
if (payload && (!lastStructuredUpdate || !sameStructuredDelegationUpdateProgress(lastStructuredUpdate, payload))) {
|
|
344
|
+
lastStructuredUpdate = payload;
|
|
345
|
+
options.events.emit(SUBAGENT_DELEGATION_UPDATE_EVENT, payload);
|
|
346
|
+
}
|
|
311
347
|
return;
|
|
312
348
|
}
|
|
313
349
|
const payload = toDelegationUpdate(requestId, update);
|