@henryqw/pi-subagent 3.0.2 → 3.1.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/CONTEXT.md +8 -4
- package/README.md +54 -62
- package/dist/ephemeral.d.ts +50 -0
- package/dist/ephemeral.js +651 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +11 -3
- package/docs/adr/001-composable-ephemeral-execution.md +19 -0
- package/docs/orchestration.md +342 -0
- package/examples/roles/implementer.md +14 -0
- package/examples/roles/reviewer.md +11 -0
- package/examples/roles/scout.md +17 -0
- package/examples/roles/synthesizer.md +18 -0
- package/extensions/result-transport.ts +213 -0
- package/extensions/role-tools.ts +1 -1
- package/extensions/subagent.ts +366 -573
- package/extensions/workflow.ts +202 -0
- package/package.json +4 -2
package/extensions/subagent.ts
CHANGED
|
@@ -1,55 +1,53 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { basename } from "node:path";
|
|
4
|
-
import { StringDecoder } from "node:string_decoder";
|
|
5
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
6
|
-
import { type AgentSessionEvent, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
2
|
+
import { type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
7
3
|
import { type Component, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
-
import { DEFAULT_TIMEOUT_CONFIG, readSubagentConfig, type SubagentTimeoutConfig } from "./config.ts";
|
|
9
4
|
import {
|
|
10
5
|
availableTaskModels,
|
|
11
|
-
THINKING_LEVELS,
|
|
12
6
|
type ThinkingLevel,
|
|
13
7
|
modelReference,
|
|
14
|
-
PROFILE_NAMES,
|
|
15
8
|
resolveAvailableModel,
|
|
16
9
|
resolveConfiguredTaskRoute,
|
|
17
10
|
type ResolvedTaskRoute,
|
|
18
11
|
taskThinkingLevels,
|
|
19
12
|
} from "@henryqw/pi-task-models";
|
|
20
|
-
import {
|
|
21
|
-
|
|
13
|
+
import {
|
|
14
|
+
capEphemeralSubagentOutput as capOutput,
|
|
15
|
+
createChildWorktree,
|
|
16
|
+
createEphemeralSubagentExecutor,
|
|
17
|
+
createRoleLaunch,
|
|
18
|
+
EphemeralSubagentError,
|
|
19
|
+
finalizeChildWorktree,
|
|
20
|
+
loadRoles,
|
|
21
|
+
resolveTaskRoute,
|
|
22
|
+
worktreeContextNote,
|
|
23
|
+
type EphemeralSubagentResult,
|
|
24
|
+
type EphemeralSubagentTimeout,
|
|
25
|
+
type Role,
|
|
26
|
+
type WorktreeInfo,
|
|
27
|
+
type WorktreePayload,
|
|
28
|
+
} from "@henryqw/pi-subagent";
|
|
29
|
+
import { DEFAULT_TIMEOUT_CONFIG, readSubagentConfig, type SubagentTimeoutConfig } from "./config.ts";
|
|
30
|
+
import {
|
|
31
|
+
formatBackgroundWorkflowResult,
|
|
32
|
+
formatWorkflowResult,
|
|
33
|
+
formatWorkflowUpdate,
|
|
34
|
+
WorkflowAbortedError,
|
|
35
|
+
WorkflowFailureError,
|
|
36
|
+
type WorkflowTransportEntry,
|
|
37
|
+
} from "./result-transport.ts";
|
|
38
|
+
import {
|
|
39
|
+
identifyWorkflowEntries,
|
|
40
|
+
parseWorkflow,
|
|
41
|
+
runForegroundWorkflow,
|
|
42
|
+
WorkflowSchema,
|
|
43
|
+
type Delegation,
|
|
44
|
+
type ParsedWorkflow,
|
|
45
|
+
type WorkflowEntry,
|
|
46
|
+
} from "./workflow.ts";
|
|
47
|
+
|
|
48
|
+
export { capOutput };
|
|
22
49
|
|
|
23
50
|
const SUBAGENT_TASK = "pi-subagent/delegateTask";
|
|
24
|
-
const MAX_OUTPUT_BYTES = 50 * 1024;
|
|
25
|
-
const MAX_JSON_EVENT_BYTES = 1024 * 1024;
|
|
26
|
-
const PI_JSON_EVENTS = {
|
|
27
|
-
agent_start: true,
|
|
28
|
-
agent_end: true,
|
|
29
|
-
agent_settled: true,
|
|
30
|
-
turn_start: true,
|
|
31
|
-
turn_end: true,
|
|
32
|
-
message_start: true,
|
|
33
|
-
message_update: true,
|
|
34
|
-
message_end: true,
|
|
35
|
-
tool_execution_start: true,
|
|
36
|
-
tool_execution_update: true,
|
|
37
|
-
tool_execution_end: true,
|
|
38
|
-
queue_update: true,
|
|
39
|
-
compaction_start: true,
|
|
40
|
-
compaction_end: true,
|
|
41
|
-
entry_appended: true,
|
|
42
|
-
session_info_changed: true,
|
|
43
|
-
thinking_level_changed: true,
|
|
44
|
-
auto_retry_start: true,
|
|
45
|
-
auto_retry_end: true,
|
|
46
|
-
summarization_retry_scheduled: true,
|
|
47
|
-
summarization_retry_attempt_start: true,
|
|
48
|
-
summarization_retry_finished: true,
|
|
49
|
-
bash_execution_update: true,
|
|
50
|
-
} satisfies Record<AgentSessionEvent["type"], true>;
|
|
51
|
-
const CONSUMED_JSON_EVENTS = new Set(["message_start", "message_update", "message_end"]);
|
|
52
|
-
const JSON_EVENT_TYPE = /^\s*\{\s*"type"\s*:\s*"([^"\\]+)"/;
|
|
53
51
|
const WIDGET_KEY = "subagent-status";
|
|
54
52
|
const WIDGET_INTERVAL_MS = 80;
|
|
55
53
|
const TERMINAL_DISPLAY_MS = 1_000;
|
|
@@ -60,7 +58,7 @@ const DEFAULT_TIMEOUT_POLICY = {
|
|
|
60
58
|
};
|
|
61
59
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
62
60
|
|
|
63
|
-
type TimeoutPolicy =
|
|
61
|
+
type TimeoutPolicy = EphemeralSubagentTimeout;
|
|
64
62
|
|
|
65
63
|
/** Merge validated config-file timeout fields over defaults; absent keys keep defaults. */
|
|
66
64
|
export function resolveTimeoutPolicy(partial: SubagentTimeoutConfig | undefined): TimeoutPolicy {
|
|
@@ -69,19 +67,6 @@ export function resolveTimeoutPolicy(partial: SubagentTimeoutConfig | undefined)
|
|
|
69
67
|
maxMs: partial?.maxMinutes === undefined ? DEFAULT_TIMEOUT_POLICY.maxMs : partial.maxMinutes * 60_000,
|
|
70
68
|
};
|
|
71
69
|
}
|
|
72
|
-
class SubagentTimeoutError extends Error {}
|
|
73
|
-
type ChildResult = {
|
|
74
|
-
exitCode: number;
|
|
75
|
-
output: string;
|
|
76
|
-
stderr: string;
|
|
77
|
-
stopReason?: string;
|
|
78
|
-
errorMessage?: string;
|
|
79
|
-
};
|
|
80
|
-
type DelegateResult = {
|
|
81
|
-
content: [{ type: "text"; text: string }];
|
|
82
|
-
details: Record<string, unknown>;
|
|
83
|
-
isError?: boolean;
|
|
84
|
-
};
|
|
85
70
|
type WidgetStatus = "working" | "success" | "failure" | "aborted";
|
|
86
71
|
type WidgetItem = {
|
|
87
72
|
roleRoute: string;
|
|
@@ -93,80 +78,10 @@ type WidgetItem = {
|
|
|
93
78
|
removeAt?: number;
|
|
94
79
|
};
|
|
95
80
|
|
|
96
|
-
const cleanText = (value: unknown, field: string, file: string): string => {
|
|
97
|
-
if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
|
|
98
|
-
throw new Error(`${file}: ${field} must be non-empty text.`);
|
|
99
|
-
}
|
|
100
|
-
return value.trim();
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
function piInvocation(args: string[]): { command: string; args: string[] } {
|
|
104
|
-
const currentScript = process.argv[1];
|
|
105
|
-
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
106
|
-
if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
|
|
107
|
-
return { command: process.execPath, args: [currentScript, ...args] };
|
|
108
|
-
}
|
|
109
|
-
const executable = basename(process.execPath).toLowerCase();
|
|
110
|
-
if (!/^(node|bun)(\.exe)?$/.test(executable)) return { command: process.execPath, args };
|
|
111
|
-
return { command: "pi", args };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function assistantText(message: unknown): string | undefined {
|
|
115
|
-
if (!message || typeof message !== "object" || Array.isArray(message)) return;
|
|
116
|
-
const record = message as Record<string, unknown>;
|
|
117
|
-
if (record.role !== "assistant" || !Array.isArray(record.content)) return;
|
|
118
|
-
const text = record.content
|
|
119
|
-
.filter((part): part is { type: "text"; text: string } =>
|
|
120
|
-
Boolean(part && typeof part === "object" && !Array.isArray(part)
|
|
121
|
-
&& (part as Record<string, unknown>).type === "text"
|
|
122
|
-
&& typeof (part as Record<string, unknown>).text === "string"))
|
|
123
|
-
.map((part) => part.text)
|
|
124
|
-
.join("\n");
|
|
125
|
-
return text || undefined;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function utf8Prefix(text: string, maxBytes: number): string {
|
|
129
|
-
return new StringDecoder().write(Buffer.from(text).subarray(0, maxBytes));
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function cappedPrefix(text: string, totalBytes: number): string {
|
|
133
|
-
if (totalBytes <= MAX_OUTPUT_BYTES) return text;
|
|
134
|
-
const worstCaseMarker = `\n\n[Output truncated: ${totalBytes} bytes omitted]`;
|
|
135
|
-
const prefix = utf8Prefix(text, MAX_OUTPUT_BYTES - Buffer.byteLength(worstCaseMarker, "utf8"));
|
|
136
|
-
const omittedBytes = totalBytes - Buffer.byteLength(prefix, "utf8");
|
|
137
|
-
return `${prefix}\n\n[Output truncated: ${omittedBytes} bytes omitted]`;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export function capOutput(text: string): string {
|
|
141
|
-
return cappedPrefix(text, Buffer.byteLength(text, "utf8"));
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
type BoundedText = { prefix: string; totalBytes: number };
|
|
145
|
-
|
|
146
|
-
function appendBounded(target: BoundedText, text: string): void {
|
|
147
|
-
target.totalBytes += Buffer.byteLength(text, "utf8");
|
|
148
|
-
const remaining = MAX_OUTPUT_BYTES - Buffer.byteLength(target.prefix, "utf8");
|
|
149
|
-
if (remaining > 0) target.prefix += utf8Prefix(text, remaining);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function boundedText(target: BoundedText): string {
|
|
153
|
-
return cappedPrefix(target.prefix, target.totalBytes);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
81
|
function taskSummary(task: string): string {
|
|
157
82
|
return task.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().split(/\s+/).slice(0, 4).join(" ");
|
|
158
83
|
}
|
|
159
84
|
|
|
160
|
-
/** Finalizes an isolated child's worktree; returns its report, or undefined when absent/failed. */
|
|
161
|
-
async function finalizeWorktreePayload(worktree: WorktreeInfo | undefined): Promise<WorktreePayload | undefined> {
|
|
162
|
-
if (!worktree) return undefined;
|
|
163
|
-
try {
|
|
164
|
-
return await finalizeChildWorktree(worktree);
|
|
165
|
-
} catch {
|
|
166
|
-
return undefined;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
85
|
function formatTokens(tokens: number): string {
|
|
171
86
|
if (tokens < 1_000) return String(tokens);
|
|
172
87
|
if (tokens < 100_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
|
@@ -181,12 +96,6 @@ function formatElapsed(startedAt: number, finishedAt = Date.now()): string {
|
|
|
181
96
|
return hours ? `${hours}h ${minutes}m` : minutes ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
|
|
182
97
|
}
|
|
183
98
|
|
|
184
|
-
function usageTokens(value: unknown): number | undefined {
|
|
185
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
186
|
-
const total = (value as Record<string, unknown>).totalTokens;
|
|
187
|
-
return typeof total === "number" && Number.isFinite(total) && total >= 0 ? Math.round(total) : undefined;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
99
|
function statusGlyph(status: WidgetStatus, spinnerIndex: number, theme: Theme): string {
|
|
191
100
|
switch (status) {
|
|
192
101
|
case "working": return theme.fg("accent", SPINNER_FRAMES[spinnerIndex % SPINNER_FRAMES.length]!);
|
|
@@ -240,270 +149,6 @@ function renderWidgetRows(
|
|
|
240
149
|
return lines;
|
|
241
150
|
}
|
|
242
151
|
|
|
243
|
-
async function runPi(
|
|
244
|
-
args: string[],
|
|
245
|
-
cwd: string,
|
|
246
|
-
signal: AbortSignal | undefined,
|
|
247
|
-
onUpdate: ((text: string) => void) | undefined,
|
|
248
|
-
onTokens: ((tokens: number) => void) | undefined,
|
|
249
|
-
timeoutPolicy: TimeoutPolicy,
|
|
250
|
-
): Promise<ChildResult> {
|
|
251
|
-
if (signal?.aborted) throw new Error("Subagent was aborted.");
|
|
252
|
-
return await new Promise<ChildResult>((resolve, reject) => {
|
|
253
|
-
const invocation = piInvocation(args);
|
|
254
|
-
const child = spawn(invocation.command, invocation.args, {
|
|
255
|
-
cwd,
|
|
256
|
-
shell: false,
|
|
257
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
258
|
-
detached: process.platform !== "win32",
|
|
259
|
-
});
|
|
260
|
-
child.stdout.setEncoding("utf8");
|
|
261
|
-
child.stderr.setEncoding("utf8");
|
|
262
|
-
let lineParts: string[] = [];
|
|
263
|
-
let lineBytes = 0;
|
|
264
|
-
let linePrefix = "";
|
|
265
|
-
let lineEventType: string | undefined;
|
|
266
|
-
let ignoreLine = false;
|
|
267
|
-
let output = "";
|
|
268
|
-
const stderr = { prefix: "", totalBytes: 0 };
|
|
269
|
-
const partial = { prefix: "", totalBytes: 0 };
|
|
270
|
-
let hasPartialText = false;
|
|
271
|
-
let stopReason: string | undefined;
|
|
272
|
-
let errorMessage: string | undefined;
|
|
273
|
-
let spawnError: Error | undefined;
|
|
274
|
-
let protocolError: Error | undefined;
|
|
275
|
-
let aborted = false;
|
|
276
|
-
const startedAt = Date.now();
|
|
277
|
-
const maxDeadline = startedAt + timeoutPolicy.maxMs;
|
|
278
|
-
let lastEventAt = startedAt;
|
|
279
|
-
let deadline = Math.min(startedAt + timeoutPolicy.idleMs, maxDeadline);
|
|
280
|
-
let timedOutAfterMs: number | undefined;
|
|
281
|
-
let timeoutReason: "idle" | "maximum" | undefined;
|
|
282
|
-
let childExited = false;
|
|
283
|
-
let completedTokens = 0;
|
|
284
|
-
let currentTokens = 0;
|
|
285
|
-
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
286
|
-
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
287
|
-
|
|
288
|
-
const scheduleDeadline = () => {
|
|
289
|
-
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
290
|
-
deadline = Math.min(lastEventAt + timeoutPolicy.idleMs, maxDeadline);
|
|
291
|
-
const scheduledDeadline = deadline;
|
|
292
|
-
deadlineTimer = setTimeout(
|
|
293
|
-
() => timeout(scheduledDeadline - startedAt, scheduledDeadline === maxDeadline ? "maximum" : "idle"),
|
|
294
|
-
Math.max(0, scheduledDeadline - Date.now()),
|
|
295
|
-
);
|
|
296
|
-
deadlineTimer.unref();
|
|
297
|
-
};
|
|
298
|
-
|
|
299
|
-
const observeEvent = () => {
|
|
300
|
-
if (aborted || timedOutAfterMs !== undefined || childExited) return;
|
|
301
|
-
const now = Date.now();
|
|
302
|
-
if (now >= deadline) {
|
|
303
|
-
timeout(deadline - startedAt, deadline === maxDeadline ? "maximum" : "idle");
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
lastEventAt = now;
|
|
307
|
-
scheduleDeadline();
|
|
308
|
-
};
|
|
309
|
-
|
|
310
|
-
const processLine = (line: string) => {
|
|
311
|
-
if (!line.trim()) return;
|
|
312
|
-
let event: unknown;
|
|
313
|
-
try {
|
|
314
|
-
event = JSON.parse(line);
|
|
315
|
-
} catch {
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
if (!event || typeof event !== "object" || Array.isArray(event)) return;
|
|
319
|
-
const record = event as Record<string, unknown>;
|
|
320
|
-
if (typeof record.type !== "string" || !Object.hasOwn(PI_JSON_EVENTS, record.type)) return;
|
|
321
|
-
observeEvent();
|
|
322
|
-
if (record.type === "message_start") {
|
|
323
|
-
partial.prefix = "";
|
|
324
|
-
partial.totalBytes = 0;
|
|
325
|
-
hasPartialText = false;
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
if (record.type === "message_update") {
|
|
329
|
-
const tokens = usageTokens(record.usage);
|
|
330
|
-
if (tokens !== undefined) {
|
|
331
|
-
currentTokens = tokens;
|
|
332
|
-
onTokens?.(completedTokens + currentTokens);
|
|
333
|
-
}
|
|
334
|
-
const update = record.assistantMessageEvent;
|
|
335
|
-
if (update && typeof update === "object" && !Array.isArray(update)) {
|
|
336
|
-
const assistantEvent = update as Record<string, unknown>;
|
|
337
|
-
if (assistantEvent.type === "text_start" && hasPartialText) appendBounded(partial, "\n");
|
|
338
|
-
if (assistantEvent.type === "text_start") hasPartialText = true;
|
|
339
|
-
if (assistantEvent.type === "text_delta" && typeof assistantEvent.delta === "string") {
|
|
340
|
-
hasPartialText = true;
|
|
341
|
-
appendBounded(partial, assistantEvent.delta);
|
|
342
|
-
output = boundedText(partial);
|
|
343
|
-
onUpdate?.(output);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
|
-
if (record.type !== "message_end") return;
|
|
349
|
-
const text = assistantText(record.message);
|
|
350
|
-
if (text !== undefined) {
|
|
351
|
-
output = capOutput(text);
|
|
352
|
-
onUpdate?.(output);
|
|
353
|
-
}
|
|
354
|
-
if (record.message && typeof record.message === "object" && !Array.isArray(record.message)) {
|
|
355
|
-
const message = record.message as Record<string, unknown>;
|
|
356
|
-
if (message.role === "assistant") {
|
|
357
|
-
completedTokens += usageTokens(message.usage) ?? currentTokens;
|
|
358
|
-
currentTokens = 0;
|
|
359
|
-
onTokens?.(completedTokens);
|
|
360
|
-
}
|
|
361
|
-
if (typeof message.stopReason === "string") stopReason = message.stopReason;
|
|
362
|
-
if (typeof message.errorMessage === "string") errorMessage = message.errorMessage;
|
|
363
|
-
}
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
const killTree = async (force: boolean): Promise<void> => {
|
|
367
|
-
if (!child.pid) return;
|
|
368
|
-
if (process.platform === "win32") {
|
|
369
|
-
await new Promise<void>((resolve) => {
|
|
370
|
-
const taskkill = spawn("taskkill", [...(force ? ["/F"] : []), "/T", "/PID", String(child.pid)], {
|
|
371
|
-
stdio: "ignore",
|
|
372
|
-
windowsHide: true,
|
|
373
|
-
});
|
|
374
|
-
taskkill.once("error", () => resolve());
|
|
375
|
-
taskkill.once("close", () => resolve());
|
|
376
|
-
});
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
try {
|
|
380
|
-
process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
|
|
381
|
-
} catch {
|
|
382
|
-
child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
383
|
-
}
|
|
384
|
-
};
|
|
385
|
-
|
|
386
|
-
child.stdout.on("data", (data: string) => {
|
|
387
|
-
if (protocolError) return;
|
|
388
|
-
let offset = 0;
|
|
389
|
-
while (offset < data.length) {
|
|
390
|
-
const newline = data.indexOf("\n", offset);
|
|
391
|
-
const end = newline === -1 ? data.length : newline;
|
|
392
|
-
const part = data.slice(offset, end);
|
|
393
|
-
if (!ignoreLine) {
|
|
394
|
-
linePrefix += part.slice(0, Math.max(0, 256 - linePrefix.length));
|
|
395
|
-
const eventType = JSON_EVENT_TYPE.exec(linePrefix)?.[1];
|
|
396
|
-
if (eventType && !lineEventType) lineEventType = eventType;
|
|
397
|
-
lineBytes += Buffer.byteLength(part, "utf8");
|
|
398
|
-
if (lineBytes > MAX_JSON_EVENT_BYTES) {
|
|
399
|
-
if (lineEventType && !CONSUMED_JSON_EVENTS.has(lineEventType)) {
|
|
400
|
-
ignoreLine = true;
|
|
401
|
-
lineParts = [];
|
|
402
|
-
lineBytes = 0;
|
|
403
|
-
} else {
|
|
404
|
-
protocolError = new Error(`Subagent JSON event exceeds ${MAX_JSON_EVENT_BYTES} bytes.`);
|
|
405
|
-
void killTree(true);
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
} else if (part) lineParts.push(part);
|
|
409
|
-
}
|
|
410
|
-
if (newline === -1) return;
|
|
411
|
-
if (!ignoreLine) processLine(lineParts.join(""));
|
|
412
|
-
lineParts = [];
|
|
413
|
-
lineBytes = 0;
|
|
414
|
-
linePrefix = "";
|
|
415
|
-
lineEventType = undefined;
|
|
416
|
-
ignoreLine = false;
|
|
417
|
-
offset = newline + 1;
|
|
418
|
-
}
|
|
419
|
-
});
|
|
420
|
-
child.stderr.on("data", (data: string) => {
|
|
421
|
-
appendBounded(stderr, data);
|
|
422
|
-
});
|
|
423
|
-
child.on("error", (error) => { spawnError = error; });
|
|
424
|
-
|
|
425
|
-
const stop = (force = false) => {
|
|
426
|
-
if (force) {
|
|
427
|
-
void killTree(true);
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
void killTree(false);
|
|
431
|
-
killTimer = setTimeout(
|
|
432
|
-
() => void killTree(true),
|
|
433
|
-
Math.min(5_000, Math.max(0, maxDeadline - Date.now())),
|
|
434
|
-
);
|
|
435
|
-
killTimer.unref();
|
|
436
|
-
};
|
|
437
|
-
const abort = () => {
|
|
438
|
-
if (timedOutAfterMs !== undefined || childExited) return;
|
|
439
|
-
aborted = true;
|
|
440
|
-
stop();
|
|
441
|
-
};
|
|
442
|
-
function timeout(afterMs: number, reason: "idle" | "maximum") {
|
|
443
|
-
if (timedOutAfterMs !== undefined || childExited) return;
|
|
444
|
-
if (reason === "maximum") {
|
|
445
|
-
if (!aborted) {
|
|
446
|
-
timedOutAfterMs = afterMs;
|
|
447
|
-
timeoutReason = reason;
|
|
448
|
-
}
|
|
449
|
-
stop(true);
|
|
450
|
-
return;
|
|
451
|
-
}
|
|
452
|
-
if (aborted) return;
|
|
453
|
-
timedOutAfterMs = afterMs;
|
|
454
|
-
timeoutReason = reason;
|
|
455
|
-
stop();
|
|
456
|
-
}
|
|
457
|
-
scheduleDeadline();
|
|
458
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
459
|
-
if (signal?.aborted) abort();
|
|
460
|
-
|
|
461
|
-
// `close` waits for stdio EOF, which descendants can hold after Pi exits.
|
|
462
|
-
// Kill the process group at Pi's exit boundary so `close` can settle.
|
|
463
|
-
child.once("exit", () => {
|
|
464
|
-
childExited = true;
|
|
465
|
-
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
466
|
-
signal?.removeEventListener("abort", abort);
|
|
467
|
-
void killTree(true);
|
|
468
|
-
});
|
|
469
|
-
child.on("close", async (code) => {
|
|
470
|
-
if (!protocolError && lineBytes) processLine(lineParts.join(""));
|
|
471
|
-
await killTree(true);
|
|
472
|
-
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
473
|
-
if (killTimer) clearTimeout(killTimer);
|
|
474
|
-
signal?.removeEventListener("abort", abort);
|
|
475
|
-
if (aborted) reject(new Error("Subagent was aborted."));
|
|
476
|
-
else if (timedOutAfterMs !== undefined) reject(new SubagentTimeoutError(
|
|
477
|
-
timeoutReason === "maximum"
|
|
478
|
-
? `Subagent reached its maximum runtime after ${formatElapsed(0, timedOutAfterMs)}.`
|
|
479
|
-
: `Subagent timed out after ${formatElapsed(0, timeoutPolicy.idleMs)} without a recognized Pi event.`,
|
|
480
|
-
));
|
|
481
|
-
else if (protocolError) reject(protocolError);
|
|
482
|
-
else if (spawnError) reject(spawnError);
|
|
483
|
-
else resolve({ exitCode: code ?? 1, output, stderr: boundedText(stderr), stopReason, errorMessage });
|
|
484
|
-
});
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
const Parameters = Type.Object({
|
|
489
|
-
role: Type.String({ description: "Configured Subagent role name" }),
|
|
490
|
-
task: Type.String({
|
|
491
|
-
description: "Bounded task packet: objective; exact scope and exclusions; relevant context and constraints; expected deliverable; validation. Never the whole parent request.",
|
|
492
|
-
}),
|
|
493
|
-
model: Type.Optional(Type.String({
|
|
494
|
-
description: "Designated model as provider/modelId; overrides modelClass. Unknown references reject with the list of available models.",
|
|
495
|
-
})),
|
|
496
|
-
modelClass: Type.Optional(StringEnum(PROFILE_NAMES, {
|
|
497
|
-
description: "Classify task complexity: fast for narrow lookups or mechanical edits; balanced for normal bounded work; frontier for ambiguous, cross-cutting, or high-risk reasoning; fav for the user's favorite model when they ask for it. Defaults to the shared pi-subagent/delegateTask assignment.",
|
|
498
|
-
})),
|
|
499
|
-
background: Type.Optional(Type.Boolean({
|
|
500
|
-
description: "Run without blocking: returns a task ID immediately and delivers the outcome as a message when the Subagent settles; the result cannot be waited on. Set only when the user explicitly asks for non-blocking delegation. Prefer blocking delegation whenever the parent needs the result to continue.",
|
|
501
|
-
})),
|
|
502
|
-
thinking: Type.Optional(StringEnum(THINKING_LEVELS, {
|
|
503
|
-
description: "Override the resolved route's thinking level (e.g. when the user asks for deeper or lighter reasoning). Must be supported by the resolved model.",
|
|
504
|
-
})),
|
|
505
|
-
});
|
|
506
|
-
|
|
507
152
|
function resolveDesignatedRoute(ctx: ExtensionContext, reference: string, thinking?: ThinkingLevel): ResolvedTaskRoute {
|
|
508
153
|
const models = availableTaskModels(ctx);
|
|
509
154
|
const model = resolveAvailableModel(models, reference, ctx.model?.provider);
|
|
@@ -511,7 +156,7 @@ function resolveDesignatedRoute(ctx: ExtensionContext, reference: string, thinki
|
|
|
511
156
|
throw new Error(`Unknown delegate_task model: ${reference}. Available models: ${models.map((candidate) => modelReference(candidate)).join(", ") || "none"}.`);
|
|
512
157
|
}
|
|
513
158
|
const levels = taskThinkingLevels(ctx, model);
|
|
514
|
-
|
|
159
|
+
if (thinking !== undefined) {
|
|
515
160
|
if (!levels.includes(thinking)) {
|
|
516
161
|
throw new Error(`delegate_task thinking ${thinking} is not usable for ${modelReference(model)} in this session. Usable levels here: ${levels.join(", ") || "none"}.`);
|
|
517
162
|
}
|
|
@@ -525,6 +170,20 @@ function resolveDesignatedRoute(ctx: ExtensionContext, reference: string, thinki
|
|
|
525
170
|
|
|
526
171
|
const BACKGROUND_RESULT_TYPE = "subagent-background-result";
|
|
527
172
|
|
|
173
|
+
function boundedError(error: unknown): Error {
|
|
174
|
+
const message = capOutput(error instanceof Error ? error.message : String(error));
|
|
175
|
+
return error instanceof Error && error.message === message ? error : new Error(message, { cause: error });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function failedToolPatch(error: WorkflowFailureError | WorkflowAbortedError) {
|
|
179
|
+
return {
|
|
180
|
+
content: [{ type: "text" as const, text: error.message }],
|
|
181
|
+
details: error.details,
|
|
182
|
+
isError: true as const,
|
|
183
|
+
...(error.usage === undefined ? {} : { usage: error.usage }),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
528
187
|
const roleSummary = (): string => {
|
|
529
188
|
try {
|
|
530
189
|
const roles = loadRoles();
|
|
@@ -551,11 +210,11 @@ export default function subagentExtension(
|
|
|
551
210
|
// Reject "2workers", "1.5", "1e3" — parseInt would silently accept prefixes —
|
|
552
211
|
// and digit strings that overflow to Infinity, which would disable the cap.
|
|
553
212
|
if (!/^\d+$/.test(maxSubagentsRaw) || !/^[1-9]\d*$/.test(maxSubagentsRaw)) {
|
|
554
|
-
throw new Error(`PI_SUBAGENT_MAX_SUBAGENTS must be a positive integer, got ${JSON.stringify(maxSubagentsRaw)}.`);
|
|
213
|
+
throw boundedError(new Error(`PI_SUBAGENT_MAX_SUBAGENTS must be a positive integer, got ${JSON.stringify(maxSubagentsRaw)}.`));
|
|
555
214
|
}
|
|
556
215
|
const parsed = Number.parseInt(maxSubagentsRaw, 10);
|
|
557
216
|
if (!Number.isSafeInteger(parsed)) {
|
|
558
|
-
throw new Error(`PI_SUBAGENT_MAX_SUBAGENTS exceeds the supported range, got ${JSON.stringify(maxSubagentsRaw)}.`);
|
|
217
|
+
throw boundedError(new Error(`PI_SUBAGENT_MAX_SUBAGENTS exceeds the supported range, got ${JSON.stringify(maxSubagentsRaw)}.`));
|
|
559
218
|
}
|
|
560
219
|
maxActiveSubagents = parsed;
|
|
561
220
|
}
|
|
@@ -563,44 +222,17 @@ export default function subagentExtension(
|
|
|
563
222
|
// Explicit policy argument (tests/embedders) wins; otherwise resolve from
|
|
564
223
|
// config file over defaults.
|
|
565
224
|
const timeoutPolicy: TimeoutPolicy = overrideTimeoutPolicy ?? resolveTimeoutPolicy(loadedConfig.config.timeout);
|
|
225
|
+
const executor = createEphemeralSubagentExecutor({ maxConcurrency: maxActiveSubagents, timeout: timeoutPolicy });
|
|
566
226
|
// Background children outlive the launching tool call, so they get their own
|
|
567
227
|
// abort signal: tied to the session, not to the turn that started them.
|
|
568
228
|
const backgroundTasks = new Map<string, { controller: AbortController; settled: Promise<void> }>();
|
|
229
|
+
const failedToolPatches = new Map<string, ReturnType<typeof failedToolPatch>>();
|
|
569
230
|
// Latest known session context; refreshed on session lifecycle and model
|
|
570
231
|
// changes so queued background launches resolve against effective state.
|
|
571
232
|
let latestCtx: ExtensionContext | undefined;
|
|
572
233
|
// Bumped by session_start and session_shutdown; background tasks may only
|
|
573
234
|
// deliver into the exact session that launched them.
|
|
574
235
|
let sessionEpoch = 0;
|
|
575
|
-
let activeChildren = 0;
|
|
576
|
-
const queuedChildren: Array<() => void> = [];
|
|
577
|
-
const acquireChildPermit = (signal: AbortSignal | undefined): Promise<void> => {
|
|
578
|
-
if (signal?.aborted) return Promise.reject(new Error("Subagent was aborted."));
|
|
579
|
-
if (activeChildren < maxActiveSubagents) {
|
|
580
|
-
activeChildren++;
|
|
581
|
-
return Promise.resolve();
|
|
582
|
-
}
|
|
583
|
-
return new Promise<void>((resolve, reject) => {
|
|
584
|
-
function abort() {
|
|
585
|
-
const index = queuedChildren.indexOf(grant);
|
|
586
|
-
if (index < 0) return;
|
|
587
|
-
queuedChildren.splice(index, 1);
|
|
588
|
-
signal?.removeEventListener("abort", abort);
|
|
589
|
-
reject(new Error("Subagent was aborted."));
|
|
590
|
-
}
|
|
591
|
-
const grant = () => {
|
|
592
|
-
signal?.removeEventListener("abort", abort);
|
|
593
|
-
resolve();
|
|
594
|
-
};
|
|
595
|
-
queuedChildren.push(grant);
|
|
596
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
597
|
-
});
|
|
598
|
-
};
|
|
599
|
-
const releaseChildPermit = () => {
|
|
600
|
-
const grant = queuedChildren.shift();
|
|
601
|
-
if (grant) grant();
|
|
602
|
-
else activeChildren--;
|
|
603
|
-
};
|
|
604
236
|
let widgetInstalled = false;
|
|
605
237
|
let widgetTimer: ReturnType<typeof setInterval> | undefined;
|
|
606
238
|
let spinnerIndex = 0;
|
|
@@ -684,6 +316,7 @@ export default function subagentExtension(
|
|
|
684
316
|
for (const warning of startupWarnings.splice(0)) ctx.ui.notify(warning, "warning");
|
|
685
317
|
});
|
|
686
318
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
319
|
+
failedToolPatches.clear();
|
|
687
320
|
stopWidgetTimer();
|
|
688
321
|
widgetItems.clear();
|
|
689
322
|
activeTui = undefined;
|
|
@@ -705,75 +338,130 @@ export default function subagentExtension(
|
|
|
705
338
|
pi.on("agent_settled", (_event, ctx) => {
|
|
706
339
|
latestCtx = ctx;
|
|
707
340
|
});
|
|
341
|
+
pi.on("tool_result", (event) => {
|
|
342
|
+
if (event.toolName !== "delegate_task") return;
|
|
343
|
+
const patch = failedToolPatches.get(event.toolCallId);
|
|
344
|
+
if (!patch) return;
|
|
345
|
+
failedToolPatches.delete(event.toolCallId);
|
|
346
|
+
return patch;
|
|
347
|
+
});
|
|
708
348
|
|
|
709
|
-
const reportBackground =
|
|
349
|
+
const reportBackground = (
|
|
710
350
|
launchEpoch: number,
|
|
711
351
|
taskId: string,
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
setupRecovery?: string,
|
|
717
|
-
): Promise<void> => {
|
|
352
|
+
mode: ParsedWorkflow["mode"],
|
|
353
|
+
entries: readonly WorkflowTransportEntry[],
|
|
354
|
+
setupRecoveries: ReadonlyMap<string, string>,
|
|
355
|
+
): void => {
|
|
718
356
|
const stale = launchEpoch !== sessionEpoch;
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
357
|
+
const retained = entries.filter(({ worktreePayload }) => worktreePayload && !worktreePayload.pruned);
|
|
358
|
+
if (stale && !retained.length && !setupRecoveries.size) return;
|
|
359
|
+
const transport = formatBackgroundWorkflowResult(mode, entries);
|
|
360
|
+
const outcome = stale ? "aborted" : transport.failed ? "failed" : "completed";
|
|
361
|
+
const content = stale
|
|
362
|
+
? capOutput([
|
|
363
|
+
"Background workflow left recoverable isolated work after session shutdown.",
|
|
364
|
+
`Task ID: ${taskId}`,
|
|
365
|
+
`Mode: ${mode}`,
|
|
366
|
+
"Recovery locations:",
|
|
367
|
+
...retained.map((entry) =>
|
|
368
|
+
`- [${entry.index}] worktree path=${JSON.stringify(entry.worktreePayload!.path)} branch=${JSON.stringify(entry.worktreePayload!.branch)}`),
|
|
369
|
+
...[...setupRecoveries].map(([id, recovery]) => {
|
|
370
|
+
const entry = entries.find((candidate) => candidate.id === id)!;
|
|
371
|
+
return `- [${entry.index}] setup state: ${recovery}`;
|
|
372
|
+
}),
|
|
373
|
+
"Evidence:",
|
|
374
|
+
...retained.map((entry) => {
|
|
375
|
+
const payload = entry.worktreePayload!;
|
|
376
|
+
return `- [${entry.index}] retained worktree commits=${payload.commits} dirty=${payload.dirty} inspection_failed=${payload.inspection_failed === true}`;
|
|
377
|
+
}),
|
|
378
|
+
...[...setupRecoveries].map(([id, recovery]) => {
|
|
379
|
+
const entry = entries.find((candidate) => candidate.id === id)!;
|
|
380
|
+
return `- [${entry.index}] recoverable WorktreeSetupError: ${recovery}`;
|
|
381
|
+
}),
|
|
382
|
+
].join("\n"))
|
|
383
|
+
: transport.text;
|
|
722
384
|
try {
|
|
385
|
+
// Custom messages convert to user-role LLM messages, so the parent agent
|
|
386
|
+
// sees the aggregate on its next turn without forcing one now.
|
|
723
387
|
pi.sendMessage({
|
|
724
388
|
customType: BACKGROUND_RESULT_TYPE,
|
|
725
|
-
content
|
|
726
|
-
? worktreePayload && !worktreePayload.pruned
|
|
727
|
-
? `Background subagent ${taskId} (${details.role}) left recoverable isolated work after session shutdown.\n${JSON.stringify(worktreePayload)}`
|
|
728
|
-
: `Background subagent ${taskId} (${details.role}) left recoverable isolated setup state after session shutdown.\n${setupRecovery}`
|
|
729
|
-
: `Background subagent ${taskId} (${details.role}) ${outcome}.\n\n${capOutput(text)}${worktreePayload ? `\n${JSON.stringify(worktreePayload)}` : ""}`,
|
|
389
|
+
content,
|
|
730
390
|
display: true,
|
|
731
|
-
details: {
|
|
391
|
+
details: {
|
|
392
|
+
...transport.details,
|
|
393
|
+
taskId,
|
|
394
|
+
outcome,
|
|
395
|
+
...(transport.usage === undefined ? {} : { usage: transport.usage }),
|
|
396
|
+
...(stale ? { recovery: true } : {}),
|
|
397
|
+
},
|
|
732
398
|
}, { triggerTurn: false });
|
|
733
|
-
} catch {
|
|
734
|
-
//
|
|
399
|
+
} catch (error) {
|
|
400
|
+
// Delivery can disappear during teardown; only an active UI gets a visible failure.
|
|
401
|
+
if (!stale && latestCtx?.hasUI) {
|
|
402
|
+
latestCtx.ui.notify(boundedError(new Error(
|
|
403
|
+
`Background workflow ${taskId} result delivery failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
404
|
+
)).message, "error");
|
|
405
|
+
}
|
|
735
406
|
}
|
|
736
407
|
};
|
|
737
408
|
|
|
738
409
|
pi.registerTool({
|
|
739
410
|
name: "delegate_task",
|
|
740
411
|
label: "Subagent",
|
|
741
|
-
description: `Delegate one
|
|
742
|
-
promptSnippet: "Delegate one bounded,
|
|
412
|
+
description: `Delegate one selected single, parallel, or chain workflow of bounded tasks to isolated Pi Subagents. Roles: ${roleSummary()}.`,
|
|
413
|
+
promptSnippet: "Delegate one bounded single, parallel, or chain workflow to isolated roles",
|
|
743
414
|
promptGuidelines: [
|
|
744
|
-
"
|
|
745
|
-
"
|
|
746
|
-
"
|
|
747
|
-
"
|
|
748
|
-
"
|
|
415
|
+
"Call delegate_task with exactly one mode: role+task for one task, tasks for 1–8 independent parallel tasks, or chain for 1–8 dependent sequential tasks using {previous} for the immediately preceding assistant output.",
|
|
416
|
+
"Every delegate_task entry must state its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and validation; never pass the parent request unchanged.",
|
|
417
|
+
"For each delegate_task entry, use fav only when the user asks for their favorite model; otherwise choose fast for narrow work, balanced for normal work, and frontier only for ambiguous, cross-cutting, or high-risk work.",
|
|
418
|
+
"Parallel delegate_task entries must own non-overlapping files. Keep integration and cross-cutting decisions in Main, and use the minimum number of Subagents needed.",
|
|
419
|
+
"delegate_task background applies to the whole selected workflow and returns before results exist; use it only when the user explicitly asks for non-blocking work.",
|
|
749
420
|
],
|
|
750
|
-
parameters:
|
|
421
|
+
parameters: WorkflowSchema,
|
|
422
|
+
prepareArguments(args) {
|
|
423
|
+
try {
|
|
424
|
+
const workflow = parseWorkflow(args);
|
|
425
|
+
if (workflow.mode === "single") return { ...workflow.delegations[0], background: workflow.background };
|
|
426
|
+
if (workflow.mode === "parallel") return { tasks: workflow.delegations, background: workflow.background };
|
|
427
|
+
return { chain: workflow.delegations, background: workflow.background };
|
|
428
|
+
} catch (error) {
|
|
429
|
+
throw boundedError(error);
|
|
430
|
+
}
|
|
431
|
+
},
|
|
751
432
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
433
|
+
const throwIfAborted = () => {
|
|
434
|
+
if (signal?.aborted) throw new EphemeralSubagentError("aborted", "Subagent was aborted.", signal.reason);
|
|
435
|
+
};
|
|
436
|
+
throwIfAborted();
|
|
437
|
+
let workflow: ParsedWorkflow;
|
|
438
|
+
let roles: Role[];
|
|
439
|
+
try {
|
|
440
|
+
workflow = parseWorkflow(params);
|
|
441
|
+
roles = loadRoles();
|
|
442
|
+
const knownRoles = new Set(roles.map(({ name }) => name));
|
|
443
|
+
for (const { role } of workflow.delegations) {
|
|
444
|
+
if (!knownRoles.has(role)) {
|
|
445
|
+
throw new Error(`Unknown Subagent role: ${role}. Available roles: ${roles.map(({ name }) => name).join(", ") || "none"}.`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
} catch (error) {
|
|
449
|
+
throw boundedError(error);
|
|
757
450
|
}
|
|
451
|
+
throwIfAborted();
|
|
452
|
+
const rolesByName = new Map(roles.map((role) => [role.name, role]));
|
|
758
453
|
|
|
759
|
-
|
|
760
|
-
throw new Error("delegate_task modelClass must be fast, balanced, frontier, or fav.");
|
|
761
|
-
}
|
|
762
|
-
// Resolve against the latest known session context: a task queued past
|
|
763
|
-
// the cap must pick up model or Codex account changes that happened
|
|
764
|
-
// while it waited.
|
|
454
|
+
// Resolve against the latest known session context after each FIFO permit.
|
|
765
455
|
const launchCtx = () => latestCtx ?? ctx;
|
|
766
|
-
|
|
767
|
-
// routes that cannot honor it are skipped so fallback routes get considered.
|
|
768
|
-
const resolveLaunch = () => createRoleLaunch(pi, launchCtx(), {
|
|
456
|
+
const resolveLaunch = (role: Role, delegation: Delegation) => createRoleLaunch(pi, launchCtx(), {
|
|
769
457
|
role,
|
|
770
|
-
route:
|
|
771
|
-
? resolveDesignatedRoute(launchCtx(),
|
|
772
|
-
:
|
|
773
|
-
? resolveConfiguredTaskRoute(launchCtx(), SUBAGENT_TASK, undefined,
|
|
774
|
-
: resolveTaskRoute(launchCtx(),
|
|
458
|
+
route: delegation.model !== undefined
|
|
459
|
+
? resolveDesignatedRoute(launchCtx(), delegation.model, delegation.thinking)
|
|
460
|
+
: delegation.modelClass === undefined
|
|
461
|
+
? resolveConfiguredTaskRoute(launchCtx(), SUBAGENT_TASK, undefined, delegation.thinking)
|
|
462
|
+
: resolveTaskRoute(launchCtx(), delegation.modelClass, undefined, delegation.thinking),
|
|
775
463
|
});
|
|
776
|
-
const notifyMissingSkills = (launch: ReturnType<typeof resolveLaunch>) => {
|
|
464
|
+
const notifyMissingSkills = (role: Role, launch: ReturnType<typeof resolveLaunch>) => {
|
|
777
465
|
if (launch.missingSkills.length) {
|
|
778
466
|
ctx.ui.notify(
|
|
779
467
|
`Subagent role ${role.name} skipped unavailable Pi skills: ${launch.missingSkills.join(", ")}.`,
|
|
@@ -782,128 +470,233 @@ export default function subagentExtension(
|
|
|
782
470
|
}
|
|
783
471
|
};
|
|
784
472
|
|
|
785
|
-
|
|
473
|
+
const foregroundWorkflow: ParsedWorkflow = { ...workflow, background: false };
|
|
474
|
+
const entries = identifyWorkflowEntries(toolCallId, foregroundWorkflow);
|
|
475
|
+
const states = new Map<string, WorkflowTransportEntry>(entries.map((entry) => [entry.id, {
|
|
476
|
+
id: entry.id,
|
|
477
|
+
index: entry.index,
|
|
478
|
+
role: entry.delegation.role,
|
|
479
|
+
status: "pending",
|
|
480
|
+
}]));
|
|
481
|
+
const setupRecoveries = new Map<string, string>();
|
|
482
|
+
const emitUpdate = (enabled: boolean) => {
|
|
483
|
+
if (!enabled) return;
|
|
484
|
+
const update = formatWorkflowUpdate(workflow.mode, [...states.values()]);
|
|
485
|
+
onUpdate?.({
|
|
486
|
+
content: [{ type: "text", text: update.text }],
|
|
487
|
+
details: update.details,
|
|
488
|
+
...(update.usage === undefined ? {} : { usage: update.usage }),
|
|
489
|
+
});
|
|
490
|
+
};
|
|
491
|
+
const runWorkflow = async (workflowSignal: AbortSignal | undefined, emitToolUpdates: boolean) => {
|
|
492
|
+
try {
|
|
493
|
+
return await runForegroundWorkflow<EphemeralSubagentResult>(toolCallId, foregroundWorkflow, async (entry: WorkflowEntry) => {
|
|
494
|
+
const role = rolesByName.get(entry.delegation.role)!;
|
|
495
|
+
let model: string | undefined;
|
|
496
|
+
let thinkingLevel: string | undefined;
|
|
497
|
+
let worktree: WorktreeInfo | undefined;
|
|
498
|
+
let worktreePayload: WorktreePayload | undefined;
|
|
499
|
+
let child: EphemeralSubagentResult | undefined;
|
|
500
|
+
let rejected: unknown;
|
|
501
|
+
let rejectedUsage: Usage | undefined;
|
|
502
|
+
let aborted = false;
|
|
503
|
+
let status: "succeeded" | "failed" | "rejected" = "rejected";
|
|
504
|
+
let text = "Subagent did not start.";
|
|
505
|
+
const setState = (
|
|
506
|
+
nextStatus: "running" | "succeeded" | "failed" | "rejected",
|
|
507
|
+
nextText: string,
|
|
508
|
+
) => {
|
|
509
|
+
const usage = child?.usage ?? rejectedUsage;
|
|
510
|
+
const base = {
|
|
511
|
+
id: entry.id,
|
|
512
|
+
index: entry.index,
|
|
513
|
+
role: role.name,
|
|
514
|
+
...(model === undefined ? {} : { model }),
|
|
515
|
+
...(thinkingLevel === undefined ? {} : { thinkingLevel }),
|
|
516
|
+
...(worktreePayload === undefined ? {} : { worktreePayload }),
|
|
517
|
+
...(usage === undefined ? {} : { usage }),
|
|
518
|
+
};
|
|
519
|
+
states.set(entry.id, nextStatus === "failed" || nextStatus === "rejected"
|
|
520
|
+
? { ...base, status: nextStatus, failure: nextText }
|
|
521
|
+
: { ...base, status: nextStatus, assistantOutput: nextText });
|
|
522
|
+
};
|
|
523
|
+
try {
|
|
524
|
+
child = await executor.run({
|
|
525
|
+
signal: workflowSignal,
|
|
526
|
+
onUpdate: (output) => {
|
|
527
|
+
setState("running", output);
|
|
528
|
+
emitUpdate(emitToolUpdates);
|
|
529
|
+
},
|
|
530
|
+
onTokens: (tokens) => updateWidgetTokens(entry.id, tokens),
|
|
531
|
+
prepare: async () => {
|
|
532
|
+
// Route and effective Role resources resolve only after this entry's
|
|
533
|
+
// shared executor permit, before isolated state is created.
|
|
534
|
+
const launch = resolveLaunch(role, entry.delegation);
|
|
535
|
+
notifyMissingSkills(role, launch);
|
|
536
|
+
model = modelReference(launch.model);
|
|
537
|
+
thinkingLevel = launch.thinkingLevel;
|
|
538
|
+
if (role.isolation === "worktree") {
|
|
539
|
+
worktree = await createChildWorktree(ctx.cwd, entry.id, undefined, workflowSignal);
|
|
540
|
+
}
|
|
541
|
+
startWidgetItem(entry.id, role.name, launch.model.id, launch.thinkingLevel, entry.delegation.task, ctx);
|
|
542
|
+
setState("running", "");
|
|
543
|
+
emitUpdate(emitToolUpdates);
|
|
544
|
+
return {
|
|
545
|
+
launch,
|
|
546
|
+
task: worktree ? `${entry.delegation.task}${worktreeContextNote(worktree)}` : entry.delegation.task,
|
|
547
|
+
cwd: worktree?.cwd ?? ctx.cwd,
|
|
548
|
+
};
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
if (child.outcome === "failure") {
|
|
552
|
+
status = "failed";
|
|
553
|
+
text = capOutput(child.errorMessage || child.stderr.trim() || child.output || `Subagent exited with code ${child.exitCode}.`);
|
|
554
|
+
} else {
|
|
555
|
+
status = "succeeded";
|
|
556
|
+
text = child.output;
|
|
557
|
+
}
|
|
558
|
+
} catch (error) {
|
|
559
|
+
rejected = error;
|
|
560
|
+
aborted = error instanceof EphemeralSubagentError && error.code === "aborted";
|
|
561
|
+
rejectedUsage = error instanceof EphemeralSubagentError
|
|
562
|
+
? (error as EphemeralSubagentError & { usage?: Usage }).usage
|
|
563
|
+
: undefined;
|
|
564
|
+
const cause = error instanceof EphemeralSubagentError ? error.cause : error;
|
|
565
|
+
if (cause instanceof Error && cause.name === "WorktreeSetupError") {
|
|
566
|
+
setupRecoveries.set(entry.id, cause.message);
|
|
567
|
+
}
|
|
568
|
+
text = capOutput(error instanceof Error ? error.message : String(error));
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
worktreePayload = worktree ? await finalizeChildWorktree(worktree) : undefined;
|
|
572
|
+
} catch (error) {
|
|
573
|
+
rejected = error;
|
|
574
|
+
aborted = false;
|
|
575
|
+
status = "rejected";
|
|
576
|
+
text = capOutput(error instanceof Error ? error.message : String(error));
|
|
577
|
+
worktreePayload = worktree ? {
|
|
578
|
+
path: worktree.path,
|
|
579
|
+
branch: worktree.branch,
|
|
580
|
+
commits: 0,
|
|
581
|
+
dirty: false,
|
|
582
|
+
pruned: false,
|
|
583
|
+
inspection_failed: true,
|
|
584
|
+
note: capOutput(`Worktree finalization failed (${text}); commits/dirty UNKNOWN. Inspect retained work before assuming no changes.`),
|
|
585
|
+
} : undefined;
|
|
586
|
+
}
|
|
587
|
+
if (worktreePayload?.inspection_failed) {
|
|
588
|
+
const note = capOutput(worktreePayload.note ?? `Worktree inspection failed; inspect ${worktreePayload.path} before assuming no work.`);
|
|
589
|
+
worktreePayload = { ...worktreePayload, note };
|
|
590
|
+
rejected = new Error(note, rejected === undefined ? undefined : { cause: rejected });
|
|
591
|
+
status = "rejected";
|
|
592
|
+
text = capOutput(`${note}\n${text}`);
|
|
593
|
+
}
|
|
594
|
+
if (rejected !== undefined) status = "rejected";
|
|
595
|
+
setState(status, text);
|
|
596
|
+
try {
|
|
597
|
+
finishWidgetItem(entry.id, aborted ? "aborted" : status === "succeeded" ? "success" : "failure");
|
|
598
|
+
emitUpdate(emitToolUpdates);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
rejected = error;
|
|
601
|
+
status = "rejected";
|
|
602
|
+
setState("rejected", capOutput(error instanceof Error ? error.message : String(error)));
|
|
603
|
+
finishWidgetItem(entry.id, "failure");
|
|
604
|
+
}
|
|
605
|
+
if (rejected !== undefined) throw rejected;
|
|
606
|
+
return child!.outcome === "success"
|
|
607
|
+
? { ok: true, assistantOutput: text, result: child! }
|
|
608
|
+
: { ok: false, result: child! };
|
|
609
|
+
}, workflowSignal);
|
|
610
|
+
} finally {
|
|
611
|
+
if (workflow.mode === "chain" && (workflowSignal?.aborted
|
|
612
|
+
|| [...states.values()].some(({ status }) => status === "failed" || status === "rejected"))) {
|
|
613
|
+
for (const [id, state] of states) {
|
|
614
|
+
if (state.status === "pending") states.set(id, { ...state, status: "skipped" });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const recordInfrastructureFailure = (error: unknown) => {
|
|
621
|
+
if ([...states.values()].some(({ status }) => status === "failed" || status === "rejected")) return;
|
|
622
|
+
const target = [...states.values()].find(({ status }) => status === "pending" || status === "running")
|
|
623
|
+
?? [...states.values()].at(-1)!;
|
|
624
|
+
states.set(target.id, {
|
|
625
|
+
id: target.id,
|
|
626
|
+
index: target.index,
|
|
627
|
+
role: target.role,
|
|
628
|
+
...(target.model === undefined ? {} : { model: target.model }),
|
|
629
|
+
...(target.thinkingLevel === undefined ? {} : { thinkingLevel: target.thinkingLevel }),
|
|
630
|
+
...(target.worktreePayload === undefined ? {} : { worktreePayload: target.worktreePayload }),
|
|
631
|
+
...(target.usage === undefined ? {} : { usage: target.usage }),
|
|
632
|
+
status: "rejected",
|
|
633
|
+
failure: capOutput(error instanceof Error ? error.message : String(error)),
|
|
634
|
+
});
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
throwIfAborted();
|
|
638
|
+
if (workflow.background) {
|
|
786
639
|
const taskId = `bg-${++backgroundSequence}-${Date.now().toString(36)}`;
|
|
787
640
|
const controller = new AbortController();
|
|
788
641
|
// Freeze the launching session now: a task that settles after a
|
|
789
642
|
// reload must not deliver into whichever session is active then.
|
|
790
643
|
const launchEpoch = sessionEpoch;
|
|
791
644
|
const settled = (async () => {
|
|
792
|
-
let acquired = false;
|
|
793
|
-
let widgetStatus: Exclude<WidgetStatus, "working"> = "failure";
|
|
794
|
-
// Role is known up front; model/thinking join after launch resolution.
|
|
795
|
-
let details: { role: string; model?: string; thinkingLevel?: string } = { role: role.name };
|
|
796
|
-
let worktree: WorktreeInfo | undefined;
|
|
797
645
|
try {
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
notifyMissingSkills(launch);
|
|
808
|
-
details = { role: role.name, model: modelReference(launch.model), thinkingLevel: launch.thinkingLevel };
|
|
809
|
-
startWidgetItem(taskId, role.name, launch.model.id, launch.thinkingLevel, task, ctx);
|
|
810
|
-
const result = await runPi(
|
|
811
|
-
["--mode", "json", "-p", ...launch.args, `Task: ${worktree ? `${task}${worktreeContextNote(worktree)}` : task}`],
|
|
812
|
-
worktree?.cwd ?? ctx.cwd,
|
|
813
|
-
controller.signal,
|
|
814
|
-
undefined,
|
|
815
|
-
(tokens) => updateWidgetTokens(taskId, tokens),
|
|
816
|
-
timeoutPolicy,
|
|
817
|
-
);
|
|
818
|
-
const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
819
|
-
widgetStatus = result.stopReason === "aborted" ? "aborted" : failed ? "failure" : "success";
|
|
820
|
-
const text = failed
|
|
821
|
-
? result.errorMessage || result.stderr.trim() || result.output || `Subagent exited with code ${result.exitCode}.`
|
|
822
|
-
: result.output || "(no output)";
|
|
823
|
-
const payloadLine = await finalizeWorktreePayload(worktree);
|
|
824
|
-
await reportBackground(
|
|
825
|
-
launchEpoch,
|
|
826
|
-
taskId,
|
|
827
|
-
details,
|
|
828
|
-
result.stopReason === "aborted" ? "aborted" : failed ? "failed" : "completed",
|
|
829
|
-
text,
|
|
830
|
-
payloadLine,
|
|
831
|
-
);
|
|
832
|
-
} catch (error) {
|
|
833
|
-
const aborted = controller.signal.aborted && !(error instanceof SubagentTimeoutError);
|
|
834
|
-
widgetStatus = aborted ? "aborted" : "failure";
|
|
835
|
-
const failureText = error instanceof Error ? error.message : String(error);
|
|
836
|
-
const payloadLine = await finalizeWorktreePayload(worktree);
|
|
837
|
-
await reportBackground(
|
|
838
|
-
launchEpoch,
|
|
839
|
-
taskId,
|
|
840
|
-
details,
|
|
841
|
-
aborted ? "aborted" : "failed",
|
|
842
|
-
failureText,
|
|
843
|
-
payloadLine,
|
|
844
|
-
error instanceof Error && error.name === "WorktreeSetupError" ? failureText : undefined,
|
|
845
|
-
);
|
|
646
|
+
// Let the acknowledgement resolve before any route, Skill, worktree,
|
|
647
|
+
// permit, or child work starts.
|
|
648
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
649
|
+
try {
|
|
650
|
+
await runWorkflow(controller.signal, false);
|
|
651
|
+
} catch (error) {
|
|
652
|
+
if (!controller.signal.aborted) recordInfrastructureFailure(error);
|
|
653
|
+
}
|
|
654
|
+
reportBackground(launchEpoch, taskId, workflow.mode, [...states.values()], setupRecoveries);
|
|
846
655
|
} finally {
|
|
847
|
-
if (acquired) releaseChildPermit();
|
|
848
|
-
finishWidgetItem(taskId, widgetStatus);
|
|
849
656
|
backgroundTasks.delete(taskId);
|
|
850
657
|
}
|
|
851
658
|
})();
|
|
852
659
|
backgroundTasks.set(taskId, { controller, settled });
|
|
853
660
|
void settled;
|
|
661
|
+
const acknowledgement = capOutput([
|
|
662
|
+
`Background workflow ${taskId} accepted.`,
|
|
663
|
+
`Mode: ${workflow.mode}`,
|
|
664
|
+
"Entries:",
|
|
665
|
+
...entries.map((entry) =>
|
|
666
|
+
`- [${entry.index}] id=${JSON.stringify(entry.id)} role=${JSON.stringify(entry.delegation.role)}`),
|
|
667
|
+
"The aggregate outcome arrives as one message; keep working or end your turn.",
|
|
668
|
+
].join("\n"));
|
|
854
669
|
return {
|
|
855
|
-
content: [{ type: "text" as const, text:
|
|
856
|
-
details: {
|
|
670
|
+
content: [{ type: "text" as const, text: acknowledgement }],
|
|
671
|
+
details: {
|
|
672
|
+
taskId,
|
|
673
|
+
background: true,
|
|
674
|
+
mode: workflow.mode,
|
|
675
|
+
entries: entries.map((entry) => ({ id: entry.id, index: entry.index, role: entry.delegation.role })),
|
|
676
|
+
},
|
|
857
677
|
};
|
|
858
678
|
}
|
|
859
679
|
|
|
860
|
-
|
|
861
|
-
notifyMissingSkills(launch);
|
|
862
|
-
const modelReferenceValue = modelReference(launch.model);
|
|
863
|
-
const details = { role: role.name, model: modelReferenceValue, thinkingLevel: launch.thinkingLevel };
|
|
864
|
-
await acquireChildPermit(signal);
|
|
865
|
-
let widgetStatus: Exclude<WidgetStatus, "working"> = "failure";
|
|
866
|
-
let result: DelegateResult | undefined;
|
|
867
|
-
let worktree: WorktreeInfo | undefined;
|
|
868
|
-
let rethrow: unknown;
|
|
869
|
-
let worktreePayload: WorktreePayload | undefined;
|
|
680
|
+
let outcomes: Awaited<ReturnType<typeof runWorkflow>>;
|
|
870
681
|
try {
|
|
871
|
-
|
|
872
|
-
// rejected delegation cannot leak worktrees; setup failure fails closed.
|
|
873
|
-
if (role.isolation === "worktree") worktree = await createChildWorktree(ctx.cwd, toolCallId, undefined, signal);
|
|
874
|
-
startWidgetItem(toolCallId, role.name, launch.model.id, launch.thinkingLevel, task, ctx);
|
|
875
|
-
const child = await runPi(
|
|
876
|
-
["--mode", "json", "-p", ...launch.args, `Task: ${worktree ? `${task}${worktreeContextNote(worktree)}` : task}`],
|
|
877
|
-
worktree?.cwd ?? ctx.cwd,
|
|
878
|
-
signal,
|
|
879
|
-
(text) => onUpdate?.({ content: [{ type: "text", text }], details }),
|
|
880
|
-
(tokens) => updateWidgetTokens(toolCallId, tokens),
|
|
881
|
-
timeoutPolicy,
|
|
882
|
-
);
|
|
883
|
-
const failed = child.exitCode !== 0 || child.stopReason === "error" || child.stopReason === "aborted";
|
|
884
|
-
widgetStatus = child.stopReason === "aborted" ? "aborted" : failed ? "failure" : "success";
|
|
885
|
-
const text = capOutput(failed
|
|
886
|
-
? child.errorMessage || child.stderr.trim() || child.output || `Subagent exited with code ${child.exitCode}.`
|
|
887
|
-
: child.output || "(no output)");
|
|
888
|
-
result = { content: [{ type: "text" as const, text }], details, ...(failed ? { isError: true } : {}) };
|
|
889
|
-
return result;
|
|
682
|
+
outcomes = await runWorkflow(signal, true);
|
|
890
683
|
} catch (error) {
|
|
891
|
-
if (signal?.aborted
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
finishWidgetItem(toolCallId, widgetStatus);
|
|
896
|
-
worktreePayload = await finalizeWorktreePayload(worktree);
|
|
897
|
-
if (result && worktreePayload) result.content[0].text += `\n${JSON.stringify(worktreePayload)}`;
|
|
684
|
+
if (!signal?.aborted) throw error;
|
|
685
|
+
const aborted = new WorkflowAbortedError(workflow.mode, [...states.values()], signal.reason);
|
|
686
|
+
failedToolPatches.set(toolCallId, failedToolPatch(aborted));
|
|
687
|
+
throw aborted;
|
|
898
688
|
}
|
|
899
|
-
if (
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
throw
|
|
903
|
-
? new Error(`${rethrow instanceof Error ? rethrow.message : String(rethrow)}\n${JSON.stringify(worktreePayload)}`)
|
|
904
|
-
: rethrow;
|
|
689
|
+
if (outcomes.some(({ status }) => status === "failed" || status === "rejected")) {
|
|
690
|
+
const failure = new WorkflowFailureError(workflow.mode, [...states.values()]);
|
|
691
|
+
failedToolPatches.set(toolCallId, failedToolPatch(failure));
|
|
692
|
+
throw failure;
|
|
905
693
|
}
|
|
906
|
-
|
|
694
|
+
const result = formatWorkflowResult(workflow.mode, [...states.values()]);
|
|
695
|
+
return {
|
|
696
|
+
content: [{ type: "text" as const, text: result.text }],
|
|
697
|
+
details: result.details,
|
|
698
|
+
...(result.usage === undefined ? {} : { usage: result.usage }),
|
|
699
|
+
};
|
|
907
700
|
},
|
|
908
701
|
});
|
|
909
702
|
}
|