pi-subagents 0.24.4 → 0.27.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 +29 -0
- package/README.md +145 -27
- package/package.json +1 -1
- package/prompts/parallel-context-build.md +3 -1
- package/prompts/parallel-handoff-plan.md +3 -1
- package/prompts/review-loop.md +1 -1
- package/skills/pi-subagents/SKILL.md +71 -20
- package/src/agents/agent-management.ts +57 -15
- package/src/agents/agent-serializer.ts +3 -2
- package/src/agents/agents.ts +47 -16
- package/src/agents/chain-serializer.ts +120 -0
- package/src/extension/fanout-child.ts +171 -0
- package/src/extension/index.ts +7 -2
- package/src/extension/schemas.ts +138 -5
- package/src/intercom/result-intercom.ts +108 -0
- package/src/runs/background/async-execution.ts +185 -10
- package/src/runs/background/async-job-tracker.ts +41 -6
- package/src/runs/background/async-resume.ts +28 -15
- package/src/runs/background/async-status.ts +71 -31
- package/src/runs/background/result-watcher.ts +111 -54
- package/src/runs/background/run-id-resolver.ts +83 -0
- package/src/runs/background/run-status.ts +89 -4
- package/src/runs/background/stale-run-reconciler.ts +46 -1
- package/src/runs/background/subagent-runner.ts +648 -42
- package/src/runs/foreground/chain-execution.ts +331 -118
- package/src/runs/foreground/execution.ts +226 -10
- package/src/runs/foreground/subagent-executor.ts +377 -14
- package/src/runs/shared/acceptance-contract.ts +291 -0
- package/src/runs/shared/acceptance-evaluation.ts +221 -0
- package/src/runs/shared/acceptance-finalization.ts +161 -0
- package/src/runs/shared/acceptance-reports.ts +127 -0
- package/src/runs/shared/acceptance.ts +22 -0
- package/src/runs/shared/chain-outputs.ts +101 -0
- package/src/runs/shared/completion-guard.ts +26 -3
- package/src/runs/shared/dynamic-fanout.ts +293 -0
- package/src/runs/shared/nested-events.ts +819 -0
- package/src/runs/shared/nested-path.ts +52 -0
- package/src/runs/shared/nested-render.ts +115 -0
- package/src/runs/shared/parallel-utils.ts +31 -1
- package/src/runs/shared/pi-args.ts +73 -5
- package/src/runs/shared/structured-output.ts +77 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +77 -7
- package/src/runs/shared/workflow-graph.ts +206 -0
- package/src/shared/formatters.ts +2 -2
- package/src/shared/settings.ts +53 -4
- package/src/shared/types.ts +345 -0
- package/src/slash/slash-commands.ts +41 -3
- package/src/tui/render.ts +268 -43
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
|
|
3
|
+
const MAX_NESTED_ID_LENGTH = 128;
|
|
4
|
+
export const MAX_NESTED_PATH_ENTRIES = 4;
|
|
5
|
+
|
|
6
|
+
export type NestedPathEntry = { runId: string; stepIndex?: number; agent?: string };
|
|
7
|
+
|
|
8
|
+
export function isSafeNestedPathId(value: unknown): value is string {
|
|
9
|
+
return typeof value === "string"
|
|
10
|
+
&& value.length > 0
|
|
11
|
+
&& value.length <= MAX_NESTED_ID_LENGTH
|
|
12
|
+
&& !path.isAbsolute(value)
|
|
13
|
+
&& !value.includes("/")
|
|
14
|
+
&& !value.includes("\\")
|
|
15
|
+
&& !value.includes("..");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
19
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function nonEmptyString(value: unknown, max: number): string | undefined {
|
|
23
|
+
return typeof value === "string" && value.length > 0 ? value.slice(0, max) : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function sanitizeNestedPath(value: unknown): NestedPathEntry[] {
|
|
27
|
+
if (!Array.isArray(value)) return [];
|
|
28
|
+
return value.map((part) => {
|
|
29
|
+
if (!part || typeof part !== "object") return undefined;
|
|
30
|
+
const record = part as Record<string, unknown>;
|
|
31
|
+
if (!isSafeNestedPathId(record.runId)) return undefined;
|
|
32
|
+
return {
|
|
33
|
+
runId: record.runId,
|
|
34
|
+
...(finiteNumber(record.stepIndex) !== undefined ? { stepIndex: finiteNumber(record.stepIndex) } : {}),
|
|
35
|
+
...(nonEmptyString(record.agent, 128) ? { agent: nonEmptyString(record.agent, 128) } : {}),
|
|
36
|
+
};
|
|
37
|
+
}).filter((part): part is NestedPathEntry => Boolean(part)).slice(0, MAX_NESTED_PATH_ENTRIES);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseNestedPathEnv(value: string | undefined): NestedPathEntry[] {
|
|
41
|
+
if (!value) return [];
|
|
42
|
+
try {
|
|
43
|
+
return sanitizeNestedPath(JSON.parse(value) as unknown);
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function encodeNestedPathEnv(value: NestedPathEntry[]): string {
|
|
50
|
+
const sanitized = sanitizeNestedPath(value);
|
|
51
|
+
return sanitized.length ? JSON.stringify(sanitized) : "";
|
|
52
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { formatDuration, formatTokens, shortenPath } from "../../shared/formatters.ts";
|
|
2
|
+
import { formatActivityLabel } from "../../shared/status-format.ts";
|
|
3
|
+
import type { ActivityState, NestedRunSummary, NestedStepSummary } from "../../shared/types.ts";
|
|
4
|
+
|
|
5
|
+
export interface NestedRunCounts {
|
|
6
|
+
total: number;
|
|
7
|
+
running: number;
|
|
8
|
+
paused: number;
|
|
9
|
+
complete: number;
|
|
10
|
+
failed: number;
|
|
11
|
+
queued: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function countNestedRuns(children: NestedRunSummary[] | undefined): NestedRunCounts {
|
|
15
|
+
const counts: NestedRunCounts = { total: 0, running: 0, paused: 0, complete: 0, failed: 0, queued: 0 };
|
|
16
|
+
for (const child of children ?? []) {
|
|
17
|
+
counts.total++;
|
|
18
|
+
counts[child.state]++;
|
|
19
|
+
const nested = countNestedRuns([...(child.children ?? []), ...(child.steps?.flatMap((step) => step.children ?? []) ?? [])]);
|
|
20
|
+
counts.total += nested.total;
|
|
21
|
+
counts.running += nested.running;
|
|
22
|
+
counts.paused += nested.paused;
|
|
23
|
+
counts.complete += nested.complete;
|
|
24
|
+
counts.failed += nested.failed;
|
|
25
|
+
counts.queued += nested.queued;
|
|
26
|
+
}
|
|
27
|
+
return counts;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function formatNestedAggregate(children: NestedRunSummary[] | undefined): string | undefined {
|
|
31
|
+
const counts = countNestedRuns(children);
|
|
32
|
+
if (counts.total === 0) return undefined;
|
|
33
|
+
const parts = [
|
|
34
|
+
counts.running > 0 ? `${counts.running} running` : "",
|
|
35
|
+
counts.paused > 0 ? `${counts.paused} paused` : "",
|
|
36
|
+
counts.failed > 0 ? `${counts.failed} failed` : "",
|
|
37
|
+
counts.complete > 0 ? `${counts.complete} complete` : "",
|
|
38
|
+
counts.queued > 0 ? `${counts.queued} queued` : "",
|
|
39
|
+
].filter(Boolean);
|
|
40
|
+
return `+${counts.total} nested run${counts.total === 1 ? "" : "s"}${parts.length ? ` (${parts.join(", ")})` : ""}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function nestedRunLabel(run: NestedRunSummary): string {
|
|
44
|
+
if (run.agent) return run.agent;
|
|
45
|
+
if (run.agents?.length) return run.agents.length === 1 ? run.agents[0]! : `${run.agents.slice(0, 2).join(", ")}${run.agents.length > 2 ? ` +${run.agents.length - 2}` : ""}`;
|
|
46
|
+
return run.id;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatNestedActivity(input: {
|
|
50
|
+
activityState?: ActivityState;
|
|
51
|
+
lastActivityAt?: number;
|
|
52
|
+
currentTool?: string;
|
|
53
|
+
currentToolStartedAt?: number;
|
|
54
|
+
currentPath?: string;
|
|
55
|
+
turnCount?: number;
|
|
56
|
+
toolCount?: number;
|
|
57
|
+
totalTokens?: NestedRunSummary["totalTokens"];
|
|
58
|
+
}): string | undefined {
|
|
59
|
+
const facts: string[] = [];
|
|
60
|
+
if (input.currentTool && input.currentToolStartedAt !== undefined) facts.push(`tool ${input.currentTool} ${formatDuration(Math.max(0, Date.now() - input.currentToolStartedAt))}`);
|
|
61
|
+
else if (input.currentTool) facts.push(`tool ${input.currentTool}`);
|
|
62
|
+
if (input.currentPath) facts.push(shortenPath(input.currentPath));
|
|
63
|
+
if (input.turnCount !== undefined) facts.push(`${input.turnCount} turns`);
|
|
64
|
+
if (input.toolCount !== undefined) facts.push(`${input.toolCount} tools`);
|
|
65
|
+
if (input.totalTokens) facts.push(`${formatTokens(input.totalTokens.total)} tok`);
|
|
66
|
+
const activity = formatActivityLabel(input.lastActivityAt, input.activityState as ActivityState | undefined);
|
|
67
|
+
return activity || facts.length ? [activity, ...facts].filter(Boolean).join(" | ") : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatNestedRunLines(children: NestedRunSummary[] | undefined, options: { indent: string; maxDepth: number; maxLines: number; commandHints?: boolean }): string[] {
|
|
71
|
+
const lines: string[] = [];
|
|
72
|
+
const append = (items: NestedRunSummary[] | undefined, depth: number, indent: string): void => {
|
|
73
|
+
if (!items?.length || lines.length >= options.maxLines) return;
|
|
74
|
+
if (depth > options.maxDepth) {
|
|
75
|
+
const aggregate = formatNestedAggregate(items);
|
|
76
|
+
if (aggregate && lines.length < options.maxLines) lines.push(`${indent}↳ ${aggregate}`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
for (let index = 0; index < items.length; index++) {
|
|
80
|
+
const child = items[index]!;
|
|
81
|
+
if (lines.length >= options.maxLines) {
|
|
82
|
+
const aggregate = formatNestedAggregate(items.slice(index));
|
|
83
|
+
if (aggregate) lines[lines.length - 1] = `${indent}↳ ${aggregate}`;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const activity = child.state === "running" ? formatNestedActivity(child) : undefined;
|
|
87
|
+
const error = child.error ? ` | error: ${child.error}` : "";
|
|
88
|
+
lines.push(`${indent}↳ ${nestedRunLabel(child)} [${child.id}] ${child.state}${activity ? ` | ${activity}` : ""}${error}`);
|
|
89
|
+
if (options.commandHints && lines.length < options.maxLines) lines.push(`${indent} Status: subagent({ action: "status", id: "${child.id}" })`);
|
|
90
|
+
if (depth === options.maxDepth) {
|
|
91
|
+
const aggregate = formatNestedAggregate([...(child.steps?.flatMap((step) => step.children ?? []) ?? []), ...(child.children ?? [])]);
|
|
92
|
+
if (aggregate && lines.length < options.maxLines) lines.push(`${indent} ↳ ${aggregate}`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
for (const [stepIndex, step] of (child.steps ?? []).entries()) {
|
|
96
|
+
if (lines.length >= options.maxLines) return;
|
|
97
|
+
const stepActivity = step.status === "running" ? formatNestedActivity(step) : undefined;
|
|
98
|
+
lines.push(`${indent} ${stepIndex + 1}. ${step.agent} ${step.status}${stepActivity ? ` | ${stepActivity}` : ""}${step.error ? ` | error: ${step.error}` : ""}`);
|
|
99
|
+
append(step.children, depth + 1, `${indent} `);
|
|
100
|
+
}
|
|
101
|
+
append(child.children, depth + 1, `${indent} `);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
append(children, 0, options.indent);
|
|
105
|
+
return lines;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function formatNestedRunStatusLines(children: NestedRunSummary[] | undefined, options: { indent?: string; maxDepth?: number; maxLines?: number; commandHints?: boolean } = {}): string[] {
|
|
109
|
+
return formatNestedRunLines(children, {
|
|
110
|
+
indent: options.indent ?? " ",
|
|
111
|
+
maxDepth: options.maxDepth ?? 2,
|
|
112
|
+
maxLines: options.maxLines ?? 40,
|
|
113
|
+
commandHints: options.commandHints ?? false,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import type { DynamicCollectSpec, DynamicExpandSpec } from "../../shared/settings.ts";
|
|
2
|
+
import type { JsonSchemaObject, ResolvedAcceptanceConfig } from "../../shared/types.ts";
|
|
3
|
+
|
|
1
4
|
export interface RunnerSubagentStep {
|
|
2
5
|
agent: string;
|
|
3
6
|
task: string;
|
|
7
|
+
phase?: string;
|
|
8
|
+
label?: string;
|
|
9
|
+
outputName?: string;
|
|
10
|
+
structured?: boolean;
|
|
4
11
|
cwd?: string;
|
|
5
12
|
model?: string;
|
|
6
13
|
thinking?: string;
|
|
@@ -18,6 +25,13 @@ export interface RunnerSubagentStep {
|
|
|
18
25
|
outputMode?: "inline" | "file-only";
|
|
19
26
|
sessionFile?: string;
|
|
20
27
|
maxSubagentDepth?: number;
|
|
28
|
+
structuredOutput?: {
|
|
29
|
+
schema: JsonSchemaObject;
|
|
30
|
+
schemaPath: string;
|
|
31
|
+
outputPath: string;
|
|
32
|
+
};
|
|
33
|
+
structuredOutputSchema?: JsonSchemaObject;
|
|
34
|
+
effectiveAcceptance?: ResolvedAcceptanceConfig;
|
|
21
35
|
}
|
|
22
36
|
|
|
23
37
|
export interface ParallelStepGroup {
|
|
@@ -27,17 +41,33 @@ export interface ParallelStepGroup {
|
|
|
27
41
|
worktree?: boolean;
|
|
28
42
|
}
|
|
29
43
|
|
|
30
|
-
export
|
|
44
|
+
export interface DynamicRunnerGroup {
|
|
45
|
+
expand: DynamicExpandSpec;
|
|
46
|
+
parallel: RunnerSubagentStep;
|
|
47
|
+
collect: DynamicCollectSpec;
|
|
48
|
+
concurrency?: number;
|
|
49
|
+
failFast?: boolean;
|
|
50
|
+
phase?: string;
|
|
51
|
+
label?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type RunnerStep = RunnerSubagentStep | ParallelStepGroup | DynamicRunnerGroup;
|
|
31
55
|
|
|
32
56
|
export function isParallelGroup(step: RunnerStep): step is ParallelStepGroup {
|
|
33
57
|
return "parallel" in step && Array.isArray(step.parallel);
|
|
34
58
|
}
|
|
35
59
|
|
|
60
|
+
export function isDynamicRunnerGroup(step: RunnerStep): step is DynamicRunnerGroup {
|
|
61
|
+
return "expand" in step && "collect" in step && "parallel" in step && !Array.isArray((step as { parallel?: unknown }).parallel);
|
|
62
|
+
}
|
|
63
|
+
|
|
36
64
|
export function flattenSteps(steps: RunnerStep[]): RunnerSubagentStep[] {
|
|
37
65
|
const flat: RunnerSubagentStep[] = [];
|
|
38
66
|
for (const step of steps) {
|
|
39
67
|
if (isParallelGroup(step)) {
|
|
40
68
|
for (const task of step.parallel) flat.push(task);
|
|
69
|
+
} else if (isDynamicRunnerGroup(step)) {
|
|
70
|
+
continue;
|
|
41
71
|
} else {
|
|
42
72
|
flat.push(step);
|
|
43
73
|
}
|
|
@@ -2,16 +2,29 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { encodeNestedPathEnv, parseNestedPathEnv, type NestedPathEntry } from "./nested-path.ts";
|
|
5
6
|
import { resolveMcpDirectToolNames } from "./mcp-direct-tool-allowlist.ts";
|
|
7
|
+
import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV } from "./structured-output.ts";
|
|
8
|
+
import type { JsonSchemaObject } from "../../shared/types.ts";
|
|
6
9
|
|
|
7
10
|
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
8
11
|
const TASK_ARG_LIMIT = 8000;
|
|
9
12
|
const PROMPT_RUNTIME_EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-prompt-runtime.ts");
|
|
13
|
+
const FANOUT_CHILD_EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "extension", "fanout-child.ts");
|
|
10
14
|
export const SUBAGENT_CHILD_ENV = "PI_SUBAGENT_CHILD";
|
|
11
15
|
export const SUBAGENT_ORCHESTRATOR_TARGET_ENV = "PI_SUBAGENT_ORCHESTRATOR_TARGET";
|
|
12
16
|
export const SUBAGENT_RUN_ID_ENV = "PI_SUBAGENT_RUN_ID";
|
|
13
17
|
export const SUBAGENT_CHILD_AGENT_ENV = "PI_SUBAGENT_CHILD_AGENT";
|
|
14
18
|
export const SUBAGENT_CHILD_INDEX_ENV = "PI_SUBAGENT_CHILD_INDEX";
|
|
19
|
+
export const SUBAGENT_FANOUT_CHILD_ENV = "PI_SUBAGENT_FANOUT_CHILD";
|
|
20
|
+
export const SUBAGENT_PARENT_EVENT_SINK_ENV = "PI_SUBAGENT_PARENT_EVENT_SINK";
|
|
21
|
+
export const SUBAGENT_PARENT_CONTROL_INBOX_ENV = "PI_SUBAGENT_PARENT_CONTROL_INBOX";
|
|
22
|
+
export const SUBAGENT_PARENT_ROOT_RUN_ID_ENV = "PI_SUBAGENT_PARENT_ROOT_RUN_ID";
|
|
23
|
+
export const SUBAGENT_PARENT_RUN_ID_ENV = "PI_SUBAGENT_PARENT_RUN_ID";
|
|
24
|
+
export const SUBAGENT_PARENT_CHILD_INDEX_ENV = "PI_SUBAGENT_PARENT_CHILD_INDEX";
|
|
25
|
+
export const SUBAGENT_PARENT_DEPTH_ENV = "PI_SUBAGENT_PARENT_DEPTH";
|
|
26
|
+
export const SUBAGENT_PARENT_PATH_ENV = "PI_SUBAGENT_PARENT_PATH";
|
|
27
|
+
export const SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV = "PI_SUBAGENT_PARENT_CAPABILITY_TOKEN";
|
|
15
28
|
|
|
16
29
|
interface BuildPiArgsInput {
|
|
17
30
|
baseArgs: string[];
|
|
@@ -35,6 +48,19 @@ interface BuildPiArgsInput {
|
|
|
35
48
|
runId?: string;
|
|
36
49
|
childAgentName?: string;
|
|
37
50
|
childIndex?: number;
|
|
51
|
+
parentEventSink?: string;
|
|
52
|
+
parentControlInbox?: string;
|
|
53
|
+
parentRootRunId?: string;
|
|
54
|
+
parentRunId?: string;
|
|
55
|
+
parentChildIndex?: number;
|
|
56
|
+
parentDepth?: number;
|
|
57
|
+
parentPath?: NestedPathEntry[];
|
|
58
|
+
parentCapabilityToken?: string;
|
|
59
|
+
structuredOutput?: {
|
|
60
|
+
schema: JsonSchemaObject;
|
|
61
|
+
schemaPath: string;
|
|
62
|
+
outputPath: string;
|
|
63
|
+
};
|
|
38
64
|
}
|
|
39
65
|
|
|
40
66
|
interface BuildPiArgsResult {
|
|
@@ -71,14 +97,14 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
|
|
|
71
97
|
args.push("--model", modelArg);
|
|
72
98
|
}
|
|
73
99
|
|
|
100
|
+
const declaredBuiltinTools = input.tools?.filter((tool) => !(tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js"))) ?? [];
|
|
101
|
+
const fanoutAuthorized = declaredBuiltinTools.includes("subagent");
|
|
74
102
|
const toolExtensionPaths: string[] = [];
|
|
75
103
|
if (input.tools?.length) {
|
|
76
|
-
const builtinTools
|
|
104
|
+
const builtinTools = [...declaredBuiltinTools];
|
|
77
105
|
for (const tool of input.tools) {
|
|
78
|
-
if (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js")) {
|
|
106
|
+
if (!declaredBuiltinTools.includes(tool) && (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js"))) {
|
|
79
107
|
toolExtensionPaths.push(tool);
|
|
80
|
-
} else {
|
|
81
|
-
builtinTools.push(tool);
|
|
82
108
|
}
|
|
83
109
|
}
|
|
84
110
|
if (builtinTools.length > 0) {
|
|
@@ -89,7 +115,9 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
|
|
|
89
115
|
}
|
|
90
116
|
}
|
|
91
117
|
|
|
92
|
-
const runtimeExtensions =
|
|
118
|
+
const runtimeExtensions = fanoutAuthorized
|
|
119
|
+
? [PROMPT_RUNTIME_EXTENSION_PATH, FANOUT_CHILD_EXTENSION_PATH]
|
|
120
|
+
: [PROMPT_RUNTIME_EXTENSION_PATH];
|
|
93
121
|
if (input.extensions !== undefined) {
|
|
94
122
|
args.push("--no-extensions");
|
|
95
123
|
for (const extPath of [...new Set([...runtimeExtensions, ...toolExtensionPaths, ...input.extensions])]) {
|
|
@@ -127,6 +155,40 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
|
|
|
127
155
|
|
|
128
156
|
const env: Record<string, string | undefined> = {};
|
|
129
157
|
env[SUBAGENT_CHILD_ENV] = "1";
|
|
158
|
+
env[SUBAGENT_FANOUT_CHILD_ENV] = fanoutAuthorized ? "1" : "0";
|
|
159
|
+
const inheritedNestedRoute = Boolean(process.env[SUBAGENT_PARENT_EVENT_SINK_ENV] && process.env[SUBAGENT_PARENT_ROOT_RUN_ID_ENV] && process.env[SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV]);
|
|
160
|
+
const parentRunId = input.parentRunId ?? input.runId ?? (inheritedNestedRoute ? process.env[SUBAGENT_RUN_ID_ENV] : undefined) ?? process.env[SUBAGENT_PARENT_RUN_ID_ENV] ?? "";
|
|
161
|
+
const parentChildIndex = input.parentChildIndex !== undefined
|
|
162
|
+
? String(input.parentChildIndex)
|
|
163
|
+
: input.childIndex !== undefined
|
|
164
|
+
? String(input.childIndex)
|
|
165
|
+
: process.env[SUBAGENT_PARENT_CHILD_INDEX_ENV] ?? "";
|
|
166
|
+
const inheritedDepth = Number(process.env[SUBAGENT_PARENT_DEPTH_ENV]);
|
|
167
|
+
const parentDepth = input.parentDepth ?? (inheritedNestedRoute && Number.isFinite(inheritedDepth) ? inheritedDepth + 1 : 1);
|
|
168
|
+
const parentPath = input.parentPath ?? [
|
|
169
|
+
...parseNestedPathEnv(process.env[SUBAGENT_PARENT_PATH_ENV]),
|
|
170
|
+
...(parentRunId ? [{
|
|
171
|
+
runId: parentRunId,
|
|
172
|
+
...(parentChildIndex && /^\d+$/.test(parentChildIndex) ? { stepIndex: Number(parentChildIndex) } : {}),
|
|
173
|
+
...(input.childAgentName ? { agent: input.childAgentName } : {}),
|
|
174
|
+
}] : []),
|
|
175
|
+
];
|
|
176
|
+
env[SUBAGENT_PARENT_EVENT_SINK_ENV] = fanoutAuthorized
|
|
177
|
+
? input.parentEventSink ?? process.env[SUBAGENT_PARENT_EVENT_SINK_ENV] ?? ""
|
|
178
|
+
: "";
|
|
179
|
+
env[SUBAGENT_PARENT_CONTROL_INBOX_ENV] = fanoutAuthorized
|
|
180
|
+
? input.parentControlInbox ?? process.env[SUBAGENT_PARENT_CONTROL_INBOX_ENV] ?? ""
|
|
181
|
+
: "";
|
|
182
|
+
env[SUBAGENT_PARENT_ROOT_RUN_ID_ENV] = fanoutAuthorized
|
|
183
|
+
? input.parentRootRunId ?? process.env[SUBAGENT_PARENT_ROOT_RUN_ID_ENV] ?? input.runId ?? ""
|
|
184
|
+
: "";
|
|
185
|
+
env[SUBAGENT_PARENT_RUN_ID_ENV] = fanoutAuthorized ? parentRunId : "";
|
|
186
|
+
env[SUBAGENT_PARENT_CHILD_INDEX_ENV] = fanoutAuthorized ? parentChildIndex : "";
|
|
187
|
+
env[SUBAGENT_PARENT_DEPTH_ENV] = fanoutAuthorized ? String(parentDepth) : "";
|
|
188
|
+
env[SUBAGENT_PARENT_PATH_ENV] = fanoutAuthorized ? encodeNestedPathEnv(parentPath) : "";
|
|
189
|
+
env[SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV] = fanoutAuthorized
|
|
190
|
+
? input.parentCapabilityToken ?? process.env[SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV] ?? ""
|
|
191
|
+
: "";
|
|
130
192
|
env.PI_SUBAGENT_INHERIT_PROJECT_CONTEXT = input.inheritProjectContext ? "1" : "0";
|
|
131
193
|
env.PI_SUBAGENT_INHERIT_SKILLS = input.inheritSkills ? "1" : "0";
|
|
132
194
|
if (input.intercomSessionName) {
|
|
@@ -149,10 +211,16 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
|
|
|
149
211
|
} else {
|
|
150
212
|
env.MCP_DIRECT_TOOLS = "__none__";
|
|
151
213
|
}
|
|
214
|
+
if (input.structuredOutput) {
|
|
215
|
+
env[STRUCTURED_OUTPUT_CAPTURE_ENV] = input.structuredOutput.outputPath;
|
|
216
|
+
env[STRUCTURED_OUTPUT_SCHEMA_ENV] = input.structuredOutput.schemaPath;
|
|
217
|
+
}
|
|
152
218
|
|
|
153
219
|
return { args, env, tempDir };
|
|
154
220
|
}
|
|
155
221
|
|
|
222
|
+
export const parseParentPathEnv = parseNestedPathEnv;
|
|
223
|
+
|
|
156
224
|
export function cleanupTempDir(tempDir: string | null | undefined): void {
|
|
157
225
|
if (!tempDir) return;
|
|
158
226
|
try {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { Compile } from "typebox/compile";
|
|
5
|
+
import type { JsonSchemaObject } from "../../shared/types.ts";
|
|
6
|
+
|
|
7
|
+
export const STRUCTURED_OUTPUT_SCHEMA_ENV = "PI_SUBAGENT_STRUCTURED_OUTPUT_SCHEMA";
|
|
8
|
+
export const STRUCTURED_OUTPUT_CAPTURE_ENV = "PI_SUBAGENT_STRUCTURED_OUTPUT_CAPTURE";
|
|
9
|
+
|
|
10
|
+
export interface StructuredOutputRuntime {
|
|
11
|
+
schema: JsonSchemaObject;
|
|
12
|
+
schemaPath: string;
|
|
13
|
+
outputPath: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface CompiledJsonSchema {
|
|
17
|
+
Check(value: unknown): boolean;
|
|
18
|
+
Errors(value: unknown): Iterable<{ instancePath?: string; message?: string }>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function assertJsonSchemaObject(schema: unknown, label = "outputSchema"): asserts schema is JsonSchemaObject {
|
|
22
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
23
|
+
throw new Error(`${label} must be a JSON Schema object.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createStructuredOutputRuntime(schema: JsonSchemaObject, baseDir?: string): StructuredOutputRuntime {
|
|
28
|
+
assertJsonSchemaObject(schema);
|
|
29
|
+
const rootDir = baseDir ?? os.tmpdir();
|
|
30
|
+
fs.mkdirSync(rootDir, { recursive: true });
|
|
31
|
+
const dir = fs.mkdtempSync(path.join(rootDir, "pi-subagent-structured-"));
|
|
32
|
+
const schemaPath = path.join(dir, "schema.json");
|
|
33
|
+
const outputPath = path.join(dir, "output.json");
|
|
34
|
+
fs.writeFileSync(schemaPath, JSON.stringify(schema), { mode: 0o600 });
|
|
35
|
+
return { schema, schemaPath, outputPath };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function validateStructuredOutputValue(schema: JsonSchemaObject, value: unknown): { status: "valid" } | { status: "invalid"; message: string } {
|
|
39
|
+
let validator: CompiledJsonSchema;
|
|
40
|
+
try {
|
|
41
|
+
validator = (Compile as (schema: unknown) => CompiledJsonSchema)(schema);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return { status: "invalid", message: `invalid outputSchema: ${error instanceof Error ? error.message : String(error)}` };
|
|
44
|
+
}
|
|
45
|
+
if (validator.Check(value)) return { status: "valid" };
|
|
46
|
+
const errors = [...validator.Errors(value)]
|
|
47
|
+
.slice(0, 8)
|
|
48
|
+
.map((error) => {
|
|
49
|
+
const pathText = error.instancePath ? error.instancePath.replace(/^\//, "").replace(/\//g, ".") : "root";
|
|
50
|
+
return `${pathText}: ${error.message}`;
|
|
51
|
+
});
|
|
52
|
+
return { status: "invalid", message: errors.join("; ") || "schema validation failed" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readStructuredOutput(runtime: StructuredOutputRuntime): { value?: unknown; error?: string } {
|
|
56
|
+
if (!fs.existsSync(runtime.outputPath)) {
|
|
57
|
+
return { error: "Missing structured_output call; this step has outputSchema and must finish by calling structured_output." };
|
|
58
|
+
}
|
|
59
|
+
let value: unknown;
|
|
60
|
+
try {
|
|
61
|
+
value = JSON.parse(fs.readFileSync(runtime.outputPath, "utf-8"));
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return { error: `Failed to read structured output: ${error instanceof Error ? error.message : String(error)}` };
|
|
64
|
+
}
|
|
65
|
+
const validation = validateStructuredOutputValue(runtime.schema, value);
|
|
66
|
+
if (validation.status === "invalid") return { error: `Structured output validation failed: ${validation.message}` };
|
|
67
|
+
return { value };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function cleanupStructuredOutputRuntime(runtime: StructuredOutputRuntime | undefined): void {
|
|
71
|
+
if (!runtime) return;
|
|
72
|
+
try {
|
|
73
|
+
fs.rmSync(path.dirname(runtime.schemaPath), { recursive: true, force: true });
|
|
74
|
+
} catch {
|
|
75
|
+
// Best-effort temp cleanup.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -1,9 +1,20 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
1
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { SUBAGENT_FANOUT_CHILD_ENV } from "./pi-args.ts";
|
|
5
|
+
import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, validateStructuredOutputValue } from "./structured-output.ts";
|
|
6
|
+
import type { JsonSchemaObject } from "../../shared/types.ts";
|
|
2
7
|
|
|
3
8
|
const SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV = "PI_SUBAGENT_INHERIT_PROJECT_CONTEXT";
|
|
4
9
|
const SUBAGENT_INHERIT_SKILLS_ENV = "PI_SUBAGENT_INHERIT_SKILLS";
|
|
5
10
|
export const SUBAGENT_INTERCOM_SESSION_NAME_ENV = "PI_SUBAGENT_INTERCOM_SESSION_NAME";
|
|
6
11
|
|
|
12
|
+
const STRUCTURED_OUTPUT_INSTRUCTIONS = [
|
|
13
|
+
"This subagent step has a strict structured output contract.",
|
|
14
|
+
"Your final action must be to call the `structured_output` tool with JSON matching the provided schema.",
|
|
15
|
+
"Do not rely on prose-only completion; if you do not call `structured_output`, the parent will fail this step.",
|
|
16
|
+
].join("\n");
|
|
17
|
+
|
|
7
18
|
export const CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS = [
|
|
8
19
|
"You are a child subagent, not the parent orchestrator.",
|
|
9
20
|
"The parent session owns delegation, orchestration, review fanout, and follow-up worker launches.",
|
|
@@ -12,6 +23,15 @@ export const CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS = [
|
|
|
12
23
|
"If you need to edit files, call the actual edit/write tools. Do not print tool-call syntax, patches, or pseudo-tool calls as text.",
|
|
13
24
|
].join("\n");
|
|
14
25
|
|
|
26
|
+
export const CHILD_FANOUT_BOUNDARY_INSTRUCTIONS = [
|
|
27
|
+
"You are a child subagent with explicit fanout responsibility for this assigned task.",
|
|
28
|
+
"The parent session owns final orchestration, acceptance, and follow-up implementation launches.",
|
|
29
|
+
"You may use the `subagent` tool only for the fanout work explicitly requested in this task.",
|
|
30
|
+
"Do not broaden yourself into general parent orchestration. Do not launch follow-up workers unless the task explicitly asks for that.",
|
|
31
|
+
"The maxSubagentDepth cap still applies and may block further fanout.",
|
|
32
|
+
"If you need to edit files, call the actual edit/write tools. Do not print tool-call syntax, patches, or pseudo-tool calls as text.",
|
|
33
|
+
].join("\n");
|
|
34
|
+
|
|
15
35
|
const PARENT_ONLY_CUSTOM_MESSAGE_TYPES = new Set([
|
|
16
36
|
"subagent-orchestration-instructions",
|
|
17
37
|
"subagent-slash-result",
|
|
@@ -62,9 +82,17 @@ export function stripSubagentOrchestrationSkill(prompt: string): string {
|
|
|
62
82
|
.replace(/[ \t]*<skill>\s*[\s\S]*?<\/skill>\s*/g, (block) => SUBAGENT_ORCHESTRATION_SKILL_NAME_PATTERN.test(block) ? "" : block);
|
|
63
83
|
}
|
|
64
84
|
|
|
85
|
+
function stripChildBoundaryInstructions(prompt: string): string {
|
|
86
|
+
let rewritten = prompt;
|
|
87
|
+
for (const boundary of [CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS, CHILD_FANOUT_BOUNDARY_INSTRUCTIONS]) {
|
|
88
|
+
rewritten = rewritten.split(boundary).join("");
|
|
89
|
+
}
|
|
90
|
+
return rewritten.replace(/^(?:[ \t]*\r?\n)+/, "");
|
|
91
|
+
}
|
|
92
|
+
|
|
65
93
|
export function rewriteSubagentPrompt(
|
|
66
94
|
prompt: string,
|
|
67
|
-
options: { inheritProjectContext: boolean; inheritSkills: boolean },
|
|
95
|
+
options: { inheritProjectContext: boolean; inheritSkills: boolean; fanoutChild?: boolean },
|
|
68
96
|
): string {
|
|
69
97
|
let rewritten = prompt;
|
|
70
98
|
if (!options.inheritProjectContext) {
|
|
@@ -74,9 +102,10 @@ export function rewriteSubagentPrompt(
|
|
|
74
102
|
rewritten = stripInheritedSkills(rewritten);
|
|
75
103
|
}
|
|
76
104
|
rewritten = stripSubagentOrchestrationSkill(rewritten);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
105
|
+
rewritten = stripChildBoundaryInstructions(rewritten);
|
|
106
|
+
const boundary = options.fanoutChild ? CHILD_FANOUT_BOUNDARY_INSTRUCTIONS : CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS;
|
|
107
|
+
const structured = process.env[STRUCTURED_OUTPUT_CAPTURE_ENV] ? `\n\n${STRUCTURED_OUTPUT_INSTRUCTIONS}` : "";
|
|
108
|
+
return `${boundary}${structured}\n\n${rewritten}`;
|
|
80
109
|
}
|
|
81
110
|
|
|
82
111
|
function isParentOnlySubagentMessage(message: unknown): boolean {
|
|
@@ -125,13 +154,52 @@ export function stripParentOnlySubagentMessages(messages: unknown[]): unknown[]
|
|
|
125
154
|
}
|
|
126
155
|
|
|
127
156
|
export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
|
|
128
|
-
|
|
157
|
+
const structuredOutputPath = process.env[STRUCTURED_OUTPUT_CAPTURE_ENV];
|
|
158
|
+
const structuredSchemaPath = process.env[STRUCTURED_OUTPUT_SCHEMA_ENV];
|
|
159
|
+
if (structuredOutputPath && structuredSchemaPath) {
|
|
160
|
+
const schema = JSON.parse(fs.readFileSync(structuredSchemaPath, "utf-8")) as JsonSchemaObject;
|
|
161
|
+
const parameters = {
|
|
162
|
+
type: "object",
|
|
163
|
+
properties: { value: schema },
|
|
164
|
+
required: ["value"],
|
|
165
|
+
additionalProperties: false,
|
|
166
|
+
};
|
|
167
|
+
const registerTool = pi.registerTool as unknown as (tool: {
|
|
168
|
+
name: string;
|
|
169
|
+
label: string;
|
|
170
|
+
description: string;
|
|
171
|
+
parameters: unknown;
|
|
172
|
+
execute: (_id: string, params: { value: unknown }) => Promise<unknown>;
|
|
173
|
+
}) => void;
|
|
174
|
+
registerTool({
|
|
175
|
+
name: "structured_output",
|
|
176
|
+
label: "Structured Output",
|
|
177
|
+
description: "Submit the required final structured output for this subagent step. This terminates the step.",
|
|
178
|
+
parameters: parameters as never,
|
|
179
|
+
async execute(_id: string, params: { value: unknown }) {
|
|
180
|
+
const validation = validateStructuredOutputValue(schema, params.value);
|
|
181
|
+
if (validation.status === "invalid") {
|
|
182
|
+
throw new Error(`Structured output validation failed: ${validation.message}`);
|
|
183
|
+
}
|
|
184
|
+
fs.mkdirSync(path.dirname(structuredOutputPath), { recursive: true });
|
|
185
|
+
fs.writeFileSync(structuredOutputPath, JSON.stringify(params.value), { mode: 0o600 });
|
|
186
|
+
return {
|
|
187
|
+
content: [{ type: "text", text: "Structured output captured." }],
|
|
188
|
+
details: { path: structuredOutputPath },
|
|
189
|
+
terminate: true,
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown) => unknown) => void;
|
|
196
|
+
onRuntimeEvent("context", (event: { messages: unknown[] }) => {
|
|
129
197
|
const messages = stripParentOnlySubagentMessages(event.messages);
|
|
130
198
|
if (messages === event.messages) return undefined;
|
|
131
199
|
return { messages };
|
|
132
200
|
});
|
|
133
201
|
|
|
134
|
-
|
|
202
|
+
onRuntimeEvent("before_agent_start", async (event: { systemPrompt: string }) => {
|
|
135
203
|
const intercomSessionName = process.env[SUBAGENT_INTERCOM_SESSION_NAME_ENV]?.trim();
|
|
136
204
|
if (intercomSessionName && typeof pi.setSessionName === "function") {
|
|
137
205
|
pi.setSessionName(intercomSessionName);
|
|
@@ -139,10 +207,12 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
|
|
|
139
207
|
|
|
140
208
|
const inheritProjectContext = readBooleanEnv(SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV);
|
|
141
209
|
const inheritSkills = readBooleanEnv(SUBAGENT_INHERIT_SKILLS_ENV);
|
|
142
|
-
|
|
210
|
+
const fanoutChild = readBooleanEnv(SUBAGENT_FANOUT_CHILD_ENV);
|
|
211
|
+
if (inheritProjectContext === undefined && inheritSkills === undefined && fanoutChild === undefined) return;
|
|
143
212
|
const rewritten = rewriteSubagentPrompt(event.systemPrompt, {
|
|
144
213
|
inheritProjectContext: inheritProjectContext ?? true,
|
|
145
214
|
inheritSkills: inheritSkills ?? true,
|
|
215
|
+
fanoutChild: fanoutChild === true,
|
|
146
216
|
});
|
|
147
217
|
if (rewritten === event.systemPrompt) return;
|
|
148
218
|
return { systemPrompt: rewritten };
|