@ferris1225/pi-subagents 4.1.13 → 4.1.15
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/README.md +51 -40
- package/agents/cleaner.md +3 -3
- package/agents/documenter.md +3 -3
- package/agents/explorer.md +1 -1
- package/agents/reviewer.md +21 -13
- package/agents/worker.md +2 -2
- package/package.json +1 -1
- package/src/announcements.ts +8 -1
- package/src/config.ts +1 -1
- package/src/dispatch.ts +93 -125
- package/src/durable.ts +13 -12
- package/src/format.ts +8 -0
- package/src/prompt.ts +14 -27
- package/src/runtime.ts +15 -11
- package/src/setup.ts +3 -3
- package/src/thread-lifecycle.ts +45 -68
- package/src/tools.ts +1 -1
- package/src/workflow.ts +96 -144
package/src/dispatch.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The `subagent` tool: dispatches explorer/worker/cleaner/documenter/reviewer agents as isolated pi
|
|
3
3
|
* child processes, single or parallel. Owns the public dispatch contract,
|
|
4
|
-
* per-run status tracking, managed worker/cleaner → reviewer
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* thread generations, final integration, and completion ownership live in
|
|
8
|
-
* thread-lifecycle.ts.
|
|
4
|
+
* per-run status tracking, the managed worker/cleaner → reviewer gate, and
|
|
5
|
+
* internal step launching. Stable thread generations, final integration, and
|
|
6
|
+
* completion ownership live in thread-lifecycle.ts.
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
9
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
@@ -18,9 +16,10 @@ import { getStateRoot } from "./durable.ts";
|
|
|
18
16
|
import { loadConfig } from "./config.ts";
|
|
19
17
|
import { formatUsage, queuedResult } from "./format.ts";
|
|
20
18
|
import {
|
|
21
|
-
buildFinalDocumenterBrief,
|
|
22
19
|
buildFinalReviewBrief,
|
|
23
|
-
|
|
20
|
+
buildReReviewBrief,
|
|
21
|
+
buildReviewerFixBrief,
|
|
22
|
+
MAX_REVIEW_FIX_ROUNDS,
|
|
24
23
|
type ChainStep,
|
|
25
24
|
type ManagedWorkflowOutcome,
|
|
26
25
|
} from "./workflow.ts";
|
|
@@ -65,11 +64,6 @@ const IsolationSchema = Type.Optional(
|
|
|
65
64
|
StringEnum(["shared", "worktree"] as const, { description: ISOLATION_DESCRIPTION }),
|
|
66
65
|
);
|
|
67
66
|
|
|
68
|
-
const ADVISORY_DESCRIPTION =
|
|
69
|
-
"Report-only reviewer dispatch: findings return to you for the decision; a verdict (if emitted anyway) never starts the auto-fix chain. Use when re-verifying work you already fixed yourself.";
|
|
70
|
-
|
|
71
|
-
const AdvisorySchema = Type.Optional(Type.Boolean({ description: ADVISORY_DESCRIPTION }));
|
|
72
|
-
|
|
73
67
|
const TaskItem = Type.Object({
|
|
74
68
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
75
69
|
task: Type.String({
|
|
@@ -78,7 +72,6 @@ const TaskItem = Type.Object({
|
|
|
78
72
|
}),
|
|
79
73
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
|
|
80
74
|
isolation: IsolationSchema,
|
|
81
|
-
advisory: AdvisorySchema,
|
|
82
75
|
});
|
|
83
76
|
|
|
84
77
|
const SubagentParams = Type.Object({
|
|
@@ -89,7 +82,6 @@ const SubagentParams = Type.Object({
|
|
|
89
82
|
tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
|
|
90
83
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
|
|
91
84
|
isolation: IsolationSchema,
|
|
92
|
-
advisory: AdvisorySchema,
|
|
93
85
|
});
|
|
94
86
|
|
|
95
87
|
export function defaultIsolationMode(mode: "single" | "parallel", agentName: string, requested?: IsolationMode): IsolationMode {
|
|
@@ -97,14 +89,26 @@ export function defaultIsolationMode(mode: "single" | "parallel", agentName: str
|
|
|
97
89
|
return mode === "parallel" && agentName === "worker" ? "worktree" : "shared";
|
|
98
90
|
}
|
|
99
91
|
|
|
100
|
-
function workflowStageStatus(result: SingleResult): WorkflowStageStatus {
|
|
92
|
+
function workflowStageStatus(result: SingleResult, relation?: string): WorkflowStageStatus {
|
|
101
93
|
if (isFailedResult(result)) return "failed";
|
|
94
|
+
if (relation === "review fix") return "done";
|
|
102
95
|
if (result.agent !== "reviewer") return "done";
|
|
103
96
|
const verdict = reviewVerdict(getResultOutput(result));
|
|
104
97
|
if (verdict === "fail") return "changes";
|
|
105
98
|
return verdict === "pass" ? "done" : "failed";
|
|
106
99
|
}
|
|
107
100
|
|
|
101
|
+
/** The runtime-granted write continuation of a failed gate: same reviewer
|
|
102
|
+
* role, model, and retained session, but the read-only boundary is lifted for
|
|
103
|
+
* this one stage so it applies its own fix instructions. */
|
|
104
|
+
function withReviewerFixStageAgent(agent: AgentConfig): AgentConfig {
|
|
105
|
+
return {
|
|
106
|
+
...agent,
|
|
107
|
+
tools: undefined,
|
|
108
|
+
systemPrompt: `${agent.systemPrompt.trimEnd()}\n\nRuntime workflow context: FIX STAGE — your gate just returned REVIEW_FAIL. Your read-only boundary is lifted for this stage only: apply your own fix instructions exactly as you specified them, run the narrowest decisive checks, and report what changed. Never edit during a review and never emit a verdict in a fix stage.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
108
112
|
export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime): void {
|
|
109
113
|
// Latest dispatch environment. The dispatcher is created once per process so
|
|
110
114
|
// restored threads can resume before any dispatch has run; each execute
|
|
@@ -185,23 +189,30 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
185
189
|
|
|
186
190
|
/** Launch one workflow-internal child in a fresh model context. It sees the
|
|
187
191
|
* parent's exact repository/worktree state and is registered by its own id,
|
|
188
|
-
* but never enters top-level lifecycle policy or completion delivery.
|
|
192
|
+
* but never enters top-level lifecycle policy or completion delivery.
|
|
193
|
+
* `stage` continues a retained session (the reviewer fix stage) and/or
|
|
194
|
+
* replaces the resolved agent (lifting the reviewer read-only boundary). */
|
|
189
195
|
const launchInWorkflow = async (
|
|
190
196
|
request: ManagedWorkflowRequest,
|
|
191
197
|
agentName: string,
|
|
192
198
|
task: string,
|
|
193
199
|
meta: RunChainMeta,
|
|
200
|
+
stage: {
|
|
201
|
+
agentOverride?: AgentConfig;
|
|
202
|
+
session?: { sessionId: string; sessionDir: string };
|
|
203
|
+
} = {},
|
|
194
204
|
): Promise<{ runId: number; result: SingleResult }> => {
|
|
195
205
|
const discoveredAgent = request.agents.find((candidate) => candidate.name === agentName);
|
|
196
206
|
if (!discoveredAgent) {
|
|
197
207
|
throw new Error(`Managed workflow requires enabled agent "${agentName}", but discovery did not provide it.`);
|
|
198
208
|
}
|
|
209
|
+
const boundaryAgent = stage.agentOverride ?? discoveredAgent;
|
|
199
210
|
const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
|
|
200
|
-
resolveAgentTools({ ...candidate, tools:
|
|
201
|
-
const agent = resolveLiveAgentTools(
|
|
202
|
-
// Workflow policy (
|
|
203
|
-
//
|
|
204
|
-
//
|
|
211
|
+
resolveAgentTools({ ...candidate, tools: boundaryAgent.tools }, runtime.getActiveTools());
|
|
212
|
+
const agent = resolveLiveAgentTools(boundaryAgent);
|
|
213
|
+
// Workflow policy (agents) stays fixed for the chain, but model/thinking
|
|
214
|
+
// routes are re-read per stage so config edits apply to stages that have
|
|
215
|
+
// not launched yet.
|
|
205
216
|
const stageConfig = await loadConfig(runtime.configPath).catch(() => request.config);
|
|
206
217
|
const resolvedRoute = resolveDispatchModelRoute(agent, stageConfig, request.ctx);
|
|
207
218
|
const route = request.isolation === "worktree"
|
|
@@ -230,6 +241,9 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
230
241
|
makeDetails: makeDetails("single", true),
|
|
231
242
|
idleTimeoutMs: stageConfig.idleTimeoutSec * 1000,
|
|
232
243
|
sessionRoot: getStateRoot(runtime.configPath),
|
|
244
|
+
...(stage.session
|
|
245
|
+
? { sessionId: stage.session.sessionId, sessionDir: stage.session.sessionDir, stdinText: task }
|
|
246
|
+
: {}),
|
|
233
247
|
},
|
|
234
248
|
route.mainFallbackRef,
|
|
235
249
|
);
|
|
@@ -303,62 +317,37 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
303
317
|
status: workflowStageStatus(initialStepResult),
|
|
304
318
|
}];
|
|
305
319
|
let reviewStage: WorkflowStage | undefined;
|
|
306
|
-
if (
|
|
320
|
+
if (enabled("reviewer")) {
|
|
307
321
|
reviewStage = { agent: "reviewer", relation: "review", status: "pending" };
|
|
308
322
|
workflowStages.push(reviewStage);
|
|
309
323
|
}
|
|
310
|
-
let documentationStage: WorkflowStage | undefined;
|
|
311
|
-
if (enabled("documenter")) {
|
|
312
|
-
documentationStage = { agent: "documenter", relation: "docs", status: "pending" };
|
|
313
|
-
workflowStages.push(documentationStage);
|
|
314
|
-
}
|
|
315
324
|
const publishWorkflowStages = (): void => {
|
|
316
325
|
monitor.setWorkflowStages(request.parentRunId, workflowStages);
|
|
317
326
|
};
|
|
318
|
-
const insertBeforeDocumentation = (stage: WorkflowStage): void => {
|
|
319
|
-
const documentationIndex = documentationStage
|
|
320
|
-
? workflowStages.indexOf(documentationStage)
|
|
321
|
-
: -1;
|
|
322
|
-
if (documentationIndex === -1) workflowStages.push(stage);
|
|
323
|
-
else workflowStages.splice(documentationIndex, 0, stage);
|
|
324
|
-
};
|
|
325
|
-
const removeDocumentationStage = (): void => {
|
|
326
|
-
if (!documentationStage) return;
|
|
327
|
-
const index = workflowStages.indexOf(documentationStage);
|
|
328
|
-
if (index !== -1) workflowStages.splice(index, 1);
|
|
329
|
-
documentationStage = undefined;
|
|
330
|
-
publishWorkflowStages();
|
|
331
|
-
};
|
|
332
327
|
publishWorkflowStages();
|
|
333
328
|
|
|
334
329
|
const launchStep = async (
|
|
335
330
|
agentName: string,
|
|
336
331
|
task: string,
|
|
337
332
|
relation: string,
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
333
|
+
stage: WorkflowStage,
|
|
334
|
+
stageOptions: {
|
|
335
|
+
agentOverride?: AgentConfig;
|
|
336
|
+
session?: { sessionId: string; sessionDir: string };
|
|
342
337
|
} = {},
|
|
343
338
|
): Promise<SingleResult> => {
|
|
344
339
|
if (!enabled(agentName)) {
|
|
345
340
|
throw new Error(`Managed workflow cannot launch disabled or missing agent "${agentName}".`);
|
|
346
341
|
}
|
|
347
|
-
const stage: WorkflowStage = projection.stage ?? {
|
|
348
|
-
agent: agentName,
|
|
349
|
-
relation: projection.timelineRelation ?? relation,
|
|
350
|
-
status: "pending",
|
|
351
|
-
};
|
|
352
|
-
if (!projection.stage) insertBeforeDocumentation(stage);
|
|
353
342
|
stage.status = "active";
|
|
354
343
|
publishWorkflowStages();
|
|
355
344
|
try {
|
|
356
345
|
const step = await launchInWorkflow(request, agentName, task, {
|
|
357
346
|
groupId: request.groupId,
|
|
358
|
-
relationLabel:
|
|
347
|
+
relationLabel: relation,
|
|
359
348
|
parentRunId: request.parentRunId,
|
|
360
|
-
});
|
|
361
|
-
stage.status = workflowStageStatus(step.result);
|
|
349
|
+
}, stageOptions);
|
|
350
|
+
stage.status = workflowStageStatus(step.result, relation);
|
|
362
351
|
publishWorkflowStages();
|
|
363
352
|
request.rememberLatest(step.result);
|
|
364
353
|
steps.push({ ...step, relation });
|
|
@@ -370,68 +359,52 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
370
359
|
}
|
|
371
360
|
};
|
|
372
361
|
|
|
373
|
-
/** Run the low-cost final documentation sync only when the terminal REVIEW_PASS
|
|
374
|
-
* reports drift or omits the marker. A failed process, missing verdict, or
|
|
375
|
-
* REVIEW_FAIL never writes docs. With no reviewer, retain the conservative
|
|
376
|
-
* writer → documenter fallback. */
|
|
377
|
-
const runFinalDocumentation = async (
|
|
378
|
-
lastWriterResult: SingleResult | undefined,
|
|
379
|
-
finalReviewResult: SingleResult | undefined,
|
|
380
|
-
): Promise<void> => {
|
|
381
|
-
if (!canContinue() || !enabled("documenter")) return;
|
|
382
|
-
if (lastWriterResult?.agent === "documenter") {
|
|
383
|
-
removeDocumentationStage();
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
if (finalReviewResult) {
|
|
387
|
-
if (isFailedResult(finalReviewResult)) {
|
|
388
|
-
removeDocumentationStage();
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
const reviewOutput = getResultOutput(finalReviewResult);
|
|
392
|
-
if (
|
|
393
|
-
reviewVerdict(reviewOutput) !== "pass" ||
|
|
394
|
-
documentationDisposition(reviewOutput) === "clean"
|
|
395
|
-
) {
|
|
396
|
-
removeDocumentationStage();
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
documentationStage ??= { agent: "documenter", relation: "docs", status: "pending" };
|
|
401
|
-
if (!workflowStages.includes(documentationStage)) workflowStages.push(documentationStage);
|
|
402
|
-
await launchStep(
|
|
403
|
-
"documenter",
|
|
404
|
-
buildFinalDocumenterBrief(lastWriterResult, finalReviewResult),
|
|
405
|
-
"final documentation sync",
|
|
406
|
-
{ stage: documentationStage },
|
|
407
|
-
);
|
|
408
|
-
};
|
|
409
|
-
|
|
410
362
|
try {
|
|
411
363
|
// Park/stop/shutdown may win after the top-level child settles but
|
|
412
364
|
// before this continuation starts. Preserve that stable checkpoint and
|
|
413
365
|
// never create an already-aborted downstream child.
|
|
414
|
-
if (!canContinue()) return {
|
|
415
|
-
if (
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
await runFinalDocumentation(undefined, initialStepResult);
|
|
419
|
-
} else if (enabled("reviewer")) {
|
|
420
|
-
// A failing gate is not a workflow continuation: the REVIEW_FAIL
|
|
421
|
-
// report and its fix instructions are delivered to the main agent,
|
|
422
|
-
// which owns the fix decision.
|
|
423
|
-
const gateReview = await launchStep(
|
|
366
|
+
if (!canContinue()) return { steps };
|
|
367
|
+
if (reviewStage) {
|
|
368
|
+
const discoveredReviewer = request.agents.find((candidate) => candidate.name === "reviewer")!;
|
|
369
|
+
let gateReview = await launchStep(
|
|
424
370
|
"reviewer",
|
|
425
|
-
buildFinalReviewBrief(initialStepResult
|
|
371
|
+
buildFinalReviewBrief(initialStepResult),
|
|
426
372
|
"final review",
|
|
427
|
-
|
|
373
|
+
reviewStage,
|
|
428
374
|
);
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
//
|
|
432
|
-
|
|
375
|
+
// The failing gate owns its fixes until it passes: the same retained
|
|
376
|
+
// reviewer session applies its own fix instructions with write access,
|
|
377
|
+
// then a fresh gate re-scans the complete diff. Nobody outside the
|
|
378
|
+
// loop has to guess what satisfies the gate; the cap only stops
|
|
379
|
+
// pathological burn and hands the still-failing gate to the main agent.
|
|
380
|
+
for (let round = 1; round <= MAX_REVIEW_FIX_ROUNDS; round++) {
|
|
381
|
+
const gateSession = gateReview.sessionId && gateReview.sessionDir
|
|
382
|
+
? { sessionId: gateReview.sessionId, sessionDir: gateReview.sessionDir }
|
|
383
|
+
: undefined;
|
|
384
|
+
if (reviewVerdict(getResultOutput(gateReview)) !== "fail" || !gateSession || !canContinue()) break;
|
|
385
|
+
const fixStage: WorkflowStage = { agent: "reviewer", relation: "fix", status: "pending" };
|
|
386
|
+
workflowStages.push(fixStage);
|
|
387
|
+
publishWorkflowStages();
|
|
388
|
+
const fixResult = await launchStep(
|
|
389
|
+
"reviewer",
|
|
390
|
+
buildReviewerFixBrief(getResultOutput(gateReview)),
|
|
391
|
+
"review fix",
|
|
392
|
+
fixStage,
|
|
393
|
+
{ agentOverride: withReviewerFixStageAgent(discoveredReviewer), session: gateSession },
|
|
394
|
+
);
|
|
395
|
+
if (isFailedResult(fixResult) || !canContinue()) break;
|
|
396
|
+
const reReviewStage: WorkflowStage = { agent: "reviewer", relation: "review", status: "pending" };
|
|
397
|
+
workflowStages.push(reReviewStage);
|
|
398
|
+
publishWorkflowStages();
|
|
399
|
+
gateReview = await launchStep(
|
|
400
|
+
"reviewer",
|
|
401
|
+
buildReReviewBrief(fixResult, round),
|
|
402
|
+
round === 1 ? "re-review" : `re-review ${round}`,
|
|
403
|
+
reReviewStage,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
433
406
|
}
|
|
434
|
-
return {
|
|
407
|
+
return { steps };
|
|
435
408
|
} finally {
|
|
436
409
|
removeWorkflowGroup(request.groupId);
|
|
437
410
|
}
|
|
@@ -456,15 +429,13 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
456
429
|
name: "subagent",
|
|
457
430
|
label: "Subagent",
|
|
458
431
|
description: [
|
|
459
|
-
"Dispatch enabled
|
|
460
|
-
"
|
|
461
|
-
"
|
|
462
|
-
"
|
|
463
|
-
"A selected-model or provider failure continues the retained session on the current main model; ordinary tool/task failures do not.",
|
|
464
|
-
"Use subagent_control to resume a parked or settled thread's retained context by stable run id; use subagent_stop for destructive cancellation.",
|
|
432
|
+
"Dispatch enabled agents as isolated leaf Pi child processes, singly or in parallel. Dispatching never blocks your turn — runs proceed in the background and each completion resumes you automatically; never poll or restate delivered results.",
|
|
433
|
+
"Put every genuinely independent unit in one `tasks` array (no per-call cap; extras queue for a free process slot). Single tasks share the checkout; parallel workers default to detached Git worktrees — write-capable agents only, and setup failure never silently falls back to shared.",
|
|
434
|
+
"Successful worker/cleaner runs get one automatic reviewer gate; a failing gate is fixed by the reviewer itself in a write-enabled continuation of the same session and re-reviewed until it passes. A REVIEW_FAIL from a gate you dispatched directly returns its findings to you — fix them inline or via a briefed worker without waiting for the user.",
|
|
435
|
+
"A configured child-model failure continues the retained session on the current main model. Resume a parked or settled thread with subagent_control by run id; use subagent_stop for destructive cancellation.",
|
|
465
436
|
].join(" "),
|
|
466
437
|
promptSnippet:
|
|
467
|
-
"Dispatch isolated background agents for
|
|
438
|
+
"Dispatch isolated background agents for recon, implementation, cleanup, docs, or review; never blocks your turn, and REVIEW_FAIL findings return to you to fix.",
|
|
468
439
|
parameters: SubagentParams,
|
|
469
440
|
|
|
470
441
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -523,10 +494,11 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
523
494
|
};
|
|
524
495
|
}
|
|
525
496
|
|
|
526
|
-
// Sub-agents
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
497
|
+
// Sub-agents run detached from the foreground turn: the editor stays
|
|
498
|
+
// available and completion messages later wake the main agent. The turn is
|
|
499
|
+
// NOT terminated here — the model can keep dispatching independent units
|
|
500
|
+
// or do its own work, and the background queue paces how many child
|
|
501
|
+
// processes actually run at once, so no per-call task cap is enforced.
|
|
530
502
|
if (params.tasks && params.tasks.length > 0) {
|
|
531
503
|
const results: SingleResult[] = [];
|
|
532
504
|
// Preserve caller order (and deterministic completion batching) while
|
|
@@ -537,7 +509,6 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
537
509
|
item.task,
|
|
538
510
|
item.cwd,
|
|
539
511
|
defaultIsolationMode("parallel", item.agent, item.isolation as IsolationMode | undefined),
|
|
540
|
-
{ advisoryReview: item.advisory === true },
|
|
541
512
|
));
|
|
542
513
|
}
|
|
543
514
|
const startedRuns = results.filter((result) => result.exitCode === -1);
|
|
@@ -558,7 +529,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
558
529
|
throw new Error(`No background subagents were started.\n${failureLines.join("\n")}`);
|
|
559
530
|
}
|
|
560
531
|
const text = [
|
|
561
|
-
`Started ${started} background subagent${started === 1 ? "" : "s"}: ${startedRefs.join(", ")}.
|
|
532
|
+
`Started ${started} background subagent${started === 1 ? "" : "s"}: ${startedRefs.join(", ")}. They run in the background and never block you — dispatch more independent units now or keep working; each result resumes you automatically when you are idle.`,
|
|
562
533
|
...(failureLines.length > 0
|
|
563
534
|
? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
|
|
564
535
|
: []),
|
|
@@ -566,7 +537,6 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
566
537
|
return {
|
|
567
538
|
content: [{ type: "text", text }],
|
|
568
539
|
details: makeDetails("parallel", true)(results),
|
|
569
|
-
terminate: true,
|
|
570
540
|
};
|
|
571
541
|
}
|
|
572
542
|
|
|
@@ -575,16 +545,14 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
575
545
|
params.task as string,
|
|
576
546
|
params.cwd,
|
|
577
547
|
defaultIsolationMode("single", params.agent as string, params.isolation as IsolationMode | undefined),
|
|
578
|
-
{ advisoryReview: params.advisory === true },
|
|
579
548
|
);
|
|
580
549
|
if (result.exitCode !== -1) {
|
|
581
550
|
throw new Error(getResultOutput(result));
|
|
582
551
|
}
|
|
583
552
|
const runRef = result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`;
|
|
584
553
|
return {
|
|
585
|
-
content: [{ type: "text", text: `Started ${runRef} in the background.
|
|
554
|
+
content: [{ type: "text", text: `Started ${runRef} in the background. It never blocks you — dispatch more independent units now or keep working; its result resumes you automatically when you are idle.` }],
|
|
586
555
|
details: makeDetails("single", true)([result]),
|
|
587
|
-
terminate: true,
|
|
588
556
|
};
|
|
589
557
|
|
|
590
558
|
},
|
package/src/durable.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Durable thread state: a manifest next to the config that lets
|
|
3
|
-
*
|
|
2
|
+
* Durable thread state: a manifest next to the config that lets interrupted
|
|
3
|
+
* (parked) sub-agent threads survive pi reloads and restarts, plus the durable
|
|
4
4
|
* state root that keeps their retained sessions and isolated worktrees out of
|
|
5
5
|
* the OS temp directory.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Only parked threads are ever recorded: a thread that settles normally drops
|
|
8
|
+
* its record, so the manifest file exists exactly while unfinished work needs
|
|
9
|
+
* it and disappears on its own. Records are small path/state snapshots, never
|
|
10
|
+
* full transcripts; the retained Pi session files and worktrees they point at
|
|
11
|
+
* remain the actual context. Writes are atomic (tmp+rename) and serialized
|
|
12
|
+
* through the same withFileMutationQueue as the recovery manifest.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
@@ -29,9 +31,9 @@ export const THREADS_MANIFEST_FILE_NAME = "pi-subagents-threads.json";
|
|
|
29
31
|
const THREADS_MANIFEST_VERSION = 1;
|
|
30
32
|
export const STATE_DIR_NAME = "pi-subagents-state";
|
|
31
33
|
|
|
32
|
-
/** Fixed retention:
|
|
33
|
-
*
|
|
34
|
-
|
|
34
|
+
/** Fixed retention: parked work (which may hold unintegrated changes) stops
|
|
35
|
+
* being resumable after a month. Older manifests may still carry settled
|
|
36
|
+
* records from previous versions; restore discards them on sight. */
|
|
35
37
|
export const PARKED_RECORD_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
36
38
|
|
|
37
39
|
/** Result excerpts are for status display after restore, not full transcripts. */
|
|
@@ -297,7 +299,7 @@ async function discardRecordArtifacts(record: ThreadRecord): Promise<void> {
|
|
|
297
299
|
}
|
|
298
300
|
|
|
299
301
|
/** Drop records past their retention age along with their artifacts. Runs at
|
|
300
|
-
* extension load; the fixed
|
|
302
|
+
* extension load; the fixed age honors the no-config-knobs policy. */
|
|
301
303
|
export async function pruneThreadRecords(
|
|
302
304
|
configPath: string,
|
|
303
305
|
now = Date.now(),
|
|
@@ -309,8 +311,7 @@ export async function pruneThreadRecords(
|
|
|
309
311
|
let changed = false;
|
|
310
312
|
const kept: ThreadRecord[] = [];
|
|
311
313
|
for (const record of records) {
|
|
312
|
-
|
|
313
|
-
if (now - record.updatedAt <= maxAge) {
|
|
314
|
+
if (now - record.updatedAt <= PARKED_RECORD_MAX_AGE_MS) {
|
|
314
315
|
kept.push(record);
|
|
315
316
|
continue;
|
|
316
317
|
}
|
package/src/format.ts
CHANGED
|
@@ -156,6 +156,14 @@ export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: nu
|
|
|
156
156
|
return `The sub-agent could not complete this task: ${cause}${retry}.${recovery}`;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/** Instruction appended whenever a failing gate verdict is delivered: the
|
|
160
|
+
* findings return to the main agent, which owns the fix decision — the runtime
|
|
161
|
+
* never auto-fixes. Stating it at this exact decision point keeps the main
|
|
162
|
+
* agent from relaying the findings to the user and stopping. */
|
|
163
|
+
export function reviewFailFollowUpNote(): string {
|
|
164
|
+
return "This gate failed and the findings are yours to resolve now: fix them inline or dispatch a worker briefed with these fix instructions, then re-verify the change. Ask the user only before a genuinely destructive or scope-changing fix; do not deliver while a finding stands.";
|
|
165
|
+
}
|
|
166
|
+
|
|
159
167
|
/** Resolve a run-id request to actual ids: an exact numeric match always wins
|
|
160
168
|
* (so "1" never fans out to 10, 11, …); only when no exact match exists does a
|
|
161
169
|
* prefix match run, as a convenience for partial ids. Keeps single-digit lookups
|
package/src/prompt.ts
CHANGED
|
@@ -40,53 +40,40 @@ export function buildDelegationDirective(
|
|
|
40
40
|
: `${namedWorktreeTargets.slice(0, -1).join(", ")}, ${namedWorktreeTargets.at(-1)}, or another`;
|
|
41
41
|
|
|
42
42
|
const dispatchRules = [
|
|
43
|
-
|
|
43
|
+
`Delegate aggressively: child contexts are cheap, yours is scarce. Inline only trivial work — a one-shot lookup, a single focused edit, an answer already in context${hasWorker ? "; default every non-trivial implementation, fix, refactor, or test task to `worker`" : ""}.`,
|
|
44
44
|
...(hasExplorer
|
|
45
45
|
? [
|
|
46
|
-
"
|
|
46
|
+
"`explorer`: broad or multi-file search. Its findings are leads, never proof — re-read load-bearing files before acting yourself (a child you brief re-verifies). Split a broad question into several parallel explorers with disjoint scopes.",
|
|
47
47
|
]
|
|
48
48
|
: []),
|
|
49
|
-
...(hasWorker
|
|
50
|
-
? ["Use `worker` for a self-contained implementation, fix, refactor, or test whose separate context pays for itself."]
|
|
51
|
-
: []),
|
|
52
49
|
...(hasCleaner
|
|
53
|
-
? [
|
|
54
|
-
`Use \`cleaner\` only for user-authorized cleanup or deduplication; it applies every safe proven cut without item-by-item approval, and never runs as a pre-commit gate or by PR count.`,
|
|
55
|
-
]
|
|
50
|
+
? ["`cleaner`: only user-authorized cleanup or dedup; it applies every safe proven cut without per-item approval and is never a gate."]
|
|
56
51
|
: []),
|
|
57
52
|
...(hasDocumenter
|
|
58
|
-
? [
|
|
59
|
-
`Use \`documenter\` directly only for explicit standalone documentation work; a top-level documenter delivers without a gate.${codeWriterNames.length > 0 ? ` The runtime runs the final docs sync after REVIEW_PASS with DOCUMENTATION: NEEDED or a missing marker, or as the reviewer-disabled fallback; writers sync docs they directly affect — never dispatch a duplicate.` : ""}`,
|
|
60
|
-
]
|
|
53
|
+
? ["`documenter`: standalone docs/comment work, or syncing real drift a change left — writers already sync what they directly affect."]
|
|
61
54
|
: []),
|
|
62
55
|
...(hasReviewer
|
|
63
56
|
? [
|
|
64
|
-
|
|
57
|
+
`\`reviewer\`: read-only assessments and gates${codeWriterNames.length > 0 ? `; successful ${codeWriterNames.join("/")} runs get one fresh gate, and failing gates are fixed by the reviewer itself and re-reviewed until they pass` : ""}. Advisory output has no VERDICT and cannot authorize edits.`,
|
|
65
58
|
]
|
|
66
59
|
: []),
|
|
67
|
-
|
|
68
|
-
"Brief each child
|
|
69
|
-
`
|
|
70
|
-
"A configured child model/provider failure automatically continues the same retained session on the current main model; do not redispatch. Ordinary tool/task failures stay on the selected model.",
|
|
60
|
+
`Parallelize by default: map the todo list onto ONE \`tasks\` dispatch. One child owns one deliverable and its files; only genuinely dependent work waits for its prerequisite.`,
|
|
61
|
+
"Brief each child completely — goal, exact paths, constraints, expected output; it has no conversation memory and cannot delegate. Resume parked threads with `subagent_control resume`.",
|
|
62
|
+
`Request \`isolation: "worktree"\` only for ${worktreeTargets} write-capable agent in a repo with committed HEAD.`,
|
|
71
63
|
];
|
|
72
64
|
|
|
73
65
|
const handoffRules = [
|
|
74
|
-
"Dispatch ends
|
|
66
|
+
"Dispatch never blocks or ends your turn — keep working; each completion resumes you automatically. Never sleep, poll, or `subagent_wait` to hold the turn.",
|
|
75
67
|
"Results are already shown; add only your conclusion or next action, never a restatement.",
|
|
76
|
-
"Before declaring the overall task done,
|
|
68
|
+
"Before declaring the overall task done, `subagent_status` must show no active runs.",
|
|
77
69
|
];
|
|
78
70
|
|
|
79
71
|
const verificationRules = [
|
|
80
|
-
"Never report an unrun check as passed; surface unavailable checks and pre-existing failures
|
|
72
|
+
"Never report an unrun check as passed; surface unavailable checks and pre-existing failures, and inspect actual changes before reporting completion.",
|
|
81
73
|
...(hasReviewer
|
|
82
74
|
? [
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
"A direct REVIEW_PASS with DOCUMENTATION: CLEAN delivers immediately; NEEDED or a missing marker runs one documentation sync.",
|
|
86
|
-
]
|
|
87
|
-
: []),
|
|
88
|
-
"A REVIEW_FAIL — direct or from a managed gate — returns the findings to you: resolve them yourself, inline or via a worker you brief, without waiting for the user; the runtime never auto-fixes. Ask only for genuinely destructive or scope-changing fixes. Advisory reports cannot trigger writes.",
|
|
89
|
-
"Use multi-model cross-review only when explicitly requested or for genuinely high-risk security, unsafe/FFI, persistence-migration, or concurrency changes.",
|
|
75
|
+
"A REVIEW_FAIL from a gate you dispatched directly returns its findings to you: fix them inline or via a briefed worker without waiting for the user (ask only for genuinely destructive or scope-changing fixes), then re-verify.",
|
|
76
|
+
"Multi-model cross-review only when explicitly requested or for high-risk security, unsafe/FFI, persistence-migration, or concurrency changes.",
|
|
90
77
|
]
|
|
91
78
|
: []),
|
|
92
79
|
"Commit or push only when explicitly requested, applicable checks pass, and no review finding remains unresolved.",
|
|
@@ -95,7 +82,7 @@ export function buildDelegationDirective(
|
|
|
95
82
|
return `
|
|
96
83
|
## Sub-agent delegation (pi-subagents)
|
|
97
84
|
|
|
98
|
-
|
|
85
|
+
\`subagent\` runs isolated leaf Pi child processes in the background; each completion resumes you.
|
|
99
86
|
|
|
100
87
|
Agents:
|
|
101
88
|
${catalog}
|
package/src/runtime.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type CompletionMessageItem,
|
|
21
21
|
} from "./completion.ts";
|
|
22
22
|
import { type ThinkingLevel } from "./config.ts";
|
|
23
|
-
import { threadRecordFromThread, upsertThreadRecord, type ThreadRecord } from "./durable.ts";
|
|
23
|
+
import { removeThreadRecord, threadRecordFromThread, upsertThreadRecord, type ThreadRecord } from "./durable.ts";
|
|
24
24
|
import { isRunActiveStatus, monitor } from "./monitor.ts";
|
|
25
25
|
import type { RpcRunControl } from "./rpc-run.ts";
|
|
26
26
|
import type { StartBackgroundInternal } from "./thread-lifecycle.ts";
|
|
@@ -50,8 +50,6 @@ export interface SubagentThread {
|
|
|
50
50
|
executionCwd: string;
|
|
51
51
|
thinkingLevel?: ThinkingLevel;
|
|
52
52
|
isolation: IsolationMode;
|
|
53
|
-
/** Report-only reviewer dispatch: verdicts never chain into a managed workflow. */
|
|
54
|
-
advisoryReview: boolean;
|
|
55
53
|
worktree?: WorktreeIsolation;
|
|
56
54
|
state: ThreadState;
|
|
57
55
|
control: RpcRunControl;
|
|
@@ -242,10 +240,14 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
242
240
|
Promise.allSettled(preflights),
|
|
243
241
|
runtime.backgroundQueue.waitForIdle(),
|
|
244
242
|
]);
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
243
|
+
// Only interrupted (parked) threads stay resumable across reloads:
|
|
244
|
+
// each keeps its durable record and retained artifacts. Settled
|
|
245
|
+
// threads drop their record — the manifest exists only while
|
|
246
|
+
// unfinished work needs it — and their sessions are deleted now. A
|
|
247
|
+
// thread whose settlement finished during the wait above already
|
|
248
|
+
// wrote (or removed) its own record; the lastResult-derived state
|
|
249
|
+
// below matches it.
|
|
250
|
+
const settledIds: number[] = [];
|
|
249
251
|
const records: ThreadRecord[] = [];
|
|
250
252
|
for (const thread of runtime.threads.values()) {
|
|
251
253
|
if (thread.retired) continue;
|
|
@@ -258,11 +260,13 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
258
260
|
} else {
|
|
259
261
|
state = "parked";
|
|
260
262
|
}
|
|
261
|
-
records.push(threadRecordFromThread(thread, state));
|
|
263
|
+
if (state === "parked") records.push(threadRecordFromThread(thread, state));
|
|
264
|
+
else settledIds.push(thread.id);
|
|
262
265
|
}
|
|
263
|
-
await Promise.all(
|
|
264
|
-
records.map((record) => upsertThreadRecord(runtime.configPath, record).catch(() => undefined)),
|
|
265
|
-
|
|
266
|
+
await Promise.all([
|
|
267
|
+
...records.map((record) => upsertThreadRecord(runtime.configPath, record).catch(() => undefined)),
|
|
268
|
+
...settledIds.map((runId) => removeThreadRecord(runtime.configPath, runId).catch(() => undefined)),
|
|
269
|
+
]);
|
|
266
270
|
// Retained-failure recovery records are persisted by the finalization
|
|
267
271
|
// itself; shutdown only drops sessions no record claims anymore.
|
|
268
272
|
const referenced = new Set(
|
package/src/setup.ts
CHANGED
|
@@ -307,9 +307,9 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
|
|
|
307
307
|
next.agentThinkingLevels.cleaner = config.agentThinkingLevels.reviewer;
|
|
308
308
|
}
|
|
309
309
|
}
|
|
310
|
-
// Documenter intentionally follows the faster explorer route.
|
|
311
|
-
//
|
|
312
|
-
// overrides instead of silently choosing a stronger model.
|
|
310
|
+
// Documenter intentionally follows the faster explorer route. When it
|
|
311
|
+
// is re-enabled after being explicitly disabled, it inherits any
|
|
312
|
+
// explorer overrides instead of silently choosing a stronger model.
|
|
313
313
|
if (!config.enabledAgents.includes("documenter") && enabled.includes("documenter")) {
|
|
314
314
|
if (!next.agentModels.documenter && config.agentModels.explorer) {
|
|
315
315
|
next.agentModels.documenter = config.agentModels.explorer;
|