@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/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 ? reviewFooterSegment(input.state, input.paused === true) : "";
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 {