@dieulc/workflow 0.9.2 → 0.10.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 +69 -7
- package/index.ts +827 -155
- package/package.json +9 -6
- package/provider-retarget.ts +258 -0
- package/review-handoff.ts +125 -0
- package/review-recovery.ts +65 -0
- package/review-runtime.ts +318 -48
- package/review-tools.ts +303 -6
- package/review.ts +242 -2
package/review.ts
CHANGED
|
@@ -64,6 +64,15 @@ export const REVIEW_SUBMITTED_ENTRY_TYPE = "workflow-review-submitted";
|
|
|
64
64
|
// ── Step 2: Review Mode configuration ────────────────────────────────
|
|
65
65
|
|
|
66
66
|
export type ReviewFallbackOnError = "skip" | "block";
|
|
67
|
+
export type ReviewProviderRecovery = "fallback" | "off";
|
|
68
|
+
export type ReviewChunkedSubmit = "auto" | "off";
|
|
69
|
+
|
|
70
|
+
/** A reviewer model reference used by the recovery chain or an explicit fallback. */
|
|
71
|
+
export interface ReviewFallbackModel {
|
|
72
|
+
provider: string;
|
|
73
|
+
id: string;
|
|
74
|
+
thinking: string;
|
|
75
|
+
}
|
|
67
76
|
|
|
68
77
|
export interface ReviewModeConfig {
|
|
69
78
|
/** Master toggle. When false every Review Mode code path is a no-op. */
|
|
@@ -76,6 +85,22 @@ export interface ReviewModeConfig {
|
|
|
76
85
|
verify: boolean;
|
|
77
86
|
/** What to do if the reviewer model is unavailable or the review fails. */
|
|
78
87
|
fallbackOnError: ReviewFallbackOnError;
|
|
88
|
+
/**
|
|
89
|
+
* Provider recovery: after a recoverable stream failure, try the fallback
|
|
90
|
+
* chain instead of failing the round immediately. `off` restores the old
|
|
91
|
+
* single-model behaviour.
|
|
92
|
+
*/
|
|
93
|
+
providerRecovery: ReviewProviderRecovery;
|
|
94
|
+
/** SDK retries per reviewer model before the chain advances (0-2). */
|
|
95
|
+
providerRetries: number;
|
|
96
|
+
/** Explicit first fallback; the runtime still dedupes and clamps it. */
|
|
97
|
+
fallbackModel?: ReviewFallbackModel;
|
|
98
|
+
/** Chunked rewrite submission for long plans (default automatic by size). */
|
|
99
|
+
chunkedSubmit: ReviewChunkedSubmit;
|
|
100
|
+
/** Max characters per `review_plan_chunk`; also sets the auto threshold. */
|
|
101
|
+
chunkChars: number;
|
|
102
|
+
/** Overall recovery budget; 0 uses the round deadline. */
|
|
103
|
+
recoveryBudgetMs: number;
|
|
79
104
|
/** Max `review_explore` subagents per review round (cost ceiling). */
|
|
80
105
|
exploreBudget: number;
|
|
81
106
|
/**
|
|
@@ -103,6 +128,11 @@ export const DEFAULT_REVIEW_MODE_CONFIG: Readonly<ReviewModeConfig> = {
|
|
|
103
128
|
passes: 2,
|
|
104
129
|
verify: true,
|
|
105
130
|
fallbackOnError: "skip",
|
|
131
|
+
providerRecovery: "fallback",
|
|
132
|
+
providerRetries: 1,
|
|
133
|
+
chunkedSubmit: "auto",
|
|
134
|
+
chunkChars: 6_000,
|
|
135
|
+
recoveryBudgetMs: 0,
|
|
106
136
|
exploreBudget: 1,
|
|
107
137
|
timeoutMs: 300_000,
|
|
108
138
|
contextChars: 15_000,
|
|
@@ -125,6 +155,83 @@ const CONTEXT_ENTRIES_MAX = 120;
|
|
|
125
155
|
const CONTEXT_TOOL_CHARS_MIN = 200;
|
|
126
156
|
const CONTEXT_TOOL_CHARS_MAX = 4_000;
|
|
127
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Build the automatic fallback list: the parent session's model first (it just
|
|
160
|
+
* planned successfully), then the base reviewer and planner roles from
|
|
161
|
+
* roles.json (ignoring the active profile). The caller supplies the clamp so
|
|
162
|
+
* this stays independent of Pi's model registry.
|
|
163
|
+
*/
|
|
164
|
+
export function buildReviewerFallbacks(input: {
|
|
165
|
+
parentModel?: ReviewFallbackModel;
|
|
166
|
+
baseReviewer?: ReviewFallbackModel;
|
|
167
|
+
basePlanner?: ReviewFallbackModel;
|
|
168
|
+
clampThinking?: (model: ReviewFallbackModel) => string;
|
|
169
|
+
}): ReviewFallbackModel[] {
|
|
170
|
+
const out: ReviewFallbackModel[] = [];
|
|
171
|
+
const seen = new Set<string>();
|
|
172
|
+
const push = (candidate: ReviewFallbackModel | undefined): void => {
|
|
173
|
+
if (!candidate) return;
|
|
174
|
+
const provider = candidate.provider.trim();
|
|
175
|
+
const id = candidate.id.trim();
|
|
176
|
+
if (!provider || !id) return;
|
|
177
|
+
const key = `${provider}/${id}`;
|
|
178
|
+
if (seen.has(key)) return;
|
|
179
|
+
seen.add(key);
|
|
180
|
+
let thinking = candidate.thinking.trim() || "high";
|
|
181
|
+
if (input.clampThinking) {
|
|
182
|
+
try {
|
|
183
|
+
thinking = input.clampThinking({ ...candidate, provider, id }) || thinking;
|
|
184
|
+
} catch {
|
|
185
|
+
/* a registry miss keeps the stored level */
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
out.push({ provider, id, thinking });
|
|
189
|
+
};
|
|
190
|
+
push(input.parentModel);
|
|
191
|
+
push(input.baseReviewer);
|
|
192
|
+
push(input.basePlanner);
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The bounded attempt chain for one review round: the selected reviewer model,
|
|
198
|
+
* an explicit fallback, then the automatic fallback list. Duplicate
|
|
199
|
+
* provider/id pairs are rejected so a retry never repeats the same model.
|
|
200
|
+
*/
|
|
201
|
+
export function buildReviewerAttempts(input: {
|
|
202
|
+
base?: ReviewFallbackModel;
|
|
203
|
+
explicitFallback?: ReviewFallbackModel;
|
|
204
|
+
fallbacks?: readonly ReviewFallbackModel[];
|
|
205
|
+
max?: number;
|
|
206
|
+
}): ReviewFallbackModel[] {
|
|
207
|
+
const out: ReviewFallbackModel[] = [];
|
|
208
|
+
const seen = new Set<string>();
|
|
209
|
+
const push = (candidate: ReviewFallbackModel | undefined): void => {
|
|
210
|
+
if (!candidate) return;
|
|
211
|
+
const provider = candidate.provider.trim();
|
|
212
|
+
const id = candidate.id.trim();
|
|
213
|
+
if (!provider || !id) return;
|
|
214
|
+
const key = `${provider}/${id}`;
|
|
215
|
+
if (seen.has(key)) return;
|
|
216
|
+
seen.add(key);
|
|
217
|
+
out.push({
|
|
218
|
+
provider,
|
|
219
|
+
id,
|
|
220
|
+
thinking: candidate.thinking.trim() || "high",
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
push(input.base);
|
|
224
|
+
push(input.explicitFallback);
|
|
225
|
+
for (const model of input.fallbacks ?? []) push(model);
|
|
226
|
+
return out.slice(0, Math.max(1, Math.trunc(input.max ?? 4)));
|
|
227
|
+
}
|
|
228
|
+
const PROVIDER_RETRIES_MIN = 0;
|
|
229
|
+
const PROVIDER_RETRIES_MAX = 2;
|
|
230
|
+
const CHUNK_CHARS_MIN = 1_000;
|
|
231
|
+
const CHUNK_CHARS_MAX = 20_000;
|
|
232
|
+
const RECOVERY_BUDGET_MIN = 0;
|
|
233
|
+
const RECOVERY_BUDGET_MAX = 3_600_000;
|
|
234
|
+
|
|
128
235
|
/** A JSON object parsed from an untrusted source (settings file, fragment). */
|
|
129
236
|
export type JsonObject = Record<string, unknown>;
|
|
130
237
|
|
|
@@ -149,6 +256,19 @@ function asBool(value: unknown, fallback: boolean): boolean {
|
|
|
149
256
|
return typeof value === "boolean" ? value : fallback;
|
|
150
257
|
}
|
|
151
258
|
|
|
259
|
+
function sanitizeFallbackModel(value: unknown): ReviewFallbackModel | undefined {
|
|
260
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
261
|
+
const raw = value as Record<string, unknown>;
|
|
262
|
+
const provider = typeof raw.provider === "string" ? raw.provider.trim() : "";
|
|
263
|
+
const id = typeof raw.id === "string" ? raw.id.trim() : "";
|
|
264
|
+
if (!provider || !id) return undefined;
|
|
265
|
+
const thinking =
|
|
266
|
+
typeof raw.thinking === "string" && raw.thinking.trim()
|
|
267
|
+
? raw.thinking.trim()
|
|
268
|
+
: "high";
|
|
269
|
+
return { provider, id, thinking };
|
|
270
|
+
}
|
|
271
|
+
|
|
152
272
|
/**
|
|
153
273
|
* Coerce an untrusted settings fragment into a complete ReviewModeConfig.
|
|
154
274
|
* Accepts an object, or the boolean shorthand `true`/`false` for
|
|
@@ -168,12 +288,42 @@ export function coerceReviewModeConfig(
|
|
|
168
288
|
r.fallbackOnError === "block" || r.fallbackOnError === "skip"
|
|
169
289
|
? r.fallbackOnError
|
|
170
290
|
: base.fallbackOnError;
|
|
291
|
+
const providerRecovery: ReviewProviderRecovery =
|
|
292
|
+
r.providerRecovery === "off" || r.providerRecovery === "fallback"
|
|
293
|
+
? r.providerRecovery
|
|
294
|
+
: base.providerRecovery;
|
|
295
|
+
const chunkedSubmit: ReviewChunkedSubmit =
|
|
296
|
+
r.chunkedSubmit === "off" || r.chunkedSubmit === "auto"
|
|
297
|
+
? r.chunkedSubmit
|
|
298
|
+
: base.chunkedSubmit;
|
|
299
|
+
const fallbackModel = sanitizeFallbackModel(r.fallbackModel);
|
|
171
300
|
return {
|
|
172
301
|
enabled: asBool(r.enabled, base.enabled),
|
|
173
302
|
rounds: clampInt(r.rounds, ROUNDS_MIN, ROUNDS_MAX, base.rounds),
|
|
174
303
|
passes: clampInt(r.passes, PASSES_MIN, PASSES_MAX, base.passes),
|
|
175
304
|
verify: asBool(r.verify, base.verify),
|
|
176
305
|
fallbackOnError,
|
|
306
|
+
providerRecovery,
|
|
307
|
+
providerRetries: clampInt(
|
|
308
|
+
r.providerRetries,
|
|
309
|
+
PROVIDER_RETRIES_MIN,
|
|
310
|
+
PROVIDER_RETRIES_MAX,
|
|
311
|
+
base.providerRetries,
|
|
312
|
+
),
|
|
313
|
+
...(fallbackModel ? { fallbackModel } : {}),
|
|
314
|
+
chunkedSubmit,
|
|
315
|
+
chunkChars: clampInt(
|
|
316
|
+
r.chunkChars,
|
|
317
|
+
CHUNK_CHARS_MIN,
|
|
318
|
+
CHUNK_CHARS_MAX,
|
|
319
|
+
base.chunkChars,
|
|
320
|
+
),
|
|
321
|
+
recoveryBudgetMs: clampInt(
|
|
322
|
+
r.recoveryBudgetMs,
|
|
323
|
+
RECOVERY_BUDGET_MIN,
|
|
324
|
+
RECOVERY_BUDGET_MAX,
|
|
325
|
+
base.recoveryBudgetMs,
|
|
326
|
+
),
|
|
177
327
|
exploreBudget: clampInt(
|
|
178
328
|
r.exploreBudget,
|
|
179
329
|
EXPLORE_MIN,
|
|
@@ -478,6 +628,22 @@ export function formatReviewStatusLines(input: ReviewStatusInput): string[] {
|
|
|
478
628
|
c.timeoutMs,
|
|
479
629
|
input.timeoutSource,
|
|
480
630
|
)}`,
|
|
631
|
+
` recovery : ${c.providerRecovery}${
|
|
632
|
+
c.providerRecovery === "fallback"
|
|
633
|
+
? ` (${c.providerRetries} SDK retry/model; budget ${
|
|
634
|
+
c.recoveryBudgetMs === 0
|
|
635
|
+
? "round deadline"
|
|
636
|
+
: `${Math.round(c.recoveryBudgetMs / 1000)}s`
|
|
637
|
+
}${
|
|
638
|
+
c.fallbackModel
|
|
639
|
+
? `; explicit ${c.fallbackModel.provider}/${c.fallbackModel.id} (${c.fallbackModel.thinking})`
|
|
640
|
+
: ""
|
|
641
|
+
})`
|
|
642
|
+
: ""
|
|
643
|
+
}`,
|
|
644
|
+
` chunked submit : ${c.chunkedSubmit}${
|
|
645
|
+
c.chunkedSubmit === "auto" ? ` (${c.chunkChars} chars/chunk)` : ""
|
|
646
|
+
}`,
|
|
481
647
|
];
|
|
482
648
|
if (
|
|
483
649
|
input.timeoutSource !== "default" &&
|
|
@@ -682,6 +848,10 @@ export interface ReviewState {
|
|
|
682
848
|
rewrittenPlan?: string;
|
|
683
849
|
/** Reviewer passes completed in this round. */
|
|
684
850
|
passes: ReviewPassResult[];
|
|
851
|
+
/** Recovery attempts made in this round, in order (model labels). */
|
|
852
|
+
recoveryAttempts?: string[];
|
|
853
|
+
/** Last provider error text captured during the round. */
|
|
854
|
+
providerError?: string;
|
|
685
855
|
/** Number of `review_explore` subagents used this round. */
|
|
686
856
|
exploreUsed: number;
|
|
687
857
|
/** Input tokens reported by the child session, when available. */
|
|
@@ -1440,9 +1610,11 @@ export function reviewEscapeAction(input: {
|
|
|
1440
1610
|
export function reviewFooterSegment(
|
|
1441
1611
|
state: ReviewState | undefined,
|
|
1442
1612
|
paused: boolean,
|
|
1613
|
+
/** A round owns the turn before its first state event (cold boot). */
|
|
1614
|
+
starting = false,
|
|
1443
1615
|
): string {
|
|
1444
1616
|
if (paused) return "🔍 review paused — /review-resume to continue";
|
|
1445
|
-
if (!state) return "";
|
|
1617
|
+
if (!state) return starting ? "🔍 review · starting" : "";
|
|
1446
1618
|
return reviewStateSummary(state);
|
|
1447
1619
|
}
|
|
1448
1620
|
|
|
@@ -1494,7 +1666,13 @@ export function reviewStatusText(input: {
|
|
|
1494
1666
|
paused: input.paused,
|
|
1495
1667
|
phase: input.state?.phase,
|
|
1496
1668
|
});
|
|
1497
|
-
return visible
|
|
1669
|
+
return visible
|
|
1670
|
+
? reviewFooterSegment(
|
|
1671
|
+
input.state,
|
|
1672
|
+
input.paused === true,
|
|
1673
|
+
input.roundInFlight === true,
|
|
1674
|
+
)
|
|
1675
|
+
: "";
|
|
1498
1676
|
}
|
|
1499
1677
|
|
|
1500
1678
|
/**
|
|
@@ -2186,6 +2364,12 @@ export interface CreateReviewSessionOptions {
|
|
|
2186
2364
|
reviewPrompt: string;
|
|
2187
2365
|
/** Reviewer-only tools, built by the caller. */
|
|
2188
2366
|
customTools: unknown[];
|
|
2367
|
+
/**
|
|
2368
|
+
* SDK retries per reviewer model (0-2). Applied through a runtime-only child
|
|
2369
|
+
* SettingsManager override so the recovery chain advances after one doomed
|
|
2370
|
+
* long request instead of spending the parent's 3-retry budget first.
|
|
2371
|
+
*/
|
|
2372
|
+
providerRetries?: number;
|
|
2189
2373
|
/**
|
|
2190
2374
|
* Parent-side provider-registration surface (structural, feature-detected),
|
|
2191
2375
|
* found with `findParentProviderSurface(ctx)` — the session's model registry
|
|
@@ -2246,6 +2430,53 @@ function readActiveToolNames(session: unknown): string[] | undefined {
|
|
|
2246
2430
|
}
|
|
2247
2431
|
}
|
|
2248
2432
|
|
|
2433
|
+
function clampProviderRetries(value: number | undefined): number {
|
|
2434
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return 1;
|
|
2435
|
+
return Math.min(2, Math.max(0, Math.trunc(value)));
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
/**
|
|
2439
|
+
* Build the child's runtime-only SettingsManager so its SDK retry budget is
|
|
2440
|
+
* bounded. `applyOverrides` is in-memory: the shared settings.json is never
|
|
2441
|
+
* written. Feature-detected for older Pi runtimes; a failure here means the
|
|
2442
|
+
* child keeps the parent's settings and the chain simply waits that budget out.
|
|
2443
|
+
*/
|
|
2444
|
+
function createChildSettingsManager(
|
|
2445
|
+
sdk: unknown,
|
|
2446
|
+
cwd: string,
|
|
2447
|
+
agentDir: string,
|
|
2448
|
+
providerRetries: number | undefined,
|
|
2449
|
+
): unknown | undefined {
|
|
2450
|
+
try {
|
|
2451
|
+
const holder = sdk as {
|
|
2452
|
+
SettingsManager?: { create?: (...args: unknown[]) => unknown };
|
|
2453
|
+
};
|
|
2454
|
+
const SettingsManager = holder?.SettingsManager;
|
|
2455
|
+
const create = SettingsManager?.create;
|
|
2456
|
+
if (typeof create !== "function") return undefined;
|
|
2457
|
+
const manager = create.call(SettingsManager, cwd, agentDir) as {
|
|
2458
|
+
getRetrySettings?: () => Record<string, unknown>;
|
|
2459
|
+
applyOverrides?: (overrides: unknown) => void;
|
|
2460
|
+
};
|
|
2461
|
+
if (!manager || typeof manager !== "object") return undefined;
|
|
2462
|
+
if (typeof manager.applyOverrides !== "function") return manager;
|
|
2463
|
+
const base =
|
|
2464
|
+
typeof manager.getRetrySettings === "function"
|
|
2465
|
+
? manager.getRetrySettings()
|
|
2466
|
+
: { enabled: true, maxRetries: 3, baseDelayMs: 2_000, maxAgentDelayMs: 60_000 };
|
|
2467
|
+
manager.applyOverrides({
|
|
2468
|
+
retry: {
|
|
2469
|
+
...base,
|
|
2470
|
+
enabled: true,
|
|
2471
|
+
maxRetries: clampProviderRetries(providerRetries),
|
|
2472
|
+
},
|
|
2473
|
+
});
|
|
2474
|
+
return manager;
|
|
2475
|
+
} catch {
|
|
2476
|
+
return undefined;
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2249
2480
|
function wrapAgentSession(
|
|
2250
2481
|
raw: unknown,
|
|
2251
2482
|
modelLabel: string,
|
|
@@ -2421,6 +2652,14 @@ export async function createReviewSession(
|
|
|
2421
2652
|
// custom tools too, so a built-ins-only list would silently remove
|
|
2422
2653
|
// `review_submit_plan` and make every review fail (see REVIEW_BUILTIN_TOOLS).
|
|
2423
2654
|
const expectedTools = reviewToolAllowlist(options.customTools);
|
|
2655
|
+
// The child gets its own retry budget. The shared settings file is not
|
|
2656
|
+
// touched: applyOverrides is runtime-only.
|
|
2657
|
+
const settingsManager = createChildSettingsManager(
|
|
2658
|
+
sdk,
|
|
2659
|
+
options.cwd,
|
|
2660
|
+
options.agentDir,
|
|
2661
|
+
options.providerRetries,
|
|
2662
|
+
);
|
|
2424
2663
|
|
|
2425
2664
|
process.env[REVIEW_CHILD_MARKER_ENV] = "1";
|
|
2426
2665
|
let created: { session?: unknown; modelFallbackMessage?: string };
|
|
@@ -2435,6 +2674,7 @@ export async function createReviewSession(
|
|
|
2435
2674
|
customTools: options.customTools as never,
|
|
2436
2675
|
resourceLoader: loader,
|
|
2437
2676
|
sessionManager,
|
|
2677
|
+
...(settingsManager ? { settingsManager } : {}),
|
|
2438
2678
|
sessionStartEvent: { type: "session_start", reason: "startup" },
|
|
2439
2679
|
} as never);
|
|
2440
2680
|
} finally {
|