pi-pr-review 1.1.2 → 1.2.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/README.md +14 -7
- package/extensions/pr-review-subagent.ts +300 -68
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -63,15 +63,17 @@ The `/pr-review-config` command maps three labels to models:
|
|
|
63
63
|
```
|
|
64
64
|
/pr-review-config # open the settings menu (like /settings & /nervous:config)
|
|
65
65
|
/pr-review-config show # print the current mapping
|
|
66
|
-
/pr-review-config light=<spec> medium=<spec> heavy=<spec> # set
|
|
66
|
+
/pr-review-config light=<spec> medium=<spec> heavy=<spec> # set primary tier models
|
|
67
|
+
/pr-review-config heavy_fallbacks=<spec>,<spec> # retry chain for quota/rate-limit failures
|
|
67
68
|
/pr-review-config medium=unset # clear a tier (back to pi default)
|
|
69
|
+
/pr-review-config heavy_fallbacks=unset # clear a fallback chain
|
|
68
70
|
/pr-review-config tools=read,bash,grep,find,ls # tools granted to each subagent
|
|
69
71
|
```
|
|
70
72
|
|
|
71
73
|
Running `/pr-review-config` with no arguments in the TUI opens an interactive settings menu that mirrors pi's `/settings` and the NERVous `/nervous:config`:
|
|
72
74
|
|
|
73
|
-
- One row per tier (`light` / `medium` / `heavy`
|
|
74
|
-
- Press Enter on a
|
|
75
|
+
- One primary-model row and one fallback-model row per tier (`light` / `medium` / `heavy`) plus a `subagent tools` row.
|
|
76
|
+
- Press Enter on a primary or fallback row to pick a model from a searchable list (or unset it); Enter/Space on `subagent tools` cycles presets. The menu sets one fallback model at a time; use the `key=value` form for longer fallback chains.
|
|
75
77
|
- Selections apply and persist **immediately**; Esc closes the menu.
|
|
76
78
|
- Type to search, and tab-completion is available for the `key=value` form.
|
|
77
79
|
|
|
@@ -91,15 +93,20 @@ Example `pr-review.json`:
|
|
|
91
93
|
"medium": "<balanced-model-spec>",
|
|
92
94
|
"heavy": "<strong-model-spec:high>"
|
|
93
95
|
},
|
|
96
|
+
"fallbacks": {
|
|
97
|
+
"light": ["<backup-fast-model>"],
|
|
98
|
+
"medium": ["<backup-balanced-model>"],
|
|
99
|
+
"heavy": ["<backup-strong-model:high>", "<balanced-model-spec>"]
|
|
100
|
+
},
|
|
94
101
|
"tools": ["read", "bash", "grep", "find", "ls"]
|
|
95
102
|
}
|
|
96
103
|
```
|
|
97
104
|
|
|
98
|
-
Each tier runs in an **isolated `pi` subprocess** on its configured model. The `review_subagents` batch tool runs independent passes concurrently (default `max_parallel: 4`, capped at 6) and returns ordered per-pass results; the older single-pass `review_subagent` tool remains available as a compatibility fallback. If a tier is unset, that subagent falls back to the nearest configured tier, then to your pi default model.
|
|
105
|
+
Each tier runs in an **isolated `pi` subprocess** on its configured model. The `review_subagents` batch tool runs independent passes concurrently (default `max_parallel: 4`, capped at 6) and returns ordered per-pass results; the older single-pass `review_subagent` tool remains available as a compatibility fallback. If a tier model fails with a retryable quota/rate-limit/capacity error, the subprocess retries that tier's configured `fallbacks` in order. Non-quota failures do not blindly cycle through fallbacks. If a tier is unset, that subagent falls back to the nearest configured tier, then to your pi default model.
|
|
99
106
|
|
|
100
|
-
### 2. The orchestrator / fallback model
|
|
107
|
+
### 2. The orchestrator / inline-fallback model
|
|
101
108
|
|
|
102
|
-
The orchestrator (which fetches the PR, merges findings, and emits the JSON) and the inline
|
|
109
|
+
The orchestrator (which fetches the PR, merges findings, and emits the JSON) and the inline fallback path both run on your pi session model:
|
|
103
110
|
|
|
104
111
|
- **Per run:** `pi --model <model-id> "/pr-review 123"`
|
|
105
112
|
- **Persistent default (user):** `~/.pi/agent/settings.json` → `{ "defaultModel": "<model-id>", "defaultThinkingLevel": "high" }`
|
|
@@ -182,7 +189,7 @@ pi-pr-review/
|
|
|
182
189
|
|
|
183
190
|
- The `review_subagents` batch tool and `review_subagent` fallback spawn isolated `pi` subprocesses (`--mode json -p --no-session`) on your configured tier models. Subagents are read-only reviewers (`read,bash,grep,find,ls` by default) and never post comments or edit files.
|
|
184
191
|
- Project-local `pr-review.json` is only read when the project is trusted.
|
|
185
|
-
- Tiered review calls multiple models per PR, now concurrently for independent passes. Point `light` at a cheap model for overview/risk scan; reserve `heavy` for the deep passes.
|
|
192
|
+
- Tiered review calls multiple models per PR, now concurrently for independent passes. Point `light` at a cheap model for overview/risk scan; reserve `heavy` for the deep passes, and configure per-tier `fallbacks` only for acceptable backup models because retries can increase cost.
|
|
186
193
|
|
|
187
194
|
## Design notes
|
|
188
195
|
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Adds configurable, tiered review subagents to the /pr-review workflow.
|
|
5
5
|
*
|
|
6
6
|
* - Config surface: `pr-review.json` (user: ~/.pi/agent, project: <repo>/.pi) maps
|
|
7
|
-
* the labels `light` / `medium` / `heavy` to whatever models you choose
|
|
7
|
+
* the labels `light` / `medium` / `heavy` to whatever models you choose,
|
|
8
|
+
* plus optional per-tier fallback chains for quota/capacity failures.
|
|
8
9
|
* No model names are hardcoded here — you configure them.
|
|
9
10
|
* - Tool: `review_subagent` spawns one isolated `pi` subprocess on the model
|
|
10
11
|
* bound to the requested tier and returns its review report.
|
|
@@ -66,6 +67,8 @@ const TIER_PURPOSE: Record<Tier, string> = {
|
|
|
66
67
|
interface PrReviewConfig {
|
|
67
68
|
/** Tier label -> model spec (e.g. "anthropic/model", "openai/model:high"). */
|
|
68
69
|
tiers: Partial<Record<Tier, string>>;
|
|
70
|
+
/** Tier label -> ordered fallback model specs used for quota/capacity failures. */
|
|
71
|
+
fallbacks: Partial<Record<Tier, string[]>>;
|
|
69
72
|
/** Tools granted to each review subagent process. */
|
|
70
73
|
tools: string[];
|
|
71
74
|
}
|
|
@@ -91,6 +94,25 @@ function readConfigFile(filePath: string): Partial<PrReviewConfig> {
|
|
|
91
94
|
}
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
function normalizeFallbacks(
|
|
98
|
+
raw: Partial<Record<Tier, unknown>> | undefined,
|
|
99
|
+
preserveEmpty = false,
|
|
100
|
+
): Partial<Record<Tier, string[]>> {
|
|
101
|
+
const out: Partial<Record<Tier, string[]>> = {};
|
|
102
|
+
if (!raw || typeof raw !== "object") return out;
|
|
103
|
+
for (const tier of TIERS) {
|
|
104
|
+
const value = raw[tier];
|
|
105
|
+
if (value === undefined) continue;
|
|
106
|
+
const list = Array.isArray(value)
|
|
107
|
+
? value.map((v) => String(v).trim()).filter(Boolean)
|
|
108
|
+
: typeof value === "string"
|
|
109
|
+
? value.split(",").map((v) => v.trim()).filter(Boolean)
|
|
110
|
+
: [];
|
|
111
|
+
if (list.length > 0 || preserveEmpty) out[tier] = [...new Set(list)];
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
94
116
|
/** User config, overlaid by project config when the project is trusted. */
|
|
95
117
|
function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): PrReviewConfig {
|
|
96
118
|
const user = readConfigFile(userConfigPath());
|
|
@@ -102,6 +124,7 @@ function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): Pr
|
|
|
102
124
|
}
|
|
103
125
|
return {
|
|
104
126
|
tiers: { ...(user.tiers ?? {}), ...(project.tiers ?? {}) },
|
|
127
|
+
fallbacks: { ...normalizeFallbacks(user.fallbacks, true), ...normalizeFallbacks(project.fallbacks, true) },
|
|
105
128
|
tools: project.tools ?? user.tools ?? DEFAULT_TOOLS,
|
|
106
129
|
};
|
|
107
130
|
}
|
|
@@ -109,7 +132,11 @@ function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): Pr
|
|
|
109
132
|
/** User-level config only (the scope the config command edits), with defaults. */
|
|
110
133
|
function readUserConfig(): PrReviewConfig {
|
|
111
134
|
const raw = readConfigFile(userConfigPath());
|
|
112
|
-
return {
|
|
135
|
+
return {
|
|
136
|
+
tiers: { ...(raw.tiers ?? {}) },
|
|
137
|
+
fallbacks: normalizeFallbacks(raw.fallbacks),
|
|
138
|
+
tools: raw.tools ?? [...DEFAULT_TOOLS],
|
|
139
|
+
};
|
|
113
140
|
}
|
|
114
141
|
|
|
115
142
|
function writeUserConfig(next: PrReviewConfig): string {
|
|
@@ -119,19 +146,50 @@ function writeUserConfig(next: PrReviewConfig): string {
|
|
|
119
146
|
return filePath;
|
|
120
147
|
}
|
|
121
148
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
149
|
+
interface ModelAttempt {
|
|
150
|
+
spec?: string;
|
|
151
|
+
usedTier?: Tier;
|
|
152
|
+
kind: "primary" | "fallback" | "nearest" | "default";
|
|
153
|
+
fallbackIndex?: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const NEAREST_TIER_ORDER: Record<Tier, Tier[]> = {
|
|
157
|
+
light: ["light", "medium", "heavy"],
|
|
158
|
+
medium: ["medium", "heavy", "light"],
|
|
159
|
+
heavy: ["heavy", "medium", "light"],
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Resolve the ordered model attempts for a tier, preserving nearest-tier/default behavior when the tier is unset. */
|
|
163
|
+
function resolveModelAttempts(config: PrReviewConfig, tier: Tier): ModelAttempt[] {
|
|
164
|
+
const attempts: ModelAttempt[] = [];
|
|
165
|
+
const seen = new Set<string>();
|
|
166
|
+
const add = (attempt: ModelAttempt) => {
|
|
167
|
+
const key = attempt.spec ?? "__pi_default__";
|
|
168
|
+
if (seen.has(key)) return;
|
|
169
|
+
seen.add(key);
|
|
170
|
+
attempts.push(attempt);
|
|
130
171
|
};
|
|
131
|
-
|
|
132
|
-
|
|
172
|
+
|
|
173
|
+
const primary = config.tiers[tier];
|
|
174
|
+
if (primary) {
|
|
175
|
+
add({ spec: primary, usedTier: tier, kind: "primary" });
|
|
176
|
+
} else {
|
|
177
|
+
let foundNearest = false;
|
|
178
|
+
for (const candidate of NEAREST_TIER_ORDER[tier]) {
|
|
179
|
+
const spec = config.tiers[candidate];
|
|
180
|
+
if (!spec) continue;
|
|
181
|
+
add({ spec, usedTier: candidate, kind: "nearest" });
|
|
182
|
+
foundNearest = true;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
if (!foundNearest) add({ kind: "default" });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const [index, spec] of (config.fallbacks[tier] ?? []).entries()) {
|
|
189
|
+
add({ spec, usedTier: tier, kind: "fallback", fallbackIndex: index });
|
|
133
190
|
}
|
|
134
|
-
|
|
191
|
+
|
|
192
|
+
return attempts;
|
|
135
193
|
}
|
|
136
194
|
|
|
137
195
|
// ---------------------------------------------------------------------------
|
|
@@ -297,6 +355,18 @@ interface SubagentPassRequest {
|
|
|
297
355
|
context?: string;
|
|
298
356
|
}
|
|
299
357
|
|
|
358
|
+
interface ModelAttemptReport {
|
|
359
|
+
kind: ModelAttempt["kind"];
|
|
360
|
+
spec?: string;
|
|
361
|
+
usedTier?: Tier;
|
|
362
|
+
model?: string;
|
|
363
|
+
exitCode: number;
|
|
364
|
+
status: "completed" | "failed";
|
|
365
|
+
retryable: boolean;
|
|
366
|
+
stopReason?: string;
|
|
367
|
+
errorMessage?: string;
|
|
368
|
+
}
|
|
369
|
+
|
|
300
370
|
interface SubagentPassResult {
|
|
301
371
|
id: string;
|
|
302
372
|
tier: Tier;
|
|
@@ -309,39 +379,63 @@ interface SubagentPassResult {
|
|
|
309
379
|
stderr?: string;
|
|
310
380
|
stopReason?: string;
|
|
311
381
|
errorMessage?: string;
|
|
382
|
+
attempts: ModelAttemptReport[];
|
|
383
|
+
fallbackUsed: boolean;
|
|
384
|
+
retryableFailure: boolean;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function noticeForAttempt(tier: Tier, attempt: ModelAttempt): string {
|
|
388
|
+
if (!attempt.spec) {
|
|
389
|
+
return `tier=${tier} (no tier configured; using pi default model — run /pr-review-config to set tiers)`;
|
|
390
|
+
}
|
|
391
|
+
if (attempt.kind === "fallback") {
|
|
392
|
+
return `tier=${tier} fallback #${(attempt.fallbackIndex ?? 0) + 1} model=${attempt.spec}`;
|
|
393
|
+
}
|
|
394
|
+
if (attempt.usedTier && attempt.usedTier !== tier) {
|
|
395
|
+
return `tier=${tier} (not configured; using ${attempt.usedTier} model=${attempt.spec})`;
|
|
396
|
+
}
|
|
397
|
+
return `tier=${tier} model=${attempt.spec}`;
|
|
312
398
|
}
|
|
313
399
|
|
|
314
|
-
function
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
: `tier=${tier} (not configured; using ${usedTier} model=${spec})`;
|
|
400
|
+
function noticeForResult(tier: Tier, attempts: ModelAttemptReport[], finalNotice: string): string {
|
|
401
|
+
const failedBeforeSuccess = attempts.filter((a) => a.status === "failed").length;
|
|
402
|
+
if (failedBeforeSuccess > 0 && attempts.some((a) => a.status === "completed")) {
|
|
403
|
+
return `${finalNotice} (after ${failedBeforeSuccess} retry${failedBeforeSuccess === 1 ? "" : "ies"})`;
|
|
319
404
|
}
|
|
320
|
-
return
|
|
405
|
+
return finalNotice;
|
|
321
406
|
}
|
|
322
407
|
|
|
323
408
|
function buildPassTask(objective: string, context: string | undefined): string {
|
|
324
409
|
return context ? `Objective: ${objective}\n\n--- PR context / diff ---\n${context}` : `Objective: ${objective}`;
|
|
325
410
|
}
|
|
326
411
|
|
|
327
|
-
|
|
412
|
+
function isRetryableModelFailure(result: RunResult): boolean {
|
|
413
|
+
if (result.stopReason === "aborted") return false;
|
|
414
|
+
const haystack = [result.errorMessage, result.stderr, result.text, result.stopReason]
|
|
415
|
+
.filter(Boolean)
|
|
416
|
+
.join("\n")
|
|
417
|
+
.toLowerCase();
|
|
418
|
+
if (!haystack) return false;
|
|
419
|
+
return /(?:\b429\b|rate[\s_-]*limit(?:ed)?|too many requests|quota|usage[\s_-]*limit|insufficient[\s_-]*quota|resource[\s_-]*exhausted|out of credits|insufficient credits|credit limit|billing quota|billing hard limit|at capacity|overloaded|temporarily unavailable|model (?:is )?(?:temporarily|currently) unavailable|service unavailable|try again later)/i.test(
|
|
420
|
+
haystack,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function runSubagentAttempt(
|
|
328
425
|
config: PrReviewConfig,
|
|
329
426
|
ctx: Pick<ExtensionContext, "cwd">,
|
|
330
427
|
pass: SubagentPassRequest,
|
|
428
|
+
attempt: ModelAttempt,
|
|
331
429
|
signal: AbortSignal | undefined,
|
|
332
430
|
onText?: (text: string) => void,
|
|
333
|
-
): Promise<
|
|
334
|
-
const tier = pass.tier;
|
|
335
|
-
const { spec, usedTier } = resolveModelSpec(config, tier);
|
|
336
|
-
const notice = noticeForTier(tier, spec, usedTier);
|
|
431
|
+
): Promise<{ result: RunResult; notice: string }> {
|
|
337
432
|
let tmp: { dir: string; filePath: string } | undefined;
|
|
338
|
-
|
|
339
433
|
try {
|
|
340
434
|
const args = ["--mode", "json", "-p", "--no-session"];
|
|
341
|
-
if (spec) args.push("--model", spec);
|
|
435
|
+
if (attempt.spec) args.push("--model", attempt.spec);
|
|
342
436
|
if (config.tools.length > 0) args.push("--tools", config.tools.join(","));
|
|
343
437
|
|
|
344
|
-
tmp = await writeTempPrompt(tier, buildSubagentSystemPrompt(tier));
|
|
438
|
+
tmp = await writeTempPrompt(pass.tier, buildSubagentSystemPrompt(pass.tier));
|
|
345
439
|
args.push("--append-system-prompt", tmp.filePath);
|
|
346
440
|
args.push(buildPassTask(pass.objective, pass.context));
|
|
347
441
|
|
|
@@ -349,32 +443,11 @@ async function runSubagentPass(
|
|
|
349
443
|
const result = await runReviewSubprocess(invocation.command, invocation.args, ctx.cwd, signal, (text) => {
|
|
350
444
|
onText?.(text);
|
|
351
445
|
});
|
|
352
|
-
|
|
353
|
-
const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
354
|
-
return {
|
|
355
|
-
id: pass.id ?? tier,
|
|
356
|
-
tier,
|
|
357
|
-
usedTier,
|
|
358
|
-
model: result.model ?? spec,
|
|
359
|
-
exitCode: result.exitCode,
|
|
360
|
-
status: failed ? "failed" : "completed",
|
|
361
|
-
notice,
|
|
362
|
-
text: result.text || (failed ? "" : "NO FINDINGS."),
|
|
363
|
-
stderr: result.stderr || undefined,
|
|
364
|
-
stopReason: result.stopReason,
|
|
365
|
-
errorMessage: result.errorMessage,
|
|
366
|
-
};
|
|
446
|
+
return { result, notice: noticeForAttempt(pass.tier, attempt) };
|
|
367
447
|
} catch (e) {
|
|
368
448
|
return {
|
|
369
|
-
|
|
370
|
-
tier,
|
|
371
|
-
usedTier,
|
|
372
|
-
model: spec,
|
|
373
|
-
exitCode: 1,
|
|
374
|
-
status: "failed",
|
|
375
|
-
notice,
|
|
376
|
-
text: "",
|
|
377
|
-
errorMessage: errMessage(e),
|
|
449
|
+
result: { text: "", exitCode: 1, stderr: "", errorMessage: errMessage(e) },
|
|
450
|
+
notice: noticeForAttempt(pass.tier, attempt),
|
|
378
451
|
};
|
|
379
452
|
} finally {
|
|
380
453
|
if (tmp) {
|
|
@@ -387,6 +460,78 @@ async function runSubagentPass(
|
|
|
387
460
|
}
|
|
388
461
|
}
|
|
389
462
|
|
|
463
|
+
async function runSubagentPass(
|
|
464
|
+
config: PrReviewConfig,
|
|
465
|
+
ctx: Pick<ExtensionContext, "cwd">,
|
|
466
|
+
pass: SubagentPassRequest,
|
|
467
|
+
signal: AbortSignal | undefined,
|
|
468
|
+
onText?: (text: string) => void,
|
|
469
|
+
): Promise<SubagentPassResult> {
|
|
470
|
+
const tier = pass.tier;
|
|
471
|
+
const attempts = resolveModelAttempts(config, tier);
|
|
472
|
+
const reports: ModelAttemptReport[] = [];
|
|
473
|
+
let lastResult: RunResult | undefined;
|
|
474
|
+
let lastNotice = noticeForAttempt(tier, attempts[0]!);
|
|
475
|
+
|
|
476
|
+
for (const attempt of attempts) {
|
|
477
|
+
const { result, notice } = await runSubagentAttempt(config, ctx, pass, attempt, signal, onText);
|
|
478
|
+
const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
479
|
+
const retryable = failed && isRetryableModelFailure(result);
|
|
480
|
+
lastResult = result;
|
|
481
|
+
lastNotice = notice;
|
|
482
|
+
reports.push({
|
|
483
|
+
kind: attempt.kind,
|
|
484
|
+
spec: attempt.spec,
|
|
485
|
+
usedTier: attempt.usedTier,
|
|
486
|
+
model: result.model ?? attempt.spec,
|
|
487
|
+
exitCode: result.exitCode,
|
|
488
|
+
status: failed ? "failed" : "completed",
|
|
489
|
+
retryable,
|
|
490
|
+
stopReason: result.stopReason,
|
|
491
|
+
errorMessage: result.errorMessage,
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
if (!failed) {
|
|
495
|
+
return {
|
|
496
|
+
id: pass.id ?? tier,
|
|
497
|
+
tier,
|
|
498
|
+
usedTier: attempt.usedTier,
|
|
499
|
+
model: result.model ?? attempt.spec,
|
|
500
|
+
exitCode: result.exitCode,
|
|
501
|
+
status: "completed",
|
|
502
|
+
notice: noticeForResult(tier, reports, notice),
|
|
503
|
+
text: result.text || "NO FINDINGS.",
|
|
504
|
+
stderr: result.stderr || undefined,
|
|
505
|
+
stopReason: result.stopReason,
|
|
506
|
+
errorMessage: result.errorMessage,
|
|
507
|
+
attempts: reports,
|
|
508
|
+
fallbackUsed: attempt.kind === "fallback" || reports.length > 1,
|
|
509
|
+
retryableFailure: false,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (!retryable || signal?.aborted) break;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const final = lastResult ?? { text: "", exitCode: 1, stderr: "", errorMessage: "No model attempts were available." };
|
|
517
|
+
return {
|
|
518
|
+
id: pass.id ?? tier,
|
|
519
|
+
tier,
|
|
520
|
+
usedTier: reports.at(-1)?.usedTier,
|
|
521
|
+
model: reports.at(-1)?.model,
|
|
522
|
+
exitCode: final.exitCode,
|
|
523
|
+
status: "failed",
|
|
524
|
+
notice: noticeForResult(tier, reports, lastNotice),
|
|
525
|
+
text: final.text || "",
|
|
526
|
+
stderr: final.stderr || undefined,
|
|
527
|
+
stopReason: final.stopReason,
|
|
528
|
+
errorMessage: final.errorMessage,
|
|
529
|
+
attempts: reports,
|
|
530
|
+
fallbackUsed: reports.length > 1,
|
|
531
|
+
retryableFailure: reports.at(-1)?.retryable ?? false,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
390
535
|
function normalizeMaxParallel(raw: unknown, count: number): number {
|
|
391
536
|
if (count <= 0) return 0;
|
|
392
537
|
const requested = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : DEFAULT_BATCH_PARALLEL;
|
|
@@ -410,6 +555,13 @@ async function runWithConcurrency<T, R>(
|
|
|
410
555
|
return results;
|
|
411
556
|
}
|
|
412
557
|
|
|
558
|
+
function formatAttemptSummary(result: SubagentPassResult): string {
|
|
559
|
+
if (result.attempts.length <= 1) return "";
|
|
560
|
+
return `attempts: ${result.attempts
|
|
561
|
+
.map((a) => `${a.status === "completed" ? "✓" : a.retryable ? "↻" : "✗"} ${a.spec ?? "pi default"}`)
|
|
562
|
+
.join(" → ")}`;
|
|
563
|
+
}
|
|
564
|
+
|
|
413
565
|
function formatBatchResults(results: SubagentPassResult[], maxParallel: number): string {
|
|
414
566
|
const failed = results.filter((r) => r.status === "failed");
|
|
415
567
|
const lines = [
|
|
@@ -422,6 +574,8 @@ function formatBatchResults(results: SubagentPassResult[], maxParallel: number):
|
|
|
422
574
|
}
|
|
423
575
|
for (const result of results) {
|
|
424
576
|
lines.push("", `## Pass: ${result.id}`, `status: ${result.status}`, result.notice);
|
|
577
|
+
const attemptSummary = formatAttemptSummary(result);
|
|
578
|
+
if (attemptSummary) lines.push(attemptSummary);
|
|
425
579
|
if (result.status === "failed") {
|
|
426
580
|
const detail = result.errorMessage || result.stderr || result.text || "(no output)";
|
|
427
581
|
lines.push(`error: ${detail}`);
|
|
@@ -535,6 +689,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
535
689
|
usedTier: result.usedTier,
|
|
536
690
|
model: result.model,
|
|
537
691
|
exitCode: result.exitCode,
|
|
692
|
+
fallbackUsed: result.fallbackUsed,
|
|
693
|
+
retryableFailure: result.retryableFailure,
|
|
694
|
+
attempts: result.attempts,
|
|
538
695
|
},
|
|
539
696
|
};
|
|
540
697
|
}
|
|
@@ -546,6 +703,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
546
703
|
usedTier: result.usedTier,
|
|
547
704
|
model: result.model,
|
|
548
705
|
exitCode: result.exitCode,
|
|
706
|
+
fallbackUsed: result.fallbackUsed,
|
|
707
|
+
attempts: result.attempts,
|
|
549
708
|
},
|
|
550
709
|
};
|
|
551
710
|
},
|
|
@@ -622,6 +781,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
622
781
|
status: r.status,
|
|
623
782
|
stopReason: r.stopReason,
|
|
624
783
|
errorMessage: r.errorMessage,
|
|
784
|
+
fallbackUsed: r.fallbackUsed,
|
|
785
|
+
retryableFailure: r.retryableFailure,
|
|
786
|
+
attempts: r.attempts,
|
|
625
787
|
outputChars: r.text.length,
|
|
626
788
|
})),
|
|
627
789
|
},
|
|
@@ -630,7 +792,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
630
792
|
});
|
|
631
793
|
|
|
632
794
|
pi.registerCommand("pr-review-config", {
|
|
633
|
-
description: "Open the review-tier settings menu, or show/set light/medium/heavy models for /pr-review",
|
|
795
|
+
description: "Open the review-tier settings menu, or show/set light/medium/heavy models and fallbacks for /pr-review",
|
|
634
796
|
handler: async (args, ctx) => {
|
|
635
797
|
const raw = (args ?? "").trim();
|
|
636
798
|
const parsed = parseConfigArgs(raw);
|
|
@@ -640,7 +802,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
640
802
|
}
|
|
641
803
|
|
|
642
804
|
try {
|
|
643
|
-
// Direct set: `/pr-review-config light=... heavy=...`
|
|
805
|
+
// Direct set: `/pr-review-config light=... heavy=... heavy_fallbacks=...`
|
|
644
806
|
if (parsed.hasChanges) {
|
|
645
807
|
const next = applyConfigPatch(readUserConfig(), parsed.patch);
|
|
646
808
|
writeUserConfig(next);
|
|
@@ -701,6 +863,10 @@ function shouldOpenConfigMenu(args: string, ctx: ExtensionContext): boolean {
|
|
|
701
863
|
|
|
702
864
|
const CONFIG_COMPLETIONS: Array<{ value: string; label: string }> = [
|
|
703
865
|
...TIERS.map((t) => ({ value: `${t}=`, label: `${t}=<model> — ${TIER_PURPOSE[t]}` })),
|
|
866
|
+
...TIERS.map((t) => ({
|
|
867
|
+
value: `${t}_fallbacks=`,
|
|
868
|
+
label: `${t}_fallbacks=<model1,model2> — retry chain for quota/rate-limit failures`,
|
|
869
|
+
})),
|
|
704
870
|
{ value: "tools=", label: "tools=read,bash,grep,find,ls — tools granted to review subagents" },
|
|
705
871
|
{ value: "show", label: "show — print the current tier config" },
|
|
706
872
|
];
|
|
@@ -734,13 +900,27 @@ function splitConfigArgs(input: string): string[] {
|
|
|
734
900
|
return tokens;
|
|
735
901
|
}
|
|
736
902
|
|
|
903
|
+
function splitCommaList(value: string): string[] {
|
|
904
|
+
return value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function isFallbackKey(key: string): boolean {
|
|
908
|
+
for (const tier of TIERS) {
|
|
909
|
+
if (key === `${tier}_fallbacks` || key === `${tier}-fallbacks` || key === `${tier}.fallbacks`) {
|
|
910
|
+
return true;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
|
|
737
916
|
interface ConfigPatch {
|
|
738
917
|
tiers: Partial<Record<Tier, string | null>>;
|
|
918
|
+
fallbacks: Partial<Record<Tier, string[] | null>>;
|
|
739
919
|
tools?: string[];
|
|
740
920
|
}
|
|
741
921
|
|
|
742
922
|
function parseConfigArgs(args: string): { patch: ConfigPatch; hasChanges: boolean; errors: string[] } {
|
|
743
|
-
const patch: ConfigPatch = { tiers: {} };
|
|
923
|
+
const patch: ConfigPatch = { tiers: {}, fallbacks: {} };
|
|
744
924
|
const errors: string[] = [];
|
|
745
925
|
const tokens = splitConfigArgs(args).filter((t) => !["show", "get", "current"].includes(t.toLowerCase()));
|
|
746
926
|
for (const token of tokens) {
|
|
@@ -753,28 +933,46 @@ function parseConfigArgs(args: string): { patch: ConfigPatch; hasChanges: boolea
|
|
|
753
933
|
const value = token.slice(eq + 1);
|
|
754
934
|
if ((TIERS as string[]).includes(key)) {
|
|
755
935
|
patch.tiers[key as Tier] = value === "" || value === "unset" ? null : value;
|
|
936
|
+
} else if (isFallbackKey(key)) {
|
|
937
|
+
const tier = key.split(/[_\-.]/)[0] as Tier;
|
|
938
|
+
patch.fallbacks[tier] = value === "" || value === "unset" || value === "none" ? null : splitCommaList(value);
|
|
756
939
|
} else if (key === "tools") {
|
|
757
|
-
patch.tools = value
|
|
940
|
+
patch.tools = splitCommaList(value);
|
|
758
941
|
} else {
|
|
759
|
-
errors.push(`unknown key "${key}" (expected light|medium|heavy|tools)`);
|
|
942
|
+
errors.push(`unknown key "${key}" (expected light|medium|heavy|<tier>_fallbacks|tools)`);
|
|
760
943
|
}
|
|
761
944
|
}
|
|
762
|
-
const hasChanges =
|
|
945
|
+
const hasChanges =
|
|
946
|
+
Object.keys(patch.tiers).length > 0 || Object.keys(patch.fallbacks).length > 0 || patch.tools !== undefined;
|
|
763
947
|
return { patch, hasChanges, errors };
|
|
764
948
|
}
|
|
765
949
|
|
|
766
950
|
function applyConfigPatch(base: PrReviewConfig, patch: ConfigPatch): PrReviewConfig {
|
|
767
|
-
const next: PrReviewConfig = {
|
|
951
|
+
const next: PrReviewConfig = {
|
|
952
|
+
tiers: { ...base.tiers },
|
|
953
|
+
fallbacks: normalizeFallbacks(base.fallbacks),
|
|
954
|
+
tools: [...base.tools],
|
|
955
|
+
};
|
|
768
956
|
for (const tier of TIERS) {
|
|
769
|
-
if (
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
957
|
+
if (tier in patch.tiers) {
|
|
958
|
+
const value = patch.tiers[tier];
|
|
959
|
+
if (value === null || value === undefined) delete next.tiers[tier];
|
|
960
|
+
else next.tiers[tier] = value;
|
|
961
|
+
}
|
|
962
|
+
if (tier in patch.fallbacks) {
|
|
963
|
+
const value = patch.fallbacks[tier];
|
|
964
|
+
if (value === null || value === undefined || value.length === 0) delete next.fallbacks[tier];
|
|
965
|
+
else next.fallbacks[tier] = [...new Set(value)];
|
|
966
|
+
}
|
|
773
967
|
}
|
|
774
968
|
if (patch.tools) next.tools = patch.tools;
|
|
775
969
|
return next;
|
|
776
970
|
}
|
|
777
971
|
|
|
972
|
+
function formatModelList(models: string[] | undefined): string {
|
|
973
|
+
return models && models.length > 0 ? `\`${models.join(",")}\`` : "_none_";
|
|
974
|
+
}
|
|
975
|
+
|
|
778
976
|
function summarizeConfig(
|
|
779
977
|
user: PrReviewConfig,
|
|
780
978
|
effective: PrReviewConfig,
|
|
@@ -801,6 +999,10 @@ function summarizeConfig(
|
|
|
801
999
|
),
|
|
802
1000
|
`| \`tools\` | \`${user.tools.join(",")}\` | \`${effective.tools.join(",")}\` | tools granted to each review subagent |`,
|
|
803
1001
|
"",
|
|
1002
|
+
"| Tier | Your fallbacks | Effective fallbacks |",
|
|
1003
|
+
"|---|---|---|",
|
|
1004
|
+
...TIERS.map((t) => `| \`${t}\` | ${formatModelList(user.fallbacks[t])} | ${formatModelList(effective.fallbacks[t])} |`),
|
|
1005
|
+
"",
|
|
804
1006
|
`User config: \`${userConfigPath()}\``,
|
|
805
1007
|
];
|
|
806
1008
|
if (projectPath) lines.push(`Project overlay (trusted): \`${projectPath}\``);
|
|
@@ -810,7 +1012,9 @@ function summarizeConfig(
|
|
|
810
1012
|
"- Open the settings menu: `/pr-review-config`",
|
|
811
1013
|
"- Print this summary: `/pr-review-config show`",
|
|
812
1014
|
"- Set directly: `/pr-review-config light=provider/model heavy=provider/model:high`",
|
|
1015
|
+
"- Set fallback chain: `/pr-review-config heavy_fallbacks=provider/backup:high,provider/backup2`",
|
|
813
1016
|
"- Clear a tier: `/pr-review-config medium=unset`",
|
|
1017
|
+
"- Clear fallback chain: `/pr-review-config heavy_fallbacks=unset`",
|
|
814
1018
|
"- A `<model>` is any pi model pattern (`provider/model` or `provider/model:thinking`).",
|
|
815
1019
|
);
|
|
816
1020
|
return lines.join("\n");
|
|
@@ -828,10 +1032,14 @@ const MODEL_LIST_ROWS = 10;
|
|
|
828
1032
|
* search UX and fuzzyFilter's slash-token matching, so typing a model name substring
|
|
829
1033
|
* finds `provider/model`). The SelectList is rebuilt as the query changes.
|
|
830
1034
|
*/
|
|
831
|
-
function buildModelSubmenu(
|
|
1035
|
+
function buildModelSubmenu(
|
|
1036
|
+
available: string[],
|
|
1037
|
+
currentSpec: string | undefined,
|
|
1038
|
+
unsetDescription = "Fall back to the nearest configured tier, then the pi default.",
|
|
1039
|
+
) {
|
|
832
1040
|
return (_currentValue: string, done: (selectedValue?: string) => void) => {
|
|
833
1041
|
const allItems: SelectItem[] = [
|
|
834
|
-
{ value: "__unset__", label: UNSET, description:
|
|
1042
|
+
{ value: "__unset__", label: UNSET, description: unsetDescription },
|
|
835
1043
|
...available.map((spec) => ({ value: spec, label: spec })),
|
|
836
1044
|
];
|
|
837
1045
|
const theme = getSelectListTheme();
|
|
@@ -890,10 +1098,23 @@ function configMenuItems(cfg: PrReviewConfig, available: string[]): SettingItem[
|
|
|
890
1098
|
currentValue: cfg.tiers[tier] ?? UNSET,
|
|
891
1099
|
submenu: buildModelSubmenu(available, cfg.tiers[tier]),
|
|
892
1100
|
}));
|
|
1101
|
+
const fallbackItems: SettingItem[] = TIERS.map((tier) => ({
|
|
1102
|
+
id: `${tier}_fallbacks`,
|
|
1103
|
+
label: `${tier} fallback model`,
|
|
1104
|
+
description:
|
|
1105
|
+
"Retry model for quota/rate-limit/capacity failures. Press Enter to set one fallback; use key=value for chains.",
|
|
1106
|
+
currentValue: cfg.fallbacks[tier]?.join(",") || "(none)",
|
|
1107
|
+
submenu: buildModelSubmenu(
|
|
1108
|
+
available,
|
|
1109
|
+
cfg.fallbacks[tier]?.[0],
|
|
1110
|
+
"Clear this tier's fallback chain. Use key=value form to set multiple fallbacks.",
|
|
1111
|
+
),
|
|
1112
|
+
}));
|
|
893
1113
|
const current = cfg.tools.join(",");
|
|
894
1114
|
const toolValues = [current, ...TOOLS_PRESETS.filter((p) => p !== current)];
|
|
895
1115
|
return [
|
|
896
1116
|
...tierItems,
|
|
1117
|
+
...fallbackItems,
|
|
897
1118
|
{
|
|
898
1119
|
id: "tools",
|
|
899
1120
|
label: "subagent tools",
|
|
@@ -920,7 +1141,10 @@ async function showConfigMenu(
|
|
|
920
1141
|
|
|
921
1142
|
let settingsList: SettingsList;
|
|
922
1143
|
const refresh = () => {
|
|
923
|
-
for (const tier of TIERS)
|
|
1144
|
+
for (const tier of TIERS) {
|
|
1145
|
+
settingsList.updateValue(tier, draft.tiers[tier] ?? UNSET);
|
|
1146
|
+
settingsList.updateValue(`${tier}_fallbacks`, draft.fallbacks[tier]?.join(",") || "(none)");
|
|
1147
|
+
}
|
|
924
1148
|
settingsList.updateValue("tools", draft.tools.join(","));
|
|
925
1149
|
};
|
|
926
1150
|
|
|
@@ -928,15 +1152,23 @@ async function showConfigMenu(
|
|
|
928
1152
|
if ((TIERS as string[]).includes(id)) {
|
|
929
1153
|
if (newValue === "__unset__") delete draft.tiers[id as Tier];
|
|
930
1154
|
else draft.tiers[id as Tier] = newValue;
|
|
1155
|
+
} else if (isFallbackKey(id)) {
|
|
1156
|
+
const tier = id.split(/[_\-.]/)[0] as Tier;
|
|
1157
|
+
if (newValue === "__unset__") delete draft.fallbacks[tier];
|
|
1158
|
+
else draft.fallbacks[tier] = [newValue];
|
|
931
1159
|
} else if (id === "tools") {
|
|
932
|
-
draft.tools = newValue
|
|
1160
|
+
draft.tools = splitCommaList(newValue);
|
|
933
1161
|
} else {
|
|
934
1162
|
return;
|
|
935
1163
|
}
|
|
936
1164
|
try {
|
|
937
1165
|
writeUserConfig(draft);
|
|
938
1166
|
refresh();
|
|
939
|
-
const shown = id === "tools"
|
|
1167
|
+
const shown = id === "tools"
|
|
1168
|
+
? draft.tools.join(",")
|
|
1169
|
+
: isFallbackKey(id)
|
|
1170
|
+
? (draft.fallbacks[id.split(/[_\-.]/)[0] as Tier]?.join(",") ?? "(none)")
|
|
1171
|
+
: (draft.tiers[id as Tier] ?? UNSET);
|
|
940
1172
|
ctx.ui.notify(`PR review config: ${id} = ${shown}`, "info");
|
|
941
1173
|
} catch (e) {
|
|
942
1174
|
ctx.ui.notify(`pr-review-config failed: ${errMessage(e)}`, "error");
|
|
@@ -946,7 +1178,7 @@ async function showConfigMenu(
|
|
|
946
1178
|
|
|
947
1179
|
settingsList = new SettingsList(
|
|
948
1180
|
configMenuItems(draft, available),
|
|
949
|
-
|
|
1181
|
+
10,
|
|
950
1182
|
getSettingsListTheme(),
|
|
951
1183
|
(id, newValue) => persist(id, newValue),
|
|
952
1184
|
() => done(undefined),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pr-review",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Model-agnostic GitHub PR code review prompt for pi. Pass a PR number; it derives the base and head branches from the PR in the current repo, reviews the diff, and returns structured JSON findings.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -21,11 +21,11 @@
|
|
|
21
21
|
"typebox": "*"
|
|
22
22
|
},
|
|
23
23
|
"pi": {
|
|
24
|
-
"prompts": [
|
|
25
|
-
"./prompts"
|
|
26
|
-
],
|
|
27
24
|
"extensions": [
|
|
28
25
|
"./extensions"
|
|
26
|
+
],
|
|
27
|
+
"prompts": [
|
|
28
|
+
"./prompts"
|
|
29
29
|
]
|
|
30
30
|
}
|
|
31
31
|
}
|