@ferris1225/pi-subagents 1.0.1 → 2.0.1
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/LICENSE +2 -0
- package/README.md +183 -92
- package/agents/cleaner.md +51 -0
- package/agents/explore.md +6 -4
- package/package.json +9 -7
- package/src/announcements.ts +8 -0
- package/src/config.ts +10 -27
- package/src/dispatch.ts +98 -77
- package/src/format.ts +26 -23
- package/src/index.ts +1 -1
- package/src/models.ts +91 -98
- package/src/monitor.ts +11 -3
- package/src/prompt.ts +13 -8
- package/src/rpc-run.ts +89 -6
- package/src/runtime.ts +0 -2
- package/src/setup.ts +463 -639
- package/src/spawn.ts +152 -107
- package/src/tools.ts +22 -6
- package/src/widget.ts +51 -14
package/src/format.ts
CHANGED
|
@@ -73,33 +73,40 @@ export function formatUsage(usage: UsageStats): string {
|
|
|
73
73
|
return parts.join(" ");
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
export
|
|
76
|
+
export interface CompletionFormatOptions {
|
|
77
|
+
/** Include individual failed-tool errors. Reserved for explicit status lookup. */
|
|
78
|
+
failedToolDetails?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function formatCompletionBlock(
|
|
82
|
+
result: SingleResult,
|
|
83
|
+
maxResultLines: number,
|
|
84
|
+
cwd?: string,
|
|
85
|
+
options: CompletionFormatOptions = {},
|
|
86
|
+
): string {
|
|
77
87
|
const failed = isFailedResult(result);
|
|
78
88
|
const failedTools = result.failedTools ?? [];
|
|
79
89
|
const status = failed
|
|
80
90
|
? "failed"
|
|
81
|
-
: failedTools.length > 0
|
|
91
|
+
: options.failedToolDetails && failedTools.length > 0
|
|
82
92
|
? `completed with ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}`
|
|
83
93
|
: "completed";
|
|
84
94
|
const usage = formatUsage(result.usage);
|
|
85
95
|
const output = getResultOutput(result);
|
|
86
96
|
const { text, truncated } = truncateResultOutput(output, maxResultLines);
|
|
87
97
|
const fallbackNote = result.modelFallbackFrom
|
|
88
|
-
? ` (model
|
|
98
|
+
? ` (selected model ${result.modelFallbackFrom} failed → main ${result.model ?? "dynamic default"})`
|
|
89
99
|
: "";
|
|
90
100
|
const startupRetryNote = result.startupRetries
|
|
91
101
|
? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
|
|
92
102
|
: "";
|
|
93
|
-
const modelRetryNote = result.modelRetries
|
|
94
|
-
? ` (recovered after ${result.modelRetries} same-model retr${result.modelRetries === 1 ? "y" : "ies"} on a transient provider error)`
|
|
95
|
-
: "";
|
|
96
103
|
const relations = [
|
|
97
104
|
result.forkedFromRunId !== undefined ? `forked from #${result.forkedFromRunId}` : undefined,
|
|
98
105
|
(result.forkChildRunIds?.length ?? 0) > 0 ? `fork children ${result.forkChildRunIds!.map((id) => `#${id}`).join(", ")}` : undefined,
|
|
99
106
|
].filter((value): value is string => Boolean(value));
|
|
100
107
|
const relationNote = relations.length > 0 ? ` · ${relations.join(" · ")}` : "";
|
|
101
108
|
const runNote = result.runId !== undefined ? ` · run #${result.runId}` : "";
|
|
102
|
-
const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${
|
|
109
|
+
const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${runNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
|
|
103
110
|
if (result.isolation === "worktree") {
|
|
104
111
|
const isolation =
|
|
105
112
|
result.integrationStatus === "integrated"
|
|
@@ -120,23 +127,22 @@ export function formatCompletionBlock(result: SingleResult, maxResultLines: numb
|
|
|
120
127
|
lines.push(`Relation: ${relations.join(" · ")}`, "");
|
|
121
128
|
}
|
|
122
129
|
lines.push(text);
|
|
123
|
-
//
|
|
124
|
-
// the
|
|
125
|
-
//
|
|
126
|
-
if (
|
|
127
|
-
const shown = failedTools.slice(0, 3);
|
|
128
|
-
const more = failedTools.length - shown.length;
|
|
130
|
+
// Explicit status always exposes every retained diagnostic, including when
|
|
131
|
+
// the overall run failed or was aborted. Automatic delivery adds only a
|
|
132
|
+
// compact pointer for otherwise-clean runs.
|
|
133
|
+
if (options.failedToolDetails && failedTools.length > 0) {
|
|
129
134
|
lines.push(
|
|
130
135
|
"",
|
|
131
|
-
`⚠ ${failedTools.length} tool call${failedTools.length === 1 ? "" : "s"}
|
|
132
|
-
...
|
|
136
|
+
`⚠ ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}:`,
|
|
137
|
+
...failedTools.map((tool) => `- ${tool.toolName}: ${tool.error.trim() || "(no output)"}`),
|
|
133
138
|
);
|
|
134
|
-
|
|
135
|
-
|
|
139
|
+
} else if (!failed && failedTools.length > 0) {
|
|
140
|
+
const lookup = result.runId !== undefined ? ` · details: subagent_status #${result.runId}` : "";
|
|
141
|
+
lines.push("", `⚠ ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}${lookup}`);
|
|
136
142
|
}
|
|
137
143
|
if (truncated) {
|
|
138
144
|
// The full text lives on disk so the main agent can read it on demand.
|
|
139
|
-
lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent, cwd)})`);
|
|
145
|
+
lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent, result.projectCwd ?? cwd)})`);
|
|
140
146
|
}
|
|
141
147
|
return lines.join("\n");
|
|
142
148
|
}
|
|
@@ -148,15 +154,12 @@ export function formatCompletionBlock(result: SingleResult, maxResultLines: numb
|
|
|
148
154
|
* RESUME it in-context once a model is available, instead of re-dispatching
|
|
149
155
|
* fresh (which would re-scan everything). */
|
|
150
156
|
export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: number }): string {
|
|
151
|
-
const
|
|
152
|
-
? `, after ${result.modelRetries} same-model retr${result.modelRetries === 1 ? "y" : "ies"} on transient errors`
|
|
153
|
-
: "";
|
|
154
|
-
const retry = result.modelFallbackFrom ? ", and the configured backup chain also failed" : "";
|
|
157
|
+
const retry = result.modelFallbackFrom ? ", and the current main model also failed" : "";
|
|
155
158
|
const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
|
|
156
159
|
const recovery = sessionPreserved
|
|
157
160
|
? ` The sub-agent's earlier work in this run is preserved. Once a model is available again, call subagent_control with { action: "resume", id: ${opts!.runId} } to CONTINUE it in-context (it keeps the same run id and does not re-scan), or execute the task in the main window with your own tools.`
|
|
158
161
|
: ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
|
|
159
|
-
return `The sub-agent could not complete this task: its model was unavailable or failed (or the run stalled)${
|
|
162
|
+
return `The sub-agent could not complete this task: its model was unavailable or failed (or the run stalled)${retry}.${recovery}`;
|
|
160
163
|
}
|
|
161
164
|
|
|
162
165
|
/** Resolve a run-id request to actual ids: an exact numeric match always wins
|
package/src/index.ts
CHANGED
|
@@ -68,7 +68,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
68
68
|
registerLookupTools(pi, runtime);
|
|
69
69
|
|
|
70
70
|
pi.registerCommand("subagents-setup", {
|
|
71
|
-
description: "Configure pi-subagents:
|
|
71
|
+
description: "Configure pi-subagents: agents, selected models, capability-aware thinking, vision, and runtime settings",
|
|
72
72
|
handler: async (_args, ctx) => {
|
|
73
73
|
await runSetup(ctx, configPath);
|
|
74
74
|
},
|
package/src/models.ts
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Model-
|
|
2
|
+
* Model routing, capability-aware thinking, and setup-picker helpers.
|
|
3
3
|
*
|
|
4
|
-
* Runtime
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* only for honest availability labels; it never rewrites persisted choices.
|
|
4
|
+
* Runtime has one explicit fallback only: a configured agent/vision model hands
|
|
5
|
+
* off directly to the current main-window model. Setup lists only currently
|
|
6
|
+
* available models and derives thinking choices from Pi's model metadata.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
|
-
import
|
|
9
|
+
import {
|
|
10
|
+
clampThinkingLevel,
|
|
11
|
+
getSupportedThinkingLevels,
|
|
12
|
+
type Api,
|
|
13
|
+
type Model,
|
|
14
|
+
} from "@earendil-works/pi-ai";
|
|
11
15
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
|
|
12
17
|
|
|
13
18
|
export type ModelContext = Pick<ExtensionContext, "model" | "modelRegistry"> &
|
|
14
19
|
Partial<Pick<ExtensionContext, "scopedModels">>;
|
|
15
20
|
|
|
16
21
|
export const CURRENT_MAIN_MODEL = "__current_main_model__";
|
|
17
22
|
|
|
18
|
-
export type
|
|
19
|
-
export type ModelPickerSlot = ModelPoolSlot | "vision";
|
|
23
|
+
export type ModelPickerSlot = "agent" | "vision";
|
|
20
24
|
|
|
21
25
|
export interface ModelPickerItem {
|
|
22
26
|
value: string;
|
|
@@ -26,34 +30,26 @@ export interface ModelPickerItem {
|
|
|
26
30
|
|
|
27
31
|
export type ModelListEntry = Pick<
|
|
28
32
|
Model<Api>,
|
|
29
|
-
"provider" | "id" | "name" | "input" | "reasoning"
|
|
33
|
+
"provider" | "id" | "name" | "input" | "reasoning" | "thinkingLevelMap"
|
|
30
34
|
>;
|
|
31
35
|
|
|
32
|
-
export interface
|
|
33
|
-
/** Effective first candidate. Undefined means let
|
|
36
|
+
export interface ResolvedAgentModelRoute {
|
|
37
|
+
/** Effective first candidate. Undefined means let Pi use its normal default. */
|
|
34
38
|
primaryRef?: string;
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
/**
|
|
39
|
+
/** Current main-window model when it differs from the selection. */
|
|
40
|
+
mainFallbackRef?: string;
|
|
41
|
+
/** Runtime order, useful for status/tests. */
|
|
38
42
|
candidateRefs: string[];
|
|
43
|
+
/** Configured ref skipped because Pi does not currently report it available. */
|
|
44
|
+
unavailableSelectedRef?: string;
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
export interface
|
|
42
|
-
|
|
43
|
-
backupRef?: string;
|
|
47
|
+
export interface AgentModelRouteInput {
|
|
48
|
+
selectedRef?: string;
|
|
44
49
|
mainRef?: string;
|
|
45
50
|
declaredDefaultRef?: string;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
export interface AgentModelPoolMaps {
|
|
49
|
-
agentModels: Record<string, string>;
|
|
50
|
-
agentBackupModels: Record<string, string>;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface AgentModelPoolRow {
|
|
54
|
-
name: string;
|
|
55
|
-
primary: string;
|
|
56
|
-
backup: string;
|
|
51
|
+
/** When supplied, a configured selection outside this live set is skipped. */
|
|
52
|
+
availableRefs?: readonly string[];
|
|
57
53
|
}
|
|
58
54
|
|
|
59
55
|
function cleanModelRef(ref: string | undefined): string | undefined {
|
|
@@ -76,62 +72,84 @@ export function currentModelRef(ctx: Pick<ModelContext, "model">): string | unde
|
|
|
76
72
|
*/
|
|
77
73
|
export function availableModelsInScope(ctx: ModelContext): readonly Model<Api>[] {
|
|
78
74
|
const models = ctx.modelRegistry.getAvailable();
|
|
79
|
-
// scopedModels was added after the
|
|
80
|
-
//
|
|
75
|
+
// scopedModels was added after the original Pi minimum. Treat a missing field
|
|
76
|
+
// exactly like an empty scope and use the full live registry.
|
|
81
77
|
const scopedModels = ctx.scopedModels ?? [];
|
|
82
78
|
if (scopedModels.length === 0) return models;
|
|
83
79
|
const scopedRefs = new Set(scopedModels.map((entry) => modelRef(entry.model)));
|
|
84
80
|
return models.filter((model) => scopedRefs.has(modelRef(model)));
|
|
85
81
|
}
|
|
86
82
|
|
|
83
|
+
export function findModelByRef(
|
|
84
|
+
models: readonly Model<Api>[],
|
|
85
|
+
ref: string | undefined,
|
|
86
|
+
): Model<Api> | undefined {
|
|
87
|
+
const normalized = cleanModelRef(ref);
|
|
88
|
+
return normalized ? models.find((model) => modelRef(model) === normalized) : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
87
91
|
/**
|
|
88
|
-
* Resolve one agent's
|
|
92
|
+
* Resolve one agent's runtime route:
|
|
89
93
|
*
|
|
90
|
-
* configured
|
|
94
|
+
* configured selection -> current main-window model
|
|
91
95
|
*
|
|
92
|
-
* Without
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* fail normally at runtime instead of being silently repaired.
|
|
96
|
+
* Without an override, current main is primary; the agent-declared default is
|
|
97
|
+
* used only when no main model exists. A configured selection that Pi no longer
|
|
98
|
+
* reports as available is skipped immediately instead of spawning a doomed child.
|
|
96
99
|
*/
|
|
97
|
-
export function
|
|
100
|
+
export function resolveAgentModelRoute(input: AgentModelRouteInput): ResolvedAgentModelRoute {
|
|
101
|
+
const selectedRef = cleanModelRef(input.selectedRef);
|
|
98
102
|
const mainRef = cleanModelRef(input.mainRef);
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
103
|
+
const declaredDefaultRef = cleanModelRef(input.declaredDefaultRef);
|
|
104
|
+
const available = input.availableRefs
|
|
105
|
+
? new Set(input.availableRefs.map((ref) => ref.trim()).filter(Boolean))
|
|
106
|
+
: undefined;
|
|
107
|
+
const selectedAvailable = !selectedRef || !available || available.has(selectedRef);
|
|
108
|
+
const usableSelectedRef = selectedAvailable ? selectedRef : undefined;
|
|
109
|
+
const primaryRef = usableSelectedRef ?? mainRef ?? declaredDefaultRef;
|
|
110
|
+
const ordered = [primaryRef, usableSelectedRef && usableSelectedRef !== mainRef ? mainRef : undefined];
|
|
111
|
+
const candidateRefs = [...new Set(ordered.filter((ref): ref is string => Boolean(ref)))];
|
|
112
|
+
return {
|
|
113
|
+
primaryRef,
|
|
114
|
+
...(candidateRefs[1] ? { mainFallbackRef: candidateRefs[1] } : {}),
|
|
115
|
+
candidateRefs,
|
|
116
|
+
...(!selectedAvailable && selectedRef ? { unavailableSelectedRef: selectedRef } : {}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The exact levels Pi exposes for this model, including `off` when supported. */
|
|
121
|
+
export function supportedThinkingLevels(model: Model<Api> | undefined): ThinkingLevel[] {
|
|
122
|
+
return model ? (getSupportedThinkingLevels(model) as ThinkingLevel[]) : [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Clamp an agent preference to the effective model's actual capability map. */
|
|
126
|
+
export function resolveThinkingLevel(
|
|
127
|
+
model: Model<Api> | undefined,
|
|
128
|
+
preferred: ThinkingLevel = DEFAULT_THINKING_LEVEL,
|
|
129
|
+
): ThinkingLevel {
|
|
130
|
+
return model ? (clampThinkingLevel(model, preferred) as ThinkingLevel) : preferred;
|
|
110
131
|
}
|
|
111
132
|
|
|
112
133
|
function modelCapabilities(model: ModelListEntry): string {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
return
|
|
134
|
+
const input = model.input.includes("image") ? "vision" : "text-only";
|
|
135
|
+
const thinking = getSupportedThinkingLevels(model as Model<Api>).join("/");
|
|
136
|
+
return `${input} · thinking: ${thinking}`;
|
|
116
137
|
}
|
|
117
138
|
|
|
118
|
-
/** Build
|
|
119
|
-
*
|
|
120
|
-
* without a configured API key/OAuth session never flood the setup picker. */
|
|
139
|
+
/** Build one searchable list for agent or vision selection. Only models Pi
|
|
140
|
+
* currently reports as available are supplied by setup. */
|
|
121
141
|
export function buildModelPickerItems(options: {
|
|
122
142
|
models: readonly ModelListEntry[];
|
|
123
|
-
availableRefs: readonly string[];
|
|
124
143
|
slot: ModelPickerSlot;
|
|
125
144
|
configuredRef?: string;
|
|
126
145
|
mainRef?: string;
|
|
127
146
|
}): ModelPickerItem[] {
|
|
128
147
|
const configuredRef = cleanModelRef(options.configuredRef);
|
|
129
148
|
const mainRef = cleanModelRef(options.mainRef);
|
|
130
|
-
const available = new Set(options.availableRefs.map((ref) => ref.trim()));
|
|
131
149
|
const byRef = new Map<string, ModelListEntry>();
|
|
132
150
|
for (const model of options.models) {
|
|
133
151
|
const ref = modelRef(model);
|
|
134
|
-
if (
|
|
152
|
+
if (!byRef.has(ref)) byRef.set(ref, model);
|
|
135
153
|
}
|
|
136
154
|
|
|
137
155
|
const refs = [...byRef.keys()]
|
|
@@ -142,26 +160,18 @@ export function buildModelPickerItems(options: {
|
|
|
142
160
|
return leftRank - rightRank || left.localeCompare(right);
|
|
143
161
|
});
|
|
144
162
|
|
|
145
|
-
const dynamic =
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
value: CURRENT_MAIN_MODEL,
|
|
153
|
-
label: "Current main model (dynamic)",
|
|
154
|
-
description: options.slot === "vision"
|
|
155
|
-
? "Clear vision override; use the main-window model for vision tasks"
|
|
156
|
-
: "Clear primary override; use the main-window model dynamically",
|
|
157
|
-
};
|
|
158
|
-
|
|
163
|
+
const dynamic: ModelPickerItem = {
|
|
164
|
+
value: CURRENT_MAIN_MODEL,
|
|
165
|
+
label: "Current main model (dynamic)",
|
|
166
|
+
description: options.slot === "vision"
|
|
167
|
+
? "Clear vision override; use the current main model for image tasks"
|
|
168
|
+
: "Clear agent override; use the current main model dynamically",
|
|
169
|
+
};
|
|
159
170
|
const items: ModelPickerItem[] = [dynamic];
|
|
160
171
|
for (const ref of refs) {
|
|
161
172
|
const model = byRef.get(ref)!;
|
|
162
|
-
const tags = ["
|
|
163
|
-
|
|
164
|
-
if (ref === mainRef) tags.push("current main");
|
|
173
|
+
const tags = [ref === configuredRef ? "configured" : "", ref === mainRef ? "current main" : ""]
|
|
174
|
+
.filter(Boolean);
|
|
165
175
|
const name = model.name.trim() && model.name !== model.id ? model.name.trim() : undefined;
|
|
166
176
|
items.push({
|
|
167
177
|
value: ref,
|
|
@@ -172,31 +182,14 @@ export function buildModelPickerItems(options: {
|
|
|
172
182
|
return items;
|
|
173
183
|
}
|
|
174
184
|
|
|
175
|
-
/**
|
|
176
|
-
export function
|
|
177
|
-
current:
|
|
185
|
+
/** The dynamic choice removes the persisted per-agent override. */
|
|
186
|
+
export function applyAgentModelChoice(
|
|
187
|
+
current: Record<string, string>,
|
|
178
188
|
agentName: string,
|
|
179
|
-
slot: ModelPoolSlot,
|
|
180
189
|
choice: string,
|
|
181
|
-
):
|
|
182
|
-
const next
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
};
|
|
186
|
-
const target = slot === "primary" ? next.agentModels : next.agentBackupModels;
|
|
187
|
-
if (choice === CURRENT_MAIN_MODEL) delete target[agentName];
|
|
188
|
-
else target[agentName] = choice.trim();
|
|
190
|
+
): Record<string, string> {
|
|
191
|
+
const next = { ...current };
|
|
192
|
+
if (choice === CURRENT_MAIN_MODEL) delete next[agentName];
|
|
193
|
+
else next[agentName] = choice.trim();
|
|
189
194
|
return next;
|
|
190
195
|
}
|
|
191
|
-
|
|
192
|
-
/** Pure rows used by the overview component and focused helper tests. */
|
|
193
|
-
export function buildAgentModelPoolRows(
|
|
194
|
-
agentNames: readonly string[],
|
|
195
|
-
pools: AgentModelPoolMaps,
|
|
196
|
-
): AgentModelPoolRow[] {
|
|
197
|
-
return agentNames.map((name) => ({
|
|
198
|
-
name,
|
|
199
|
-
primary: pools.agentModels[name] ?? "Main (dynamic)",
|
|
200
|
-
backup: pools.agentBackupModels[name] ?? "Main (default)",
|
|
201
|
-
}));
|
|
202
|
-
}
|
package/src/monitor.ts
CHANGED
|
@@ -33,7 +33,7 @@ export interface RunView {
|
|
|
33
33
|
* are doing, not just their run id. */
|
|
34
34
|
label?: string;
|
|
35
35
|
model?: string;
|
|
36
|
-
/**
|
|
36
|
+
/** Selected model ref when the run handed off to current main. */
|
|
37
37
|
modelFallbackFrom?: string;
|
|
38
38
|
/** Effective thinking strength this run was launched with (frontmatter/config/global). */
|
|
39
39
|
thinking?: string;
|
|
@@ -406,7 +406,7 @@ export class MonitorStore {
|
|
|
406
406
|
run.status = status;
|
|
407
407
|
if (status === "running" || status === "steering" || status === "interrupting") {
|
|
408
408
|
if (run.startedAt === undefined) run.startedAt = Date.now();
|
|
409
|
-
// A
|
|
409
|
+
// A selected-to-main handoff or resumed generation restarts the clock; a
|
|
410
410
|
// stale endedAt would freeze the elapsed display at the first attempt.
|
|
411
411
|
if (run.endedAt !== undefined) run.endedAt = undefined;
|
|
412
412
|
} else if ((status === "parked" || status === "done" || status === "failed") && run.endedAt === undefined) {
|
|
@@ -422,7 +422,7 @@ export class MonitorStore {
|
|
|
422
422
|
this.notify();
|
|
423
423
|
}
|
|
424
424
|
|
|
425
|
-
/** Record the final actual model and
|
|
425
|
+
/** Record the final actual model and selected-to-main transition. */
|
|
426
426
|
setModel(id: number, model?: string, fallbackFrom?: string): void {
|
|
427
427
|
const run = this.find(id);
|
|
428
428
|
if (!run) return;
|
|
@@ -431,6 +431,14 @@ export class MonitorStore {
|
|
|
431
431
|
this.notify();
|
|
432
432
|
}
|
|
433
433
|
|
|
434
|
+
/** Record the capability-clamped thinking level for the active model. */
|
|
435
|
+
setThinking(id: number, thinking?: string): void {
|
|
436
|
+
const run = this.find(id);
|
|
437
|
+
if (!run || !thinking) return;
|
|
438
|
+
run.thinking = thinking;
|
|
439
|
+
this.notify();
|
|
440
|
+
}
|
|
441
|
+
|
|
434
442
|
/** Update the run's current one-line activity (what it is doing now). */
|
|
435
443
|
setActivity(id: number, text: string): void {
|
|
436
444
|
const run = this.find(id);
|
package/src/prompt.ts
CHANGED
|
@@ -16,8 +16,9 @@ import { formatCatalogEntry } from "./agents.ts";
|
|
|
16
16
|
|
|
17
17
|
/** Compact role routing hints, emitted only for roles that are enabled. */
|
|
18
18
|
const ROLE_ROUTING: Record<string, string> = {
|
|
19
|
-
explore: "explore — codebase reconnaissance: broad/open-ended search, multi-file lookups, mapping unfamiliar code, tracing symbols/dependencies (read-only,
|
|
19
|
+
explore: "explore — codebase reconnaissance: broad/open-ended search, multi-file lookups, mapping unfamiliar code, tracing symbols/dependencies (read-only, competent fast model); NOT for one-line lookups.",
|
|
20
20
|
worker: "worker — implement/fix/refactor/test a self-contained task worth a separate context (full tools; plans internally).",
|
|
21
|
+
cleaner: "cleaner — evidence-first cleanup for explicit cleanup intent in any language (for example dead code, redundancy, simplification, or over-engineering) or a requested periodic cleanup pass; audit/find/inspect/report is read-only, while explicit remove/clean/simplify/refactor wording permits verified edits; never PR-count or pre-commit driven (reviewer remains the gate).",
|
|
21
22
|
reviewer: "reviewer — adversarial pre-commit review of a diff (read-only; independent context).",
|
|
22
23
|
};
|
|
23
24
|
|
|
@@ -30,6 +31,8 @@ export function buildDelegationDirective(agents: AgentConfig[]): string {
|
|
|
30
31
|
.filter((line): line is string => Boolean(line))
|
|
31
32
|
.map((line) => `- ${line}`)
|
|
32
33
|
.join("\n");
|
|
34
|
+
const hasExplore = agents.some((a) => a.name === "explore");
|
|
35
|
+
const hasCleaner = agents.some((a) => a.name === "cleaner");
|
|
33
36
|
const hasReviewer = agents.some((a) => a.name === "reviewer");
|
|
34
37
|
const hasMultiple = agents.length > 1;
|
|
35
38
|
|
|
@@ -54,18 +57,18 @@ ${catalog}
|
|
|
54
57
|
|
|
55
58
|
${routing ? `Routing:\n${routing}\n` : ""}Dispatch discipline:
|
|
56
59
|
- Handle SIMPLE work INLINE with direct tools: a one-line lookup, single edit, or quick question is a grep/read/edit in the main context — never a sub-agent. Sub-agents cost startup time, tokens, and a context switch.
|
|
57
|
-
- Use \`explore\` PROACTIVELY for codebase reconnaissance: mapping an unfamiliar area, multi-file lookups, tracing symbols across modules, or any "where is X / which files reference Y" question that would take several greps or reading multiple files. It
|
|
58
|
-
- Delegate only when isolation genuinely pays: a self-contained implementation/fix with its own validation (worker), or a fresh-context review gate (reviewer).
|
|
59
|
-
- When in doubt, start with a direct tool call in the main context; escalate to \`explore\` as soon as the search turns broad or crosses multiple files.
|
|
60
|
+
- Use \`explore\` PROACTIVELY for codebase reconnaissance: mapping an unfamiliar area, multi-file lookups, tracing symbols across modules, or any "where is X / which files reference Y" question that would take several greps or reading multiple files. It should run on a competent fast code model and returns compressed findings, saving main-context space.
|
|
61
|
+
- Delegate only when isolation genuinely pays: a self-contained implementation/fix with its own validation (worker)${hasCleaner ? ", explicit evidence-first cleanup (cleaner)" : ""}, or a fresh-context review gate (reviewer).
|
|
62
|
+
${hasCleaner ? "- Route explicit cleanup intent in any language to `cleaner` (for example dead code, redundancy, simplification, or over-engineering), including a requested periodic maintenance pass. Audit/find/inspect/report wording means read-only evidence; apply only for explicit remove/clean/simplify/refactor wording. Generic code review without cleanup intent goes to `reviewer`. Never dispatch cleaner by PR count or automatically as the pre-commit gate; `reviewer` separately reviews cleaner edits.\n" : ""}- When in doubt, start with a direct tool call in the main context; escalate to \`explore\` as soon as the search turns broad or crosses multiple files.
|
|
60
63
|
- For an already-known or trivial target, use a direct search/read tool (e.g. grep/find/read) — do not over-delegate a one-line lookup.
|
|
61
|
-
${hasMultiple ?
|
|
64
|
+
${hasMultiple ? `- Run INDEPENDENT tasks in parallel: one subagent call with a \`tasks\` array, and track them with your todo list. Parallel worker items default to detached Git worktree isolation; pass \`isolation: "shared"\` only when a worker intentionally needs the caller's live uncommitted tree.${hasCleaner ? " Cleaner is also write-capable and may use explicit worktree isolation." : ""} Let the automatically resumed main agent launch dependent work only after its prerequisite result arrives (e.g. explore, then ${hasCleaner ? "worker/cleaner" : "worker"}, then reviewer).\n` : ""}- Single dispatch stays in the shared working tree by default. Use \`isolation: "worktree"\` only for ${hasCleaner ? "worker, cleaner, or another" : "worker or another"} write-capable agent in a Git repository; never request it for explore/reviewer, and never silently retry shared after setup fails.
|
|
62
65
|
- Brief each sub-agent as self-contained: goal, exact paths, constraints, expected output. It has NO memory of this conversation.
|
|
63
66
|
- Treat delegated agents as leaf workers: do not ask a sub-agent to dispatch another sub-agent; child processes do not have this tool. Use \`subagent_control fork\` on a parked/settled retained thread when you need an independent continuation with preserved context and a new run id.
|
|
64
67
|
- Trust but verify: a sub-agent's summary describes intent, not outcome. Check the actual changes/results before reporting work done.
|
|
65
|
-
|
|
68
|
+
${hasExplore ? "- Treat `explore` findings as a retrieval index, never as sole proof for edits, deletion, security, compatibility, persistence, or dynamic reachability. Re-read load-bearing files before acting. An underpowered model can be false economy on complex dynamic, concurrent, migration, or security-sensitive code; use a stronger model or specialist there.\n" : ""}
|
|
66
69
|
Vision tasks:
|
|
67
70
|
- Judge whether a delegated task may require viewing images (frontend screenshots, mockups, design files, visual regression comparisons). If it might, pass \`vision: true\` in the subagent call and give the sub-agent the exact image paths — it reads them with its read tool.
|
|
68
|
-
- \`vision: true\` runs the sub-agent on the vision-capable model configured in /subagents-setup; when none is configured it falls back to the main session's current model. Do not skip the flag because the agent's default model looks fast
|
|
71
|
+
- \`vision: true\` runs the sub-agent on the vision-capable model configured in /subagents-setup; when none is configured it falls back to the main session's current model. Do not skip the flag because the agent's default model looks fast — a non-vision model cannot see the images.
|
|
69
72
|
|
|
70
73
|
Result handoff (do not re-state):
|
|
71
74
|
- A sub-agent's result arrives as a message that is already shown to the user. Do NOT restate, paraphrase, or re-summarize its findings in your reply — that just burns tokens duplicating what is already visible. The user can read the result above.
|
|
@@ -75,5 +78,7 @@ Result handoff (do not re-state):
|
|
|
75
78
|
|
|
76
79
|
Review & verification:
|
|
77
80
|
- Never report an unrun check as passed; report it as unavailable or as a pre-existing failure.
|
|
78
|
-
${hasReviewer ?
|
|
81
|
+
${hasReviewer ? `- For non-trivial diffs${hasCleaner ? " (including cleaner edits)" : ""}, run one fresh read-only \`reviewer\` sub-agent before reporting done. Fix only concrete blockers and re-review at most once.
|
|
82
|
+
- Use multi-model cross-review only when explicitly requested or for genuinely high-risk changes (security, unsafe/FFI, persistence-migration, concurrency). Reviewers are read-only; only the main agent edits.
|
|
83
|
+
` : ""}- Commit or push only when explicitly requested, applicable checks pass, and no accepted blockers remain.`;
|
|
79
84
|
}
|
package/src/rpc-run.ts
CHANGED
|
@@ -52,18 +52,23 @@ export interface RpcSingleResult {
|
|
|
52
52
|
thinking?: string;
|
|
53
53
|
stopReason?: string;
|
|
54
54
|
errorMessage?: string;
|
|
55
|
-
/**
|
|
55
|
+
/** Selected model when this result handed off to the current main model. */
|
|
56
56
|
modelFallbackFrom?: string;
|
|
57
57
|
dispatchFailed?: boolean;
|
|
58
58
|
/** An accepted generation failed because an RPC prompt was rejected before
|
|
59
|
-
* model execution. This remains model
|
|
60
|
-
*
|
|
59
|
+
* model execution. This remains main-model handoff eligible even when an
|
|
60
|
+
* earlier, aborted objective left assistant text in the session. */
|
|
61
61
|
rpcPromptRejected?: boolean;
|
|
62
|
+
/** The child accepted a prompt; startup retries must never duplicate it. */
|
|
63
|
+
rpcPromptAccepted?: boolean;
|
|
64
|
+
/** Pi emitted agent/turn/model/tool activity for this attempt. */
|
|
65
|
+
rpcActivity?: boolean;
|
|
62
66
|
startupRetries?: number;
|
|
63
|
-
modelRetries?: number;
|
|
64
67
|
failedTools?: Array<{ toolName: string; error: string }>;
|
|
65
68
|
sessionId?: string;
|
|
66
69
|
sessionDir?: string;
|
|
70
|
+
/** Original task/project cwd used for result-artifact retention buckets. */
|
|
71
|
+
projectCwd?: string;
|
|
67
72
|
/** Internal disposition: dispatch suppresses completion delivery for parks. */
|
|
68
73
|
parked?: boolean;
|
|
69
74
|
/** Stable logical run id assigned by dispatch (also present on queued results). */
|
|
@@ -84,7 +89,7 @@ export interface RpcSingleResult {
|
|
|
84
89
|
|
|
85
90
|
export type SubagentLiveEvent =
|
|
86
91
|
| { kind: "status"; status: "queued" | "running" | "steering" | "interrupting" | "parked" | "done" | "failed" }
|
|
87
|
-
| { kind: "model"; model?: string; fallbackFrom?: string }
|
|
92
|
+
| { kind: "model"; model?: string; thinking?: ThinkingLevel; fallbackFrom?: string }
|
|
88
93
|
| { kind: "usage"; usage: UsageStats; model?: string }
|
|
89
94
|
| { kind: "tool_start"; toolCallId?: string; toolName: string; args: unknown }
|
|
90
95
|
| { kind: "tool_end"; toolCallId?: string; toolName: string; isError: boolean }
|
|
@@ -110,7 +115,7 @@ interface AttemptControl {
|
|
|
110
115
|
}
|
|
111
116
|
|
|
112
117
|
/**
|
|
113
|
-
* Stable control surface for a logical run generation.
|
|
118
|
+
* Stable control surface for a logical run generation. Startup/main-handoff attempts
|
|
114
119
|
* attach and detach beneath it, so callers never retain a stale child handle.
|
|
115
120
|
* Control calls are serialized to prevent overlapping abort/settle/prompt flows.
|
|
116
121
|
*/
|
|
@@ -338,6 +343,46 @@ export function extractToolErrorText(content: unknown): string {
|
|
|
338
343
|
.join("\n");
|
|
339
344
|
}
|
|
340
345
|
|
|
346
|
+
interface ChildRetryPolicyExtension {
|
|
347
|
+
dir: string;
|
|
348
|
+
filePath: string;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Build a child-only Pi extension that replaces the selected provider's
|
|
352
|
+
* stream adapter with its registered API implementation while forcing
|
|
353
|
+
* maxRetries=0. It uses Pi's public extension and pi-ai compatibility APIs, so
|
|
354
|
+
* it works in Node and standalone/Bun builds without touching user settings. */
|
|
355
|
+
export async function writeChildRetryPolicyExtension(
|
|
356
|
+
modelRef?: string,
|
|
357
|
+
): Promise<ChildRetryPolicyExtension> {
|
|
358
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-subagents-policy-"));
|
|
359
|
+
const filePath = join(dir, "no-provider-retries.mjs");
|
|
360
|
+
const slash = modelRef?.indexOf("/") ?? -1;
|
|
361
|
+
const selectedProvider = slash > 0 ? modelRef!.slice(0, slash) : undefined;
|
|
362
|
+
const source = `import { getApiProvider } from "@earendil-works/pi-ai/compat";\n`
|
|
363
|
+
+ `const selectedProvider = ${JSON.stringify(selectedProvider)};\n`
|
|
364
|
+
+ `export default function noProviderRetries(pi) {\n`
|
|
365
|
+
+ ` pi.on("before_provider_request", (_event, ctx) => {\n`
|
|
366
|
+
+ ` const providerId = ctx.model?.provider ?? selectedProvider;\n`
|
|
367
|
+
+ ` if (!providerId) return;\n`
|
|
368
|
+
+ ` pi.registerProvider(providerId, {\n`
|
|
369
|
+
+ ` streamSimple(model, context, options) {\n`
|
|
370
|
+
+ ` const api = getApiProvider(model.api);\n`
|
|
371
|
+
+ ` if (!api) throw new Error(\`No API stream implementation is registered for \${model.api}.\`);\n`
|
|
372
|
+
+ ` return api.streamSimple(model, context, { ...options, maxRetries: 0 });\n`
|
|
373
|
+
+ ` },\n`
|
|
374
|
+
+ ` });\n`
|
|
375
|
+
+ ` });\n`
|
|
376
|
+
+ `}\n`;
|
|
377
|
+
try {
|
|
378
|
+
await writeFile(filePath, source, "utf8");
|
|
379
|
+
return { dir, filePath };
|
|
380
|
+
} catch (error) {
|
|
381
|
+
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
341
386
|
async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
|
|
342
387
|
const dir = await mkdtemp(join(tmpdir(), "pi-subagents-"));
|
|
343
388
|
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
@@ -422,6 +467,15 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
422
467
|
args.push("--append-system-prompt", tmpPromptPath);
|
|
423
468
|
}
|
|
424
469
|
|
|
470
|
+
let retryPolicy: ChildRetryPolicyExtension;
|
|
471
|
+
try {
|
|
472
|
+
retryPolicy = await writeChildRetryPolicyExtension(agent.model);
|
|
473
|
+
args.push("--extension", retryPolicy.filePath);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (tmpPromptDir) await rm(tmpPromptDir, { recursive: true, force: true }).catch(() => undefined);
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
|
|
425
479
|
const result: RpcSingleResult = {
|
|
426
480
|
agent: agentName,
|
|
427
481
|
task,
|
|
@@ -512,6 +566,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
512
566
|
const resolveInitialPrompt = (accepted: boolean, error?: Error): void => {
|
|
513
567
|
if (initialPromptResolved) return;
|
|
514
568
|
initialPromptResolved = true;
|
|
569
|
+
if (accepted) result.rpcPromptAccepted = true;
|
|
515
570
|
initialPrompt.resolve({ accepted, error });
|
|
516
571
|
};
|
|
517
572
|
|
|
@@ -738,6 +793,33 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
738
793
|
}
|
|
739
794
|
if (finished) return;
|
|
740
795
|
|
|
796
|
+
if (
|
|
797
|
+
[
|
|
798
|
+
"agent_start",
|
|
799
|
+
"agent_end",
|
|
800
|
+
"turn_start",
|
|
801
|
+
"turn_end",
|
|
802
|
+
"message_start",
|
|
803
|
+
"message_update",
|
|
804
|
+
"message_end",
|
|
805
|
+
"tool_execution_start",
|
|
806
|
+
"tool_execution_update",
|
|
807
|
+
"tool_execution_end",
|
|
808
|
+
"auto_retry_start",
|
|
809
|
+
"auto_retry_end",
|
|
810
|
+
"agent_settled",
|
|
811
|
+
].includes(event.type)
|
|
812
|
+
) {
|
|
813
|
+
result.rpcActivity = true;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// The child provider adapter disables request-level retries. Cancel Pi's
|
|
817
|
+
// separate outer turn retry the instant it is scheduled, before another
|
|
818
|
+
// same-model provider call can begin.
|
|
819
|
+
if (event.type === "auto_retry_start") {
|
|
820
|
+
void send({ type: "abort_retry" }).catch(() => undefined);
|
|
821
|
+
}
|
|
822
|
+
|
|
741
823
|
// Child RPC mode exposes extension dialogs. Sub-agents are non-interactive:
|
|
742
824
|
// cancel blocking dialogs so an unrelated child extension cannot deadlock.
|
|
743
825
|
if (
|
|
@@ -973,5 +1055,6 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
973
1055
|
/* ignore */
|
|
974
1056
|
}
|
|
975
1057
|
}
|
|
1058
|
+
await rm(retryPolicy.dir, { recursive: true, force: true }).catch(() => undefined);
|
|
976
1059
|
}
|
|
977
1060
|
}
|