@dieulc/workflow 0.3.0 → 0.3.3
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 +69 -1
- package/index.ts +8 -0
- package/package.json +2 -1
- package/review-providers.ts +406 -0
- package/review-runtime.ts +108 -5
- package/review-ui.ts +1 -0
- package/review.ts +219 -4
package/README.md
CHANGED
|
@@ -190,6 +190,46 @@ The reviewer is an in-process child session created with `createAgentSession`, a
|
|
|
190
190
|
|
|
191
191
|
An environment marker (`PI_WORKFLOW_REVIEW_CHILD`) is set only for the duration of session creation and restored immediately — the child runs in the same process, so leaking it would affect a later `/reload`.
|
|
192
192
|
|
|
193
|
+
### Provider seeding
|
|
194
|
+
|
|
195
|
+
The isolation above has one consequence: a provider that exists **only** because an extension registered it in the parent session (`pi.registerProvider(...)`) does not exist in the reviewer's freshly created runtime — every review round would fail with `No API key found for <provider>.` even though `/login` had stored a valid credential.
|
|
196
|
+
|
|
197
|
+
Pi composes providers from three sources: the **built-in catalog**, the **static `~/.pi/agent/models.json`**, and **extension registrations** on the parent runtime. Only the last one is parent-only. Review Mode bridges it with a **present-probe**, not layer names (`review-providers.ts`):
|
|
198
|
+
|
|
199
|
+
1. **Probe the parent** for a provider-registration surface: the session's model registry (`ctx.modelRegistry`) first, then the underlying runtime (`ctx.modelRuntime`). Both expose the same three accessors; a future Pi surface is picked up by adding one entry to the probe list.
|
|
200
|
+
2. **Compare with the child.** Providers the child already composes are skipped. The built-in catalog and `~/.pi/agent/models.json` are loaded by *every* runtime by construction, so for them the seeder is a guaranteed no-op — and any future globally-composed provider source is skipped automatically, with zero layer-name drift.
|
|
201
|
+
3. **Seed the difference, native-first.** For every provider the parent knows and the child does not, the seeder registers the parent's provider *object* when there is one, otherwise its config — each in its own try/catch, so a broken unrelated provider can never block a review. Idempotent: already-seeded ids are skipped on a second pass.
|
|
202
|
+
4. **Preflight auth, provider-agnostically.** After seeding and before the session is created, the reviewer runtime is asked `hasConfiguredAuth(provider) || checkAuth(provider)` for the *reviewer model's own* provider. On absence the review fails fast with the provider name, the seed report, and the universal escape hatch below — never the bare mid-review error.
|
|
203
|
+
|
|
204
|
+
Every bridged method is feature-detected: on a Pi version without a given accessor the seeder degrades to "nothing to seed" and the preflight reports the escape hatch instead of crashing.
|
|
205
|
+
|
|
206
|
+
**The universal escape hatch (works for any provider, even without this fix):** `~/.pi/agent/models.json` is loaded by every runtime, including the reviewer's:
|
|
207
|
+
|
|
208
|
+
```json
|
|
209
|
+
{
|
|
210
|
+
"providers": {
|
|
211
|
+
"<provider>": {
|
|
212
|
+
"baseUrl": "https://…/v1",
|
|
213
|
+
"apiKey": "$MY_API_KEY",
|
|
214
|
+
"api": "openai-completions",
|
|
215
|
+
"models": [
|
|
216
|
+
{
|
|
217
|
+
"id": "<model-id>",
|
|
218
|
+
"name": "<display name>",
|
|
219
|
+
"reasoning": true,
|
|
220
|
+
"input": ["text"],
|
|
221
|
+
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
|
|
222
|
+
"contextWindow": 200000,
|
|
223
|
+
"maxTokens": 65536
|
|
224
|
+
}
|
|
225
|
+
]
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
A stored credential (`/login <provider>` → `auth.json`) always wins over the `apiKey` template, so `$MY_API_KEY` needs no environment variable while you are logged in. Extensions that register the same provider at runtime keep working: their live model list replaces the static one in the parent, so nothing regresses there.
|
|
232
|
+
|
|
193
233
|
### Configuration
|
|
194
234
|
|
|
195
235
|
Settings live under `workflow.reviewMode` in `~/.pi/agent/settings.json` (global) or `<cwd>/.pi/settings.json` (project). Project values win field-by-field over global. Writing them preserves every other key in the file.
|
|
@@ -222,7 +262,7 @@ Review Mode settings live under `workflow.reviewMode`:
|
|
|
222
262
|
|
|
223
263
|
## How it works
|
|
224
264
|
|
|
225
|
-
See `index.ts`, `checkpoint.ts` (shadow bare-repo), `utils.ts` (safe-command gating, todo/plan extraction, plan hashing), `roles.ts` (role registry, one model per role), and the Review Mode modules: `review.ts` (config, prompt, state, transcript, child session — no runtime imports, so it is unit-testable), `review-tools.ts` (the reviewer's tool set), `review-runtime.ts` (round orchestration), `review-ui.ts` (the main-transcript entries: line buffer, formatters, renderers). Hot-reload via `/reload` when placed in `~/.pi/agent/extensions/workflow/` or loaded via `pi.extensions` manifest.
|
|
265
|
+
See `index.ts`, `checkpoint.ts` (shadow bare-repo), `utils.ts` (safe-command gating, todo/plan extraction, plan hashing), `roles.ts` (role registry, one model per role), and the Review Mode modules: `review.ts` (config, prompt, state, transcript, child session — no runtime imports, so it is unit-testable), `review-providers.ts` (parent→reviewer provider seeding and the provider-agnostic auth preflight), `review-tools.ts` (the reviewer's tool set), `review-runtime.ts` (round orchestration), `review-ui.ts` (the main-transcript entries: line buffer, formatters, renderers). Hot-reload via `/reload` when placed in `~/.pi/agent/extensions/workflow/` or loaded via `pi.extensions` manifest.
|
|
226
266
|
|
|
227
267
|
### Todo protocol (industry-aligned)
|
|
228
268
|
|
|
@@ -232,6 +272,34 @@ See `index.ts`, `checkpoint.ts` (shadow bare-repo), `utils.ts` (safe-command gat
|
|
|
232
272
|
- **Reminder cadence** (Claude Code-style throttle): a hidden `[TODO LIST]` context block is injected only when `turnsSinceLastTodoWrite ≥ TURNS_SINCE_WRITE` **and** `turnsSinceLastReminder ≥ TURNS_BETWEEN_REMINDERS` (`=3`). Unresolved `[DONE:…]` refs from the previous turn bypass the throttle once so the model learns the right numbers. A one-line `[TODO …]` footer is appended to the first successful `edit`/`write`/`bash` result of each turn.
|
|
233
273
|
- **Stale notice (no forced turns)**: if a run mutated files but recorded no todo progress, the user gets one `⚠` status marker + a `ctx.ui.notify` (30 s dedupe); plan-file `- [x]` ticks are reconciled at `agent_end` in all modes, skipped when the plan content is unchanged (path+hash guard). No auto-completion — the list is never marked done based on intent. Legacy lists that already contain duplicate rows are not auto-merged; run `/todos clear` (or approve a new plan) to reset.
|
|
234
274
|
|
|
275
|
+
## Troubleshooting
|
|
276
|
+
|
|
277
|
+
### `No API key found for <provider>.` in a Review Mode round
|
|
278
|
+
|
|
279
|
+
The transcript shows `Review Mode: … (could not prompt the reviewer: No API key found for <provider>.)`. This means the **reviewer session** could not compose auth for the provider — your login itself is fine (Plan/Build keep working off the same `auth.json`).
|
|
280
|
+
|
|
281
|
+
The reviewer runs in an isolated session without ambient extensions by design (see [Provider seeding](#provider-seeding)), so a provider registered only by an extension in the parent session is invisible to it unless seeded, and its auth must resolve on its own. Since 0.3.2 the provider is seeded automatically and a missing auth fails fast with a message naming the provider, what the seed found, and the escape hatch.
|
|
282
|
+
|
|
283
|
+
Fixes, in order of preference:
|
|
284
|
+
|
|
285
|
+
1. **`/login <provider>`** — a stored credential in `~/.pi/agent/auth.json` is read by every runtime, including the reviewer's. No environment variable needed.
|
|
286
|
+
2. **Static provider in `~/.pi/agent/models.json`** — the universal workaround (works even on an installed version without the seeding fix): add the provider under `"providers"` with `baseUrl`, `apiKey: "$MY_API_KEY"`, `api` and its `models` (template in [Provider seeding](#provider-seeding)).
|
|
287
|
+
3. **`export MY_API_KEY=…`** — only needed when the provider config references `"$MY_API_KEY"` and no credential is stored.
|
|
288
|
+
|
|
289
|
+
After editing `models.json`, run `/reload` or restart `pi` so every runtime picks it up.
|
|
290
|
+
|
|
291
|
+
### `the reviewer's provider failed after N retry attempt(s): …` in a Review Mode round
|
|
292
|
+
|
|
293
|
+
The warning reads `review skipped (the reviewer's provider failed after 3 retry attempt(s): 429 Too Many Requests …)` and the reviewer pane shows the provider's error text live. This is the reviewer's **model provider** failing mid-run — its API returned errors until Pi's auto-retry budget (`settings.retry.maxRetries`, default 3) ran out, so the run settled without ever reaching `review_submit_plan`. It is **not** a Review Mode fault and nothing was written; the author's plan is handed over unchanged.
|
|
294
|
+
|
|
295
|
+
Earlier versions mislabeled this as `the reviewer finished without submitting a plan — it has no write path…`; since 0.3.3 the real provider error is captured from the reviewer session and reported here, in the pane, and via `/review-status` (the old message now appears only when the reviewer genuinely settled without calling `review_submit_plan`).
|
|
296
|
+
|
|
297
|
+
What to do:
|
|
298
|
+
|
|
299
|
+
1. **Check the provider** — the truncated message is the provider's own reply (rate limit, quota, overloaded, auth). Retry the review with `/review` once the provider recovers; a review that dies this way can simply be re-run.
|
|
300
|
+
2. **A long review shares one API key with the parent** — planner + reviewer often hit the same key, so a long review can exhaust rate limits. Stagger heavy Plan Mode work or raise the provider's quota.
|
|
301
|
+
3. **Switch the reviewer model** if the failures recur — `/role set reviewer <provider/model>` (see [Review Mode](#review-mode)); pick a model whose provider is currently healthy.
|
|
302
|
+
|
|
235
303
|
## Development
|
|
236
304
|
|
|
237
305
|
```bash
|
package/index.ts
CHANGED
|
@@ -132,6 +132,7 @@ import {
|
|
|
132
132
|
type ReviewTranscriptOp,
|
|
133
133
|
} from "./review.ts";
|
|
134
134
|
import { createReviewRuntime, type ReviewRuntime } from "./review-runtime.ts";
|
|
135
|
+
import { findParentProviderSurface } from "./review-providers.ts";
|
|
135
136
|
import {
|
|
136
137
|
createReviewBandGate,
|
|
137
138
|
createReviewLineBuffer,
|
|
@@ -891,6 +892,13 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
891
892
|
},
|
|
892
893
|
findModel: (provider: string, id: string) =>
|
|
893
894
|
(currentSessionCtx as any)?.modelRegistry?.find?.(provider, id),
|
|
895
|
+
// Structural probe of the live parent context, evaluated at reviewer
|
|
896
|
+
// session-creation time. Providers the parent registered (e.g. via an
|
|
897
|
+
// extension) that the isolated reviewer runtime does not compose yet
|
|
898
|
+
// are seeded onto it before the session exists; when the probe finds
|
|
899
|
+
// no surface, nothing is seeded and the preflight reports the escape
|
|
900
|
+
// hatch. No-op for providers every runtime composes by construction.
|
|
901
|
+
parentProviders: () => findParentProviderSurface(currentSessionCtx),
|
|
894
902
|
planPath: () => currentPlanFile,
|
|
895
903
|
readPlan: () => {
|
|
896
904
|
const p = currentPlanFile;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dieulc/workflow",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"keywords": ["pi-package", "workflow", "plan-mode", "review-mode", "questionnaire", "rewind"],
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"roles.ts",
|
|
30
30
|
"permissions.ts",
|
|
31
31
|
"review.ts",
|
|
32
|
+
"review-providers.ts",
|
|
32
33
|
"review-tools.ts",
|
|
33
34
|
"review-runtime.ts",
|
|
34
35
|
"review-ui.ts",
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider seeding for Review Mode's reviewer session.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: `createReviewSession()` builds the reviewer as an isolated
|
|
5
|
+
* child (`noExtensions: true`, its own `ModelRuntime`). That isolation is
|
|
6
|
+
* deliberate — the reviewer must not inherit ambient extensions — but it has
|
|
7
|
+
* one consequence: a provider that is registered ONLY by an extension in the
|
|
8
|
+
* parent session (e.g. `pi.registerProvider("synthetic", …)`) does not exist in
|
|
9
|
+
* the child's runtime, so the child's prompt preflight throws
|
|
10
|
+
* `No API key found for <provider>.` even though `auth.json` holds a valid
|
|
11
|
+
* credential for it.
|
|
12
|
+
*
|
|
13
|
+
* The fix is a *present-probe*, not layer names: the seeder asks the child
|
|
14
|
+
* runtime what it already composes (`getRegisteredProviderIds()`), asks the
|
|
15
|
+
* parent surface for the same list, and registers only the difference —
|
|
16
|
+
* native provider objects first, plain provider configs otherwise. Providers
|
|
17
|
+
* the child already composes by construction (Pi's provider catalog and
|
|
18
|
+
* `~/.pi/agent/models.json` — every `ModelRuntime` loads both) never appear in
|
|
19
|
+
* these id lists at all, so the probe is automatically a no-op for them and for
|
|
20
|
+
* any future provider source Pi composes globally. No code here mentions or
|
|
21
|
+
* branches on provider layers; grep this file for "built-in"/"extension" to
|
|
22
|
+
* confirm the decision logic stays structural.
|
|
23
|
+
*
|
|
24
|
+
* Everything is feature-detected and per-provider fault-isolated:
|
|
25
|
+
* - a missing method degrades to "nothing to seed / cannot verify" — never a
|
|
26
|
+
* crash (the dev-dependency pins a different Pi version than is installed);
|
|
27
|
+
* - a broken unrelated provider registration is recorded in `errors` and must
|
|
28
|
+
* never block a review.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A provider registration as the parent holds it, copied opaquely to the
|
|
33
|
+
* child. The seeder never interprets a field — the child runtime validates
|
|
34
|
+
* and composes it exactly as it would a registration of its own.
|
|
35
|
+
*/
|
|
36
|
+
interface ProviderRegistration {
|
|
37
|
+
/** Provider id (present on native provider objects). */
|
|
38
|
+
readonly id?: string;
|
|
39
|
+
readonly [field: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The parent-side provider-registration surface the seeder reads from.
|
|
44
|
+
*
|
|
45
|
+
* Structural, not nominal: any object exposing these three accessors
|
|
46
|
+
* qualifies. Both real Pi surfaces — the session's `ModelRegistry` facade
|
|
47
|
+
* (`ctx.modelRegistry`) and its underlying `ModelRuntime` (`ctx.modelRuntime`)
|
|
48
|
+
* — expose exactly this shape; a future surface is picked up by adding one
|
|
49
|
+
* entry to `PARENT_SURFACE_KEYS`.
|
|
50
|
+
*/
|
|
51
|
+
export interface ParentProviderSurface {
|
|
52
|
+
getRegisteredProviderIds(): readonly string[];
|
|
53
|
+
getRegisteredProviderConfig(providerId: string): ProviderRegistration | undefined;
|
|
54
|
+
getRegisteredNativeProvider(providerId: string): ProviderRegistration | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One provider that could not be seeded, with the reason. */
|
|
58
|
+
export interface ProviderSeedError {
|
|
59
|
+
name: string;
|
|
60
|
+
error: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What one seeding pass did (or did not) do. */
|
|
64
|
+
export interface ProviderSeedResult {
|
|
65
|
+
/** Ids that were newly registered on the child runtime. */
|
|
66
|
+
seeded: string[];
|
|
67
|
+
/** Parent ids the child already composes — untouched, proving the no-op. */
|
|
68
|
+
skippedPresent: string[];
|
|
69
|
+
/** Per-provider failures; the review is never blocked by these. */
|
|
70
|
+
errors: ProviderSeedError[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ProviderGapScan {
|
|
74
|
+
/** Parent ids the child does not compose yet. */
|
|
75
|
+
missing: string[];
|
|
76
|
+
/** Parent ids the child already composes. */
|
|
77
|
+
present: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Junk-tolerant `getRegisteredProviderIds()` reader. Empty when unavailable. */
|
|
81
|
+
function registeredIdsOn(surface: unknown): string[] {
|
|
82
|
+
try {
|
|
83
|
+
const get = (surface as { getRegisteredProviderIds?: unknown } | undefined)
|
|
84
|
+
?.getRegisteredProviderIds;
|
|
85
|
+
if (typeof get !== "function") return [];
|
|
86
|
+
const ids = (get as (this: unknown) => unknown).call(surface);
|
|
87
|
+
if (!Array.isArray(ids)) return [];
|
|
88
|
+
const seen = new Set<string>();
|
|
89
|
+
for (const id of ids) {
|
|
90
|
+
if (typeof id === "string" && id.trim()) seen.add(id.trim());
|
|
91
|
+
}
|
|
92
|
+
return [...seen];
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Structural probe: does this object expose the full provider-registration
|
|
100
|
+
* surface (id list + both getters)? Both real Pi surfaces — the parent
|
|
101
|
+
* runtime and its `ModelRegistry` facade — expose all three; anything less
|
|
102
|
+
* cannot seed anything, so it is treated as "no surface".
|
|
103
|
+
*/
|
|
104
|
+
function isProviderSurface(value: unknown): value is ParentProviderSurface {
|
|
105
|
+
if (typeof value !== "object" || value === null) return false;
|
|
106
|
+
const s = value as Partial<ParentProviderSurface>;
|
|
107
|
+
return (
|
|
108
|
+
typeof s.getRegisteredProviderIds === "function" &&
|
|
109
|
+
typeof s.getRegisteredProviderConfig === "function" &&
|
|
110
|
+
typeof s.getRegisteredNativeProvider === "function"
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Public alias for the structural probe, so callers can distinguish a parent
|
|
116
|
+
* with no surface (worth reporting) from a surface that had nothing to give
|
|
117
|
+
* (a normal no-op).
|
|
118
|
+
*/
|
|
119
|
+
export function isParentProviderSurface(value: unknown): boolean {
|
|
120
|
+
return isProviderSurface(value);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Find a provider-registration surface on the parent context, trying the
|
|
125
|
+
* documented public surfaces in order. Returns `undefined` when none exists —
|
|
126
|
+
* callers must treat that as "nothing to seed", never as an error.
|
|
127
|
+
*
|
|
128
|
+
* The list is the extension point for future Pi versions: add one entry and
|
|
129
|
+
* the new surface is picked up without touching the seeder.
|
|
130
|
+
*/
|
|
131
|
+
const PARENT_SURFACE_KEYS = ["modelRegistry", "modelRuntime"] as const;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Find a provider-registration surface on the parent context, trying the
|
|
135
|
+
* documented public surfaces in order. Returns `undefined` when none exists —
|
|
136
|
+
* callers must treat that as "nothing to seed", never as an error.
|
|
137
|
+
*/
|
|
138
|
+
export function findParentProviderSurface(
|
|
139
|
+
ctxLike: unknown,
|
|
140
|
+
): ParentProviderSurface | undefined {
|
|
141
|
+
if (typeof ctxLike !== "object" || ctxLike === null) return undefined;
|
|
142
|
+
const holder = ctxLike as Record<string, unknown>;
|
|
143
|
+
for (const key of PARENT_SURFACE_KEYS) {
|
|
144
|
+
try {
|
|
145
|
+
const candidate = holder[key];
|
|
146
|
+
if (isProviderSurface(candidate)) return candidate;
|
|
147
|
+
} catch {
|
|
148
|
+
/* a throwing getter is just not a usable surface */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Compare the parent surface's registrations with what the child already
|
|
156
|
+
* composes. Never throws; a feature-detection failure on either side degrades
|
|
157
|
+
* to the safest scan (empty child set ⇒ everything in the parent counts as
|
|
158
|
+
* missing — re-registering is a merge, not a clobber).
|
|
159
|
+
*/
|
|
160
|
+
export function collectMissingChildProviders(
|
|
161
|
+
childRuntimeLike: unknown,
|
|
162
|
+
parentSurfaceLike: unknown,
|
|
163
|
+
): ProviderGapScan {
|
|
164
|
+
const childIds = new Set(registeredIdsOn(childRuntimeLike));
|
|
165
|
+
const parentIds = registeredIdsOn(parentSurfaceLike);
|
|
166
|
+
const missing: string[] = [];
|
|
167
|
+
const present: string[] = [];
|
|
168
|
+
for (const id of parentIds) {
|
|
169
|
+
if (childIds.has(id)) present.push(id);
|
|
170
|
+
else missing.push(id);
|
|
171
|
+
}
|
|
172
|
+
return { missing, present };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Seed the child runtime with the parent surface's provider registrations.
|
|
177
|
+
*
|
|
178
|
+
* Per missing id: register the provider object when the parent has one, else
|
|
179
|
+
* the provider config. Every id is individually fault-isolated — a malformed
|
|
180
|
+
* registration is recorded in `errors` and the remaining ids are still seeded.
|
|
181
|
+
* Idempotent: ids the child already composes are skipped, so a repeated call
|
|
182
|
+
* (or a shared runtime) is a no-op for them.
|
|
183
|
+
*
|
|
184
|
+
* Never throws. On the real Pi runtime each registration also triggers a
|
|
185
|
+
* network-off, fire-and-forget catalog refresh — local composition only.
|
|
186
|
+
*/
|
|
187
|
+
export function seedChildRuntimeProviders(
|
|
188
|
+
modelRuntimeLike: unknown,
|
|
189
|
+
parentSurfaceLike: unknown,
|
|
190
|
+
): ProviderSeedResult {
|
|
191
|
+
const result: ProviderSeedResult = {
|
|
192
|
+
seeded: [],
|
|
193
|
+
skippedPresent: [],
|
|
194
|
+
errors: [],
|
|
195
|
+
};
|
|
196
|
+
try {
|
|
197
|
+
if (!isProviderSurface(parentSurfaceLike)) return result;
|
|
198
|
+
const gap = collectMissingChildProviders(modelRuntimeLike, parentSurfaceLike);
|
|
199
|
+
result.skippedPresent = gap.present;
|
|
200
|
+
for (const id of gap.missing) {
|
|
201
|
+
try {
|
|
202
|
+
const getNative = parentSurfaceLike.getRegisteredNativeProvider as (
|
|
203
|
+
this: unknown,
|
|
204
|
+
providerId: string,
|
|
205
|
+
) => unknown;
|
|
206
|
+
const native =
|
|
207
|
+
typeof getNative === "function" ? getNative.call(parentSurfaceLike, id) : undefined;
|
|
208
|
+
if (native !== undefined && native !== null) {
|
|
209
|
+
const registerNative = (modelRuntimeLike as {
|
|
210
|
+
registerNativeProvider?: unknown;
|
|
211
|
+
})?.registerNativeProvider;
|
|
212
|
+
if (typeof registerNative !== "function") {
|
|
213
|
+
throw new Error(
|
|
214
|
+
"the reviewer runtime does not expose registerNativeProvider",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
(registerNative as (this: unknown, p: unknown) => void).call(
|
|
218
|
+
modelRuntimeLike,
|
|
219
|
+
native,
|
|
220
|
+
);
|
|
221
|
+
} else {
|
|
222
|
+
const getConfig = parentSurfaceLike.getRegisteredProviderConfig as (
|
|
223
|
+
this: unknown,
|
|
224
|
+
providerId: string,
|
|
225
|
+
) => unknown;
|
|
226
|
+
const config =
|
|
227
|
+
typeof getConfig === "function"
|
|
228
|
+
? getConfig.call(parentSurfaceLike, id)
|
|
229
|
+
: undefined;
|
|
230
|
+
if (config === undefined || config === null) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
"the parent surface has no registration to copy for this id",
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const registerProvider = (modelRuntimeLike as {
|
|
236
|
+
registerProvider?: unknown;
|
|
237
|
+
})?.registerProvider;
|
|
238
|
+
if (typeof registerProvider !== "function") {
|
|
239
|
+
throw new Error(
|
|
240
|
+
"the reviewer runtime does not expose registerProvider",
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
(registerProvider as (this: unknown, id: string, c: unknown) => void).call(
|
|
244
|
+
modelRuntimeLike,
|
|
245
|
+
id,
|
|
246
|
+
config,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
result.seeded.push(id);
|
|
250
|
+
} catch (e: unknown) {
|
|
251
|
+
result.errors.push({
|
|
252
|
+
name: id,
|
|
253
|
+
error: e instanceof Error ? e.message : String(e),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} catch {
|
|
258
|
+
/* the probe itself misbehaved: report "did nothing" rather than throw */
|
|
259
|
+
}
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Outcome of the reviewer's provider-auth preflight. */
|
|
264
|
+
export interface ReviewerAuthCheck {
|
|
265
|
+
configured: boolean;
|
|
266
|
+
/** `hasConfiguredAuth` said yes (snapshot). */
|
|
267
|
+
viaSnapshot: boolean;
|
|
268
|
+
/** `checkAuth` resolved to something (credential resolution). */
|
|
269
|
+
viaCheckAuth: boolean;
|
|
270
|
+
/** True when the runtime exposes neither accessor (cannot verify — trust). */
|
|
271
|
+
unverifiable: boolean;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Provider-agnostic reviewer auth preflight, mirroring the child session's own
|
|
276
|
+
* prompt gate (`hasConfiguredAuth(provider) || checkAuth(provider) !==
|
|
277
|
+
* undefined`) but tolerating a runtime that exposes neither accessor (old or
|
|
278
|
+
* stubbed SDKs): "cannot verify" proceeds rather than blocks. Feature-detected
|
|
279
|
+
* and never throws; a throwing `checkAuth` counts as "not configured".
|
|
280
|
+
*/
|
|
281
|
+
export async function checkReviewerProviderAuth(
|
|
282
|
+
runtimeLike: unknown,
|
|
283
|
+
providerId: string,
|
|
284
|
+
): Promise<ReviewerAuthCheck> {
|
|
285
|
+
let viaSnapshot = false;
|
|
286
|
+
let sawSnapshotAccessor = false;
|
|
287
|
+
try {
|
|
288
|
+
const has = (runtimeLike as { hasConfiguredAuth?: unknown })
|
|
289
|
+
?.hasConfiguredAuth;
|
|
290
|
+
if (typeof has === "function") {
|
|
291
|
+
sawSnapshotAccessor = true;
|
|
292
|
+
viaSnapshot =
|
|
293
|
+
(has as (this: unknown, id: string) => unknown).call(
|
|
294
|
+
runtimeLike,
|
|
295
|
+
providerId,
|
|
296
|
+
) === true;
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
viaSnapshot = false;
|
|
300
|
+
}
|
|
301
|
+
if (viaSnapshot) {
|
|
302
|
+
return { configured: true, viaSnapshot: true, viaCheckAuth: false, unverifiable: false };
|
|
303
|
+
}
|
|
304
|
+
let viaCheckAuth = false;
|
|
305
|
+
let sawCheckAuthAccessor = false;
|
|
306
|
+
try {
|
|
307
|
+
const check = (runtimeLike as { checkAuth?: unknown })?.checkAuth;
|
|
308
|
+
if (typeof check === "function") {
|
|
309
|
+
sawCheckAuthAccessor = true;
|
|
310
|
+
const resolved = await (check as (this: unknown, id: string) => unknown).call(
|
|
311
|
+
runtimeLike,
|
|
312
|
+
providerId,
|
|
313
|
+
);
|
|
314
|
+
viaCheckAuth = resolved !== undefined && resolved !== null;
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
viaCheckAuth = false;
|
|
318
|
+
}
|
|
319
|
+
const unverifiable = !sawSnapshotAccessor && !sawCheckAuthAccessor;
|
|
320
|
+
return {
|
|
321
|
+
configured: viaCheckAuth || unverifiable,
|
|
322
|
+
viaSnapshot: false,
|
|
323
|
+
viaCheckAuth,
|
|
324
|
+
unverifiable,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** One-line summary of a seed result, for preflight/failure messages. */
|
|
329
|
+
export function describeSeedResult(result: ProviderSeedResult): string {
|
|
330
|
+
const parts: string[] = [];
|
|
331
|
+
parts.push(
|
|
332
|
+
result.seeded.length > 0
|
|
333
|
+
? `seeded into the reviewer runtime: ${result.seeded.join(", ")}`
|
|
334
|
+
: "nothing needed seeding",
|
|
335
|
+
);
|
|
336
|
+
parts.push(
|
|
337
|
+
result.skippedPresent.length > 0
|
|
338
|
+
? `already present: ${result.skippedPresent.join(", ")}`
|
|
339
|
+
: "none were already present",
|
|
340
|
+
);
|
|
341
|
+
parts.push(
|
|
342
|
+
result.errors.length > 0
|
|
343
|
+
? `failed: ${result.errors
|
|
344
|
+
.map((e) => `${e.name} (${e.error})`)
|
|
345
|
+
.join("; ")}`
|
|
346
|
+
: "no failures",
|
|
347
|
+
);
|
|
348
|
+
return parts.join("; ");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Build the fail-fast error for a reviewer provider whose auth could not be
|
|
353
|
+
* resolved after seeding. Names the provider, reports what the seed found,
|
|
354
|
+
* and gives the generic escape hatch that works for ANY provider.
|
|
355
|
+
*/
|
|
356
|
+
export function unconfiguredReviewerProviderMessage(input: {
|
|
357
|
+
providerId: string;
|
|
358
|
+
seed: ProviderSeedResult;
|
|
359
|
+
/** Parent surface was absent entirely (no probe hit). */
|
|
360
|
+
parentSurfaceFound: boolean;
|
|
361
|
+
modelsJsonPath?: string;
|
|
362
|
+
}): string {
|
|
363
|
+
const modelsJson =
|
|
364
|
+
input.modelsJsonPath ?? "~/.pi/agent/models.json";
|
|
365
|
+
const seedSummary = input.parentSurfaceFound
|
|
366
|
+
? describeSeedResult(input.seed)
|
|
367
|
+
: "the parent session exposed no provider-registration surface, so nothing could be seeded";
|
|
368
|
+
const lines = [
|
|
369
|
+
`the reviewer session has no usable auth for provider "${input.providerId}".`,
|
|
370
|
+
`Provider seeding: ${seedSummary}.`,
|
|
371
|
+
`The reviewer runs in an isolated session without ambient extensions, so a provider registered only in the parent session must be seeded (above) AND its auth must be resolvable on its own.`,
|
|
372
|
+
`Fix any one of:`,
|
|
373
|
+
` 1. run /login ${input.providerId} — the stored credential is read by every runtime, including the reviewer's;`,
|
|
374
|
+
` 2. add a static provider entry to ${modelsJson} — that file is loaded by every runtime, including the reviewer's:`,
|
|
375
|
+
` { "providers": { "${input.providerId}": { "baseUrl": "…", "apiKey": "$MY_API_KEY", "api": "openai-completions", "models": [ … ] } } }`,
|
|
376
|
+
` 3. export MY_API_KEY in the environment when the provider config references "$MY_API_KEY".`,
|
|
377
|
+
];
|
|
378
|
+
return lines.join("\n");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Defensive hint appended to a mid-run pass-1 failure that still carries the
|
|
383
|
+
* bare provider-auth text. After seeding + preflight this path should be rare;
|
|
384
|
+
* it is the safety net for any future provider-source gap.
|
|
385
|
+
*
|
|
386
|
+
* Only fires when the error text matches the provider-auth failure shape AND
|
|
387
|
+
* the failing provider (extracted from the text) matches the reviewer's own
|
|
388
|
+
* provider id — an unrelated error must never gain a misleading hint.
|
|
389
|
+
*/
|
|
390
|
+
export function appendProviderAuthHint(
|
|
391
|
+
errorText: string,
|
|
392
|
+
providerId: string | undefined,
|
|
393
|
+
modelsJsonPath?: string,
|
|
394
|
+
): string {
|
|
395
|
+
if (!providerId || !errorText) return errorText;
|
|
396
|
+
const match = errorText.match(/No API key found for\s+([^\s.]+)/);
|
|
397
|
+
if (!match) return errorText;
|
|
398
|
+
const failingProvider = match[1];
|
|
399
|
+
if (failingProvider !== providerId) return errorText;
|
|
400
|
+
const modelsJson = modelsJsonPath ?? "~/.pi/agent/models.json";
|
|
401
|
+
return [
|
|
402
|
+
errorText,
|
|
403
|
+
`Hint: provider "${providerId}" may only be registered in the parent session; the reviewer session starts without ambient extensions.`,
|
|
404
|
+
`Run /login ${providerId}, or add a static provider entry to ${modelsJson} (loaded by every runtime, including the reviewer's).`,
|
|
405
|
+
].join("\n");
|
|
406
|
+
}
|
package/review-runtime.ts
CHANGED
|
@@ -22,13 +22,17 @@
|
|
|
22
22
|
* instructions — the reviewer only supplies judgement.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
|
|
25
27
|
import {
|
|
26
28
|
DEFAULT_REVIEW_MODE_CONFIG,
|
|
27
29
|
createReviewRoundGate,
|
|
28
30
|
createReviewSession,
|
|
29
31
|
createReviewState,
|
|
30
32
|
planHash,
|
|
33
|
+
readProviderErrorEvent,
|
|
31
34
|
reviewNeedsVerification,
|
|
35
|
+
truncateErrorText,
|
|
32
36
|
type CreateReviewSessionOptions,
|
|
33
37
|
type CreateReviewSessionResult,
|
|
34
38
|
type ReviewChildSession,
|
|
@@ -44,6 +48,7 @@ import {
|
|
|
44
48
|
type ReviewVerdict,
|
|
45
49
|
ReviewTranscript as Transcript,
|
|
46
50
|
} from "./review.ts";
|
|
51
|
+
import { appendProviderAuthHint } from "./review-providers.ts";
|
|
47
52
|
import { createReviewTools } from "./review-tools.ts";
|
|
48
53
|
import { renderReviewContextBlock, type ReviewFinding } from "./utils.ts";
|
|
49
54
|
|
|
@@ -71,6 +76,15 @@ export interface ReviewRuntimeDeps {
|
|
|
71
76
|
| undefined;
|
|
72
77
|
/** Resolve a model from the parent's registry. */
|
|
73
78
|
findModel: (provider: string, id: string) => unknown;
|
|
79
|
+
/**
|
|
80
|
+
* Parent-side provider-registration surface for the reviewer runtime, found
|
|
81
|
+
* with `findParentProviderSurface(ctx)` at session-creation time. Providers
|
|
82
|
+
* the parent knows that the isolated child does not compose yet are seeded
|
|
83
|
+
* onto it before `createAgentSession`. Optional: when absent (or when the
|
|
84
|
+
* probe finds no surface) nothing is seeded and the provider-auth preflight
|
|
85
|
+
* reports the generic escape hatch instead.
|
|
86
|
+
*/
|
|
87
|
+
parentProviders?: () => unknown;
|
|
74
88
|
/** Plan path Plan Mode chose (absolute or cwd-relative), if known. */
|
|
75
89
|
planPath: () => string | undefined;
|
|
76
90
|
/** Current plan text on disk, if it exists. */
|
|
@@ -279,6 +293,29 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
279
293
|
let abortController: AbortController | undefined;
|
|
280
294
|
/** True while `runRound` is in flight (gates prompt routing). */
|
|
281
295
|
let roundActive = false;
|
|
296
|
+
/**
|
|
297
|
+
* The reviewer's most recent provider failure, captured live from child
|
|
298
|
+
* events (`auto_retry_start`, failed `auto_retry_end`, an `agent_end` that
|
|
299
|
+
* stopped on error). Latest wins. Cleared at the start of every pass so a
|
|
300
|
+
* stale error can never mislabel a later clean settle (see runRound).
|
|
301
|
+
*/
|
|
302
|
+
let lastProviderError:
|
|
303
|
+
| { message: string; retries: number; at: number }
|
|
304
|
+
| undefined;
|
|
305
|
+
|
|
306
|
+
/** Record one provider failure. The message arrives pre-decoded, non-empty. */
|
|
307
|
+
const noteProviderError = (message: string, retries: number): void => {
|
|
308
|
+
lastProviderError = {
|
|
309
|
+
message,
|
|
310
|
+
retries: Math.max(0, Math.trunc(retries)),
|
|
311
|
+
at: Date.now(),
|
|
312
|
+
};
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/** Drop the captured error when a new pass starts. */
|
|
316
|
+
const clearProviderError = (): void => {
|
|
317
|
+
lastProviderError = undefined;
|
|
318
|
+
};
|
|
282
319
|
|
|
283
320
|
const config = (): ReviewModeConfig => {
|
|
284
321
|
try {
|
|
@@ -594,6 +631,11 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
594
631
|
// Reuse the already-resolved module instead of importing twice.
|
|
595
632
|
loadSdk: async () => sdk,
|
|
596
633
|
};
|
|
634
|
+
// Probed lazily, at creation time: the parent context is live here.
|
|
635
|
+
const parentSurface = deps.parentProviders?.();
|
|
636
|
+
if (parentSurface !== undefined) {
|
|
637
|
+
createOptions.parentProviders = parentSurface;
|
|
638
|
+
}
|
|
597
639
|
|
|
598
640
|
const created: CreateReviewSessionResult =
|
|
599
641
|
await createReviewSession(createOptions);
|
|
@@ -633,6 +675,29 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
633
675
|
}
|
|
634
676
|
}
|
|
635
677
|
const type = readEventType(event);
|
|
678
|
+
// Capture provider failures as they happen, so a pass that ends
|
|
679
|
+
// without a submission can be diagnosed with the reviewer's real
|
|
680
|
+
// API error instead of a generic "no plan" message (see runRound).
|
|
681
|
+
try {
|
|
682
|
+
const failure = readProviderErrorEvent(event);
|
|
683
|
+
if (failure.kind === "retry") {
|
|
684
|
+
noteProviderError(failure.message, failure.retries);
|
|
685
|
+
} else if (failure.kind === "exhaust") {
|
|
686
|
+
noteProviderError(
|
|
687
|
+
failure.message ||
|
|
688
|
+
lastProviderError?.message ||
|
|
689
|
+
"provider error (no detail)",
|
|
690
|
+
failure.retries || lastProviderError?.retries || 0,
|
|
691
|
+
);
|
|
692
|
+
} else if (failure.kind === "stop") {
|
|
693
|
+
noteProviderError(
|
|
694
|
+
failure.message,
|
|
695
|
+
lastProviderError?.retries ?? 0,
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
} catch {
|
|
699
|
+
/* error capture is diagnostics only: never break the review */
|
|
700
|
+
}
|
|
636
701
|
if (type === "agent_settled") {
|
|
637
702
|
noteSettled();
|
|
638
703
|
} else if (type === "agent_end") {
|
|
@@ -738,6 +803,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
738
803
|
// ── Pass 1 ──────────────────────────────────────────────────────
|
|
739
804
|
await settleInFlight(deadline, rc);
|
|
740
805
|
gate.reset();
|
|
806
|
+
clearProviderError();
|
|
741
807
|
const pass1Prompt = buildPass1Prompt({
|
|
742
808
|
planPath: input.planPath,
|
|
743
809
|
planText: originalPlan,
|
|
@@ -755,7 +821,16 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
755
821
|
|
|
756
822
|
if (p1.stop === "error") {
|
|
757
823
|
await abortChild();
|
|
758
|
-
|
|
824
|
+
// Safety net for any provider-source gap the preflight could not see:
|
|
825
|
+
// the bare "No API key found for <provider>." gains the actionable
|
|
826
|
+
// hint ONLY when the failing provider is the reviewer's own.
|
|
827
|
+
return fail(
|
|
828
|
+
`could not prompt the reviewer: ${appendProviderAuthHint(
|
|
829
|
+
errorText(p1.error),
|
|
830
|
+
deps.reviewerModel()?.provider,
|
|
831
|
+
path.join(deps.agentDir, "models.json"),
|
|
832
|
+
)}`,
|
|
833
|
+
);
|
|
759
834
|
}
|
|
760
835
|
if (p1.stop === "aborted" || rc.signal.aborted) {
|
|
761
836
|
await abortChild();
|
|
@@ -773,10 +848,23 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
773
848
|
// The run is over (or out of time) and nothing reached the plan file.
|
|
774
849
|
// Fail now — waiting longer cannot produce a submission.
|
|
775
850
|
await abortChild();
|
|
851
|
+
if (p1.stop === "timeout") {
|
|
852
|
+
return fail(
|
|
853
|
+
`the reviewer did not finish within ${Math.round((limitMs ?? 0) / 1000)}s and submitted no plan`,
|
|
854
|
+
originalPlan,
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
// A provider failure settles the run without a submission — name the
|
|
858
|
+
// real cause (with the provider's own message) instead of blaming the
|
|
859
|
+
// write path. The slot only holds errors captured during THIS pass.
|
|
860
|
+
if (lastProviderError) {
|
|
861
|
+
return fail(
|
|
862
|
+
`the reviewer's provider failed after ${lastProviderError.retries} retry attempt(s): ${truncateErrorText(lastProviderError.message)}`,
|
|
863
|
+
originalPlan,
|
|
864
|
+
);
|
|
865
|
+
}
|
|
776
866
|
return fail(
|
|
777
|
-
|
|
778
|
-
? `the reviewer did not finish within ${Math.round((limitMs ?? 0) / 1000)}s and submitted no plan`
|
|
779
|
-
: "the reviewer finished without submitting a plan — it has no write path unless review_submit_plan succeeds",
|
|
867
|
+
"the reviewer finished without submitting a plan — it has no write path unless review_submit_plan succeeds",
|
|
780
868
|
originalPlan,
|
|
781
869
|
);
|
|
782
870
|
}
|
|
@@ -802,6 +890,7 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
802
890
|
setPhase("verifying");
|
|
803
891
|
await settleInFlight(deadline, rc);
|
|
804
892
|
gate.reset();
|
|
893
|
+
clearProviderError();
|
|
805
894
|
const pass2Prompt = buildPass2Prompt({
|
|
806
895
|
planPath: input.planPath,
|
|
807
896
|
originalPlan,
|
|
@@ -811,14 +900,28 @@ export function createReviewRuntime(deps: ReviewRuntimeDeps): ReviewRuntime {
|
|
|
811
900
|
const p2 = await runPass(2, deadline, rc, () =>
|
|
812
901
|
child.followUp(pass2Prompt),
|
|
813
902
|
);
|
|
903
|
+
// Name the provider when the verification pass died on one; pass 2
|
|
904
|
+
// stays best-effort either way — pass 1's plan is never lost to it.
|
|
905
|
+
const p2Provider = lastProviderError
|
|
906
|
+
? `provider error after ${lastProviderError.retries} retry attempt(s): ${truncateErrorText(lastProviderError.message)}`
|
|
907
|
+
: undefined;
|
|
814
908
|
if (p2.error !== undefined) {
|
|
815
909
|
// Verification is an enhancement: never lose pass 1's plan over it.
|
|
816
910
|
deps.log(
|
|
817
|
-
|
|
911
|
+
p2Provider
|
|
912
|
+
? `Review Mode verification pass failed (${p2Provider}; send error: ${errorText(p2.error)}) — keeping pass 1's plan.`
|
|
913
|
+
: `Review Mode verification pass failed (${errorText(p2.error)}) — keeping pass 1's plan.`,
|
|
818
914
|
"warning",
|
|
819
915
|
);
|
|
820
916
|
} else if (p2.submitted) {
|
|
821
917
|
finalPlan = p2.submitted.planText;
|
|
918
|
+
} else if (p2Provider) {
|
|
919
|
+
// The run settled without a submission AND a provider error was
|
|
920
|
+
// seen this pass: say why, then keep going with pass 1's rewrite.
|
|
921
|
+
deps.log(
|
|
922
|
+
`Review Mode verification pass ended without a submission (${p2Provider}) — keeping pass 1's plan.`,
|
|
923
|
+
"warning",
|
|
924
|
+
);
|
|
822
925
|
}
|
|
823
926
|
if (p2.stop === "timeout") await abortChild();
|
|
824
927
|
}
|
package/review-ui.ts
CHANGED
package/review.ts
CHANGED
|
@@ -36,6 +36,13 @@ import {
|
|
|
36
36
|
type ReviewFinding,
|
|
37
37
|
type ReviewTier,
|
|
38
38
|
} from "./utils.ts";
|
|
39
|
+
import {
|
|
40
|
+
checkReviewerProviderAuth,
|
|
41
|
+
isParentProviderSurface,
|
|
42
|
+
seedChildRuntimeProviders,
|
|
43
|
+
unconfiguredReviewerProviderMessage,
|
|
44
|
+
type ProviderSeedResult,
|
|
45
|
+
} from "./review-providers.ts";
|
|
39
46
|
|
|
40
47
|
// Re-exported so `review.ts` is the single import surface for Review Mode.
|
|
41
48
|
export type { ReviewDisposition, ReviewFinding, ReviewTier };
|
|
@@ -567,7 +574,8 @@ export type ReviewLineKind =
|
|
|
567
574
|
| "tool"
|
|
568
575
|
| "finding"
|
|
569
576
|
| "notice"
|
|
570
|
-
| "status"
|
|
577
|
+
| "status"
|
|
578
|
+
| "error";
|
|
571
579
|
|
|
572
580
|
/** One line in the reviewer pane. Kept small: the pane renders these verbatim. */
|
|
573
581
|
export interface ReviewTranscriptLine {
|
|
@@ -1099,6 +1107,103 @@ function truncateLabel(s: string, max: number): string {
|
|
|
1099
1107
|
return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`;
|
|
1100
1108
|
}
|
|
1101
1109
|
|
|
1110
|
+
/** Longest provider-error excerpt shown inline in a transcript line. */
|
|
1111
|
+
export const MAX_ERROR_TEXT = 160;
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* One-line excerpt of a provider error message for `notify` and the reviewer
|
|
1115
|
+
* pane. Provider errors are often multi-line JSON blobs; they must become a
|
|
1116
|
+
* single line to survive a transcript entry and a `ui.notify` banner.
|
|
1117
|
+
* Pure and total: empty/undefined input yields "", never throws.
|
|
1118
|
+
*/
|
|
1119
|
+
export function truncateErrorText(input: unknown, max: number = MAX_ERROR_TEXT): string {
|
|
1120
|
+
const raw = typeof input === "string" ? input : "";
|
|
1121
|
+
const flat = raw.replace(/\s+/g, " ").trim();
|
|
1122
|
+
if (!flat) return "";
|
|
1123
|
+
const cap = Math.max(1, Math.trunc(max));
|
|
1124
|
+
return flat.length <= cap ? flat : `${flat.slice(0, cap - 1).trimEnd()}…`;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Extract the provider error message when a run stopped on an error: scan an
|
|
1129
|
+
* `agent_end` event's messages from the end for the last assistant message
|
|
1130
|
+
* with `stopReason === "error"`. Structural on purpose — SDK message shapes
|
|
1131
|
+
* are not imported here. Returns undefined for clean runs and junk input.
|
|
1132
|
+
*/
|
|
1133
|
+
export function lastAssistantErrorStop(messages: unknown): string | undefined {
|
|
1134
|
+
if (!Array.isArray(messages)) return undefined;
|
|
1135
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1136
|
+
const rec = messages[i] as Record<string, unknown> | null | undefined;
|
|
1137
|
+
if (!rec || typeof rec !== "object") continue;
|
|
1138
|
+
if (rec.role !== "assistant") continue;
|
|
1139
|
+
// Last assistant message reached: a non-error stop means a clean run.
|
|
1140
|
+
if (rec.stopReason !== "error") return undefined;
|
|
1141
|
+
const err = typeof rec.errorMessage === "string" ? rec.errorMessage : "";
|
|
1142
|
+
return err || "unknown provider error";
|
|
1143
|
+
}
|
|
1144
|
+
return undefined;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* What a child-session event says about provider health, decoded at the
|
|
1149
|
+
* boundary so callers branch on typed values instead of SDK event shapes:
|
|
1150
|
+
*
|
|
1151
|
+
* - `retry` → the SDK scheduled another attempt (`auto_retry_start`) and
|
|
1152
|
+
* reported the provider's error message.
|
|
1153
|
+
* - `exhaust` → the retry budget ran out (`auto_retry_end`, `success: false`);
|
|
1154
|
+
* the run is about to settle on an error. `message` may be empty when the
|
|
1155
|
+
* event carries no `finalError` — callers then keep their earlier message.
|
|
1156
|
+
* - `stop` → the run itself ended with `stopReason === "error"` on its last
|
|
1157
|
+
* assistant message (auto-retry disabled, or a non-retryable failure).
|
|
1158
|
+
* - `none` → anything else, including a retry that recovered.
|
|
1159
|
+
*/
|
|
1160
|
+
export type ReviewProviderFailure =
|
|
1161
|
+
| { kind: "retry"; message: string; retries: number }
|
|
1162
|
+
| { kind: "exhaust"; message: string; retries: number }
|
|
1163
|
+
| { kind: "stop"; message: string }
|
|
1164
|
+
| { kind: "none" };
|
|
1165
|
+
|
|
1166
|
+
/** Structural decode of one provider-failure event. Never throws. */
|
|
1167
|
+
export function readProviderErrorEvent(
|
|
1168
|
+
event: unknown,
|
|
1169
|
+
): ReviewProviderFailure {
|
|
1170
|
+
const e = eventRecord(event);
|
|
1171
|
+
if (!e) return { kind: "none" };
|
|
1172
|
+
const type = typeof e.type === "string" ? e.type : "";
|
|
1173
|
+
if (type === "auto_retry_start") {
|
|
1174
|
+
const message =
|
|
1175
|
+
typeof e.errorMessage === "string" ? e.errorMessage.trim() : "";
|
|
1176
|
+
if (!message) return { kind: "none" };
|
|
1177
|
+
return {
|
|
1178
|
+
kind: "retry",
|
|
1179
|
+
message,
|
|
1180
|
+
retries: readRetryAttempt(e.attempt),
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
if (type === "auto_retry_end") {
|
|
1184
|
+
if (e.success !== false) return { kind: "none" };
|
|
1185
|
+
const message = typeof e.finalError === "string" ? e.finalError.trim() : "";
|
|
1186
|
+
return {
|
|
1187
|
+
kind: "exhaust",
|
|
1188
|
+
message,
|
|
1189
|
+
retries: readRetryAttempt(e.attempt),
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
if (type === "agent_end") {
|
|
1193
|
+
const message = lastAssistantErrorStop(e.messages);
|
|
1194
|
+
if (message === undefined) return { kind: "none" };
|
|
1195
|
+
return { kind: "stop", message };
|
|
1196
|
+
}
|
|
1197
|
+
return { kind: "none" };
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** Non-negative attempt count from a retry event; 0 when absent or junk. */
|
|
1201
|
+
function readRetryAttempt(value: unknown): number {
|
|
1202
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
1203
|
+
? Math.trunc(value)
|
|
1204
|
+
: 0;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1102
1207
|
/**
|
|
1103
1208
|
* Human-readable header for a tool call: the tool name plus a short, safe
|
|
1104
1209
|
* excerpt of its key argument (`▸ read src/app.ts`, `▸ review_bash: git log -5`).
|
|
@@ -1170,6 +1275,12 @@ export function formatActionLabel(toolName: unknown, args: unknown): string {
|
|
|
1170
1275
|
* `toolLabels` is owned by the stateful caller (`ReviewTranscript`): the end
|
|
1171
1276
|
* event carries no args, so the start event registers `toolCallId → label` and
|
|
1172
1277
|
* the end event consumes it. Pure otherwise; junk input never throws.
|
|
1278
|
+
*
|
|
1279
|
+
* Provider failures are surfaced with their real (truncated) text: the
|
|
1280
|
+
* `auto_retry_start` notice carries the provider's message, and a failed
|
|
1281
|
+
* `auto_retry_end` or an `agent_end` that stopped on `stopReason === "error"`
|
|
1282
|
+
* appends an `error` line — a dead provider is then visible in the pane during
|
|
1283
|
+
* the review instead of resurfacing later as a mysterious "no plan" failure.
|
|
1173
1284
|
*/
|
|
1174
1285
|
export function translateReviewEvent(
|
|
1175
1286
|
event: unknown,
|
|
@@ -1207,15 +1318,70 @@ export function translateReviewEvent(
|
|
|
1207
1318
|
if (type === "agent_start") {
|
|
1208
1319
|
return [{ op: "new", kind: "status", text: "reviewer started" }];
|
|
1209
1320
|
}
|
|
1321
|
+
return providerErrorOps(e, type);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Provider-error events → transcript lines, split out of
|
|
1326
|
+
* `translateReviewEvent` to keep the dispatcher flat:
|
|
1327
|
+
*
|
|
1328
|
+
* - `agent_end` → status line, plus an `error` line when the run
|
|
1329
|
+
* stopped on `stopReason === "error"` (auto-retry disabled, or the first
|
|
1330
|
+
* failure was not retryable).
|
|
1331
|
+
* - `auto_retry_start` → notice carrying the provider's message (truncated).
|
|
1332
|
+
* - `auto_retry_end` → `error` line when the retry budget is exhausted
|
|
1333
|
+
* (`success: false`); a recovered retry (`success: true`) stays silent.
|
|
1334
|
+
*
|
|
1335
|
+
* Unknown types and junk fields degrade to empty ops; never throws.
|
|
1336
|
+
*/
|
|
1337
|
+
function providerErrorOps(
|
|
1338
|
+
e: Record<string, unknown>,
|
|
1339
|
+
type: string,
|
|
1340
|
+
): ReviewTranscriptOp[] {
|
|
1210
1341
|
if (type === "agent_end") {
|
|
1211
|
-
|
|
1342
|
+
const ops: ReviewTranscriptOp[] = [
|
|
1343
|
+
{ op: "new", kind: "status", text: "reviewer finished this run" },
|
|
1344
|
+
];
|
|
1345
|
+
const failed = lastAssistantErrorStop(e.messages);
|
|
1346
|
+
if (failed) {
|
|
1347
|
+
ops.push({
|
|
1348
|
+
op: "new",
|
|
1349
|
+
kind: "error",
|
|
1350
|
+
text: `reviewer run ended on a provider error: ${truncateErrorText(failed)}`,
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
return ops;
|
|
1212
1354
|
}
|
|
1213
1355
|
if (type === "auto_retry_start") {
|
|
1356
|
+
const msg = typeof e.errorMessage === "string" ? e.errorMessage : "";
|
|
1214
1357
|
return [
|
|
1215
|
-
{
|
|
1358
|
+
{
|
|
1359
|
+
op: "new",
|
|
1360
|
+
kind: "notice",
|
|
1361
|
+
text: msg
|
|
1362
|
+
? `retrying after a provider error: ${truncateErrorText(msg)}`
|
|
1363
|
+
: "retrying after a provider error",
|
|
1364
|
+
},
|
|
1216
1365
|
];
|
|
1217
1366
|
}
|
|
1218
|
-
|
|
1367
|
+
if (type === "auto_retry_end") {
|
|
1368
|
+
// `success: false` ends the retry loop with the provider still failing —
|
|
1369
|
+
// the run is about to settle on an error stop. `success: true` means a
|
|
1370
|
+
// retry recovered; the earlier notice already covers that case.
|
|
1371
|
+
if (e.success === false) {
|
|
1372
|
+
const detail = truncateErrorText(e.finalError);
|
|
1373
|
+
return [
|
|
1374
|
+
{
|
|
1375
|
+
op: "new",
|
|
1376
|
+
kind: "error",
|
|
1377
|
+
text: detail
|
|
1378
|
+
? `reviewer retry budget exhausted: ${detail}`
|
|
1379
|
+
: "reviewer retry budget exhausted",
|
|
1380
|
+
},
|
|
1381
|
+
];
|
|
1382
|
+
}
|
|
1383
|
+
return [];
|
|
1384
|
+
}
|
|
1219
1385
|
return [];
|
|
1220
1386
|
}
|
|
1221
1387
|
|
|
@@ -1443,6 +1609,23 @@ export interface CreateReviewSessionOptions {
|
|
|
1443
1609
|
reviewPrompt: string;
|
|
1444
1610
|
/** Reviewer-only tools, built by the caller. */
|
|
1445
1611
|
customTools: unknown[];
|
|
1612
|
+
/**
|
|
1613
|
+
* Parent-side provider-registration surface (structural, feature-detected),
|
|
1614
|
+
* found with `findParentProviderSurface(ctx)` — the session's model registry
|
|
1615
|
+
* facade or its underlying model runtime.
|
|
1616
|
+
*
|
|
1617
|
+
* The reviewer session is deliberately created without ambient extensions,
|
|
1618
|
+
* so a provider registered only in the parent session (e.g. by a provider
|
|
1619
|
+
* extension) does not exist in the reviewer's fresh runtime. When this
|
|
1620
|
+
* surface is given, every provider the parent knows that the child does not
|
|
1621
|
+
* yet compose is registered onto the child BEFORE `createAgentSession` —
|
|
1622
|
+
* natively when the parent has a provider object, otherwise by config.
|
|
1623
|
+
* Absent or surface-less ⇒ nothing is seeded and the provider-auth preflight
|
|
1624
|
+
* (below) reports the generic escape hatch instead. Providers the child
|
|
1625
|
+
* already composes are skipped, so this is a no-op for every provider the
|
|
1626
|
+
* runtime builds in by construction.
|
|
1627
|
+
*/
|
|
1628
|
+
parentProviders?: unknown;
|
|
1446
1629
|
/** Injectable for tests; defaults to a real dynamic import. */
|
|
1447
1630
|
loadSdk?: ReviewSdkLoader;
|
|
1448
1631
|
}
|
|
@@ -1453,6 +1636,8 @@ export interface CreateReviewSessionResult {
|
|
|
1453
1636
|
modelLabel: string;
|
|
1454
1637
|
/** Tool names the child actually has enabled, when the SDK exposes them. */
|
|
1455
1638
|
activeTools?: string[];
|
|
1639
|
+
/** What provider seeding did before the session was created, when probed. */
|
|
1640
|
+
providerSeed?: ProviderSeedResult;
|
|
1456
1641
|
error?: string;
|
|
1457
1642
|
}
|
|
1458
1643
|
|
|
@@ -1601,6 +1786,36 @@ export async function createReviewSession(
|
|
|
1601
1786
|
};
|
|
1602
1787
|
}
|
|
1603
1788
|
|
|
1789
|
+
// Seed providers the child does not compose yet (e.g. providers registered
|
|
1790
|
+
// only in the parent session) BEFORE the session exists. Present-probe:
|
|
1791
|
+
// only the difference is registered, native-first, per-provider
|
|
1792
|
+
// fault-isolated — never fatal, never layer-aware.
|
|
1793
|
+
const providerSeed = seedChildRuntimeProviders(
|
|
1794
|
+
modelRuntime,
|
|
1795
|
+
options.parentProviders,
|
|
1796
|
+
);
|
|
1797
|
+
|
|
1798
|
+
// Provider-agnostic auth preflight, mirroring the child session's own
|
|
1799
|
+
// prompt gate: fail fast here — with the seed report and the generic
|
|
1800
|
+
// escape hatch — instead of mid-review with a bare "No API key found".
|
|
1801
|
+
const auth = await checkReviewerProviderAuth(
|
|
1802
|
+
modelRuntime,
|
|
1803
|
+
options.modelRef.provider,
|
|
1804
|
+
);
|
|
1805
|
+
if (!auth.configured) {
|
|
1806
|
+
return {
|
|
1807
|
+
ok: false,
|
|
1808
|
+
modelLabel,
|
|
1809
|
+
providerSeed,
|
|
1810
|
+
error: unconfiguredReviewerProviderMessage({
|
|
1811
|
+
providerId: options.modelRef.provider,
|
|
1812
|
+
seed: providerSeed,
|
|
1813
|
+
parentSurfaceFound: isParentProviderSurface(options.parentProviders),
|
|
1814
|
+
modelsJsonPath: path.join(options.agentDir, "models.json"),
|
|
1815
|
+
}),
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1604
1819
|
// No ambient extensions, no skills/prompts/themes: the reviewer gets exactly
|
|
1605
1820
|
// the system prompt below and nothing from the user's other packages.
|
|
1606
1821
|
// Project context files (AGENTS.md etc.) DO load — they are the framework
|