agent-nuvira 3.4.0 → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +13 -13
  2. package/dist/agent-sdk/src/register.d.ts +23 -7
  3. package/dist/agent-sdk/src/register.d.ts.map +1 -1
  4. package/dist/agent-sdk/src/register.js +159 -5
  5. package/dist/agent-sdk/src/register.js.map +1 -1
  6. package/dist/agent-sdk/src/scaffold.d.ts +1 -1
  7. package/dist/agent-sdk/src/scaffold.js +1 -1
  8. package/dist/cli/agent.d.ts.map +1 -1
  9. package/dist/cli/agent.js +16 -3
  10. package/dist/cli/agent.js.map +1 -1
  11. package/dist/cli/chat.d.ts +202 -6
  12. package/dist/cli/chat.d.ts.map +1 -1
  13. package/dist/cli/chat.js +805 -34
  14. package/dist/cli/chat.js.map +1 -1
  15. package/dist/cli/config.d.ts +10 -0
  16. package/dist/cli/config.d.ts.map +1 -1
  17. package/dist/cli/config.js +39 -0
  18. package/dist/cli/config.js.map +1 -1
  19. package/dist/cli/doctor.d.ts.map +1 -1
  20. package/dist/cli/doctor.js +18 -3
  21. package/dist/cli/doctor.js.map +1 -1
  22. package/dist/cli/duties.d.ts +11 -0
  23. package/dist/cli/duties.d.ts.map +1 -1
  24. package/dist/cli/duties.js +31 -0
  25. package/dist/cli/duties.js.map +1 -1
  26. package/dist/cli/mcp.d.ts +50 -0
  27. package/dist/cli/mcp.d.ts.map +1 -1
  28. package/dist/cli/mcp.js +496 -49
  29. package/dist/cli/mcp.js.map +1 -1
  30. package/dist/cli/memory.d.ts.map +1 -1
  31. package/dist/cli/memory.js +3 -2
  32. package/dist/cli/memory.js.map +1 -1
  33. package/dist/cli/models.d.ts.map +1 -1
  34. package/dist/cli/models.js +55 -0
  35. package/dist/cli/models.js.map +1 -1
  36. package/dist/cli/plugins.d.ts.map +1 -1
  37. package/dist/cli/plugins.js +26 -9
  38. package/dist/cli/plugins.js.map +1 -1
  39. package/dist/cli/sdk.d.ts.map +1 -1
  40. package/dist/cli/sdk.js +10 -3
  41. package/dist/cli/sdk.js.map +1 -1
  42. package/dist/cli/tool-install-prompt.d.ts.map +1 -1
  43. package/dist/cli/tool-install-prompt.js +23 -1
  44. package/dist/cli/tool-install-prompt.js.map +1 -1
  45. package/dist/cli/tools.d.ts +16 -0
  46. package/dist/cli/tools.d.ts.map +1 -1
  47. package/dist/cli/tools.js +101 -1
  48. package/dist/cli/tools.js.map +1 -1
  49. package/dist/config/manager.d.ts +6 -0
  50. package/dist/config/manager.d.ts.map +1 -1
  51. package/dist/config/manager.js +66 -0
  52. package/dist/config/manager.js.map +1 -1
  53. package/dist/config/types.d.ts +18 -0
  54. package/dist/config/types.d.ts.map +1 -1
  55. package/dist/index.d.ts +2 -2
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +4 -1
  58. package/dist/index.js.map +1 -1
  59. package/dist/learning/auto-router.d.ts +28 -0
  60. package/dist/learning/auto-router.d.ts.map +1 -1
  61. package/dist/learning/auto-router.js +84 -5
  62. package/dist/learning/auto-router.js.map +1 -1
  63. package/dist/learning/duty-lease.d.ts +163 -0
  64. package/dist/learning/duty-lease.d.ts.map +1 -0
  65. package/dist/learning/duty-lease.js +301 -0
  66. package/dist/learning/duty-lease.js.map +1 -0
  67. package/dist/learning/exhaustion-report.d.ts +12 -0
  68. package/dist/learning/exhaustion-report.d.ts.map +1 -1
  69. package/dist/learning/exhaustion-report.js +3 -1
  70. package/dist/learning/exhaustion-report.js.map +1 -1
  71. package/dist/learning/model-capability.d.ts +33 -0
  72. package/dist/learning/model-capability.d.ts.map +1 -1
  73. package/dist/learning/model-capability.js +51 -0
  74. package/dist/learning/model-capability.js.map +1 -1
  75. package/dist/learning/model-first-router.d.ts +21 -22
  76. package/dist/learning/model-first-router.d.ts.map +1 -1
  77. package/dist/learning/model-first-router.js +61 -54
  78. package/dist/learning/model-first-router.js.map +1 -1
  79. package/dist/learning/model-selection.d.ts +27 -0
  80. package/dist/learning/model-selection.d.ts.map +1 -1
  81. package/dist/learning/model-selection.js +46 -0
  82. package/dist/learning/model-selection.js.map +1 -1
  83. package/dist/learning/model-warmup.d.ts +92 -0
  84. package/dist/learning/model-warmup.d.ts.map +1 -1
  85. package/dist/learning/model-warmup.js +137 -2
  86. package/dist/learning/model-warmup.js.map +1 -1
  87. package/dist/learning/reasoning-trace.d.ts +1 -1
  88. package/dist/learning/reasoning-trace.d.ts.map +1 -1
  89. package/dist/learning/reasoning-trace.js.map +1 -1
  90. package/dist/learning/resilient-call.d.ts +15 -1
  91. package/dist/learning/resilient-call.d.ts.map +1 -1
  92. package/dist/learning/resilient-call.js +38 -22
  93. package/dist/learning/resilient-call.js.map +1 -1
  94. package/dist/learning/retry-patience.d.ts +139 -0
  95. package/dist/learning/retry-patience.d.ts.map +1 -0
  96. package/dist/learning/retry-patience.js +169 -0
  97. package/dist/learning/retry-patience.js.map +1 -0
  98. package/dist/learning/working-state.d.ts +7 -0
  99. package/dist/learning/working-state.d.ts.map +1 -1
  100. package/dist/learning/working-state.js +23 -1
  101. package/dist/learning/working-state.js.map +1 -1
  102. package/dist/mcp/catalog.d.ts +206 -26
  103. package/dist/mcp/catalog.d.ts.map +1 -1
  104. package/dist/mcp/catalog.js +664 -52
  105. package/dist/mcp/catalog.js.map +1 -1
  106. package/dist/mcp/client.d.ts +6 -0
  107. package/dist/mcp/client.d.ts.map +1 -1
  108. package/dist/mcp/client.js +35 -5
  109. package/dist/mcp/client.js.map +1 -1
  110. package/dist/mcp/custom-server.d.ts +120 -0
  111. package/dist/mcp/custom-server.d.ts.map +1 -0
  112. package/dist/mcp/custom-server.js +330 -0
  113. package/dist/mcp/custom-server.js.map +1 -0
  114. package/dist/mcp/manager.d.ts +9 -1
  115. package/dist/mcp/manager.d.ts.map +1 -1
  116. package/dist/mcp/manager.js +15 -2
  117. package/dist/mcp/manager.js.map +1 -1
  118. package/dist/mcp/mcp-oauth.d.ts +28 -0
  119. package/dist/mcp/mcp-oauth.d.ts.map +1 -1
  120. package/dist/mcp/mcp-oauth.js +63 -8
  121. package/dist/mcp/mcp-oauth.js.map +1 -1
  122. package/dist/mcp/oauth-authorize.d.ts +51 -0
  123. package/dist/mcp/oauth-authorize.d.ts.map +1 -0
  124. package/dist/mcp/oauth-authorize.js +133 -0
  125. package/dist/mcp/oauth-authorize.js.map +1 -0
  126. package/dist/mcp/oauth-connect.d.ts +53 -0
  127. package/dist/mcp/oauth-connect.d.ts.map +1 -0
  128. package/dist/mcp/oauth-connect.js +103 -0
  129. package/dist/mcp/oauth-connect.js.map +1 -0
  130. package/dist/mcp/pin-verifier.d.ts +44 -0
  131. package/dist/mcp/pin-verifier.d.ts.map +1 -0
  132. package/dist/mcp/pin-verifier.js +102 -0
  133. package/dist/mcp/pin-verifier.js.map +1 -0
  134. package/dist/mcp/prereqs.d.ts +145 -0
  135. package/dist/mcp/prereqs.d.ts.map +1 -0
  136. package/dist/mcp/prereqs.js +245 -0
  137. package/dist/mcp/prereqs.js.map +1 -0
  138. package/dist/mcp/runtime.d.ts +59 -0
  139. package/dist/mcp/runtime.d.ts.map +1 -0
  140. package/dist/mcp/runtime.js +77 -0
  141. package/dist/mcp/runtime.js.map +1 -0
  142. package/dist/mcp/server.d.ts +21 -0
  143. package/dist/mcp/server.d.ts.map +1 -1
  144. package/dist/mcp/server.js +29 -1
  145. package/dist/mcp/server.js.map +1 -1
  146. package/dist/mcp/settings.d.ts +164 -0
  147. package/dist/mcp/settings.d.ts.map +1 -0
  148. package/dist/mcp/settings.js +358 -0
  149. package/dist/mcp/settings.js.map +1 -0
  150. package/dist/mcp/types.d.ts +62 -0
  151. package/dist/mcp/types.d.ts.map +1 -1
  152. package/dist/mcp/types.js.map +1 -1
  153. package/dist/memory/vector-store.d.ts +12 -0
  154. package/dist/memory/vector-store.d.ts.map +1 -1
  155. package/dist/memory/vector-store.js +23 -0
  156. package/dist/memory/vector-store.js.map +1 -1
  157. package/dist/plugins/agent-plugin.d.ts +35 -0
  158. package/dist/plugins/agent-plugin.d.ts.map +1 -1
  159. package/dist/plugins/agent-plugin.js +142 -24
  160. package/dist/plugins/agent-plugin.js.map +1 -1
  161. package/dist/sandbox/manager.d.ts +12 -0
  162. package/dist/sandbox/manager.d.ts.map +1 -1
  163. package/dist/sandbox/manager.js +43 -2
  164. package/dist/sandbox/manager.js.map +1 -1
  165. package/dist/tools/modality/browser.d.ts +39 -3
  166. package/dist/tools/modality/browser.d.ts.map +1 -1
  167. package/dist/tools/modality/browser.js +113 -16
  168. package/dist/tools/modality/browser.js.map +1 -1
  169. package/dist/tools/optional-deps.d.ts +48 -0
  170. package/dist/tools/optional-deps.d.ts.map +1 -0
  171. package/dist/tools/optional-deps.js +42 -0
  172. package/dist/tools/optional-deps.js.map +1 -0
  173. package/dist/tools/registry.d.ts.map +1 -1
  174. package/dist/tools/registry.js +68 -11
  175. package/dist/tools/registry.js.map +1 -1
  176. package/dist/tools/remediation-ladder.d.ts.map +1 -1
  177. package/dist/tools/remediation-ladder.js +9 -1
  178. package/dist/tools/remediation-ladder.js.map +1 -1
  179. package/dist/tools/tool-loop.d.ts +41 -3
  180. package/dist/tools/tool-loop.d.ts.map +1 -1
  181. package/dist/tools/tool-loop.js +317 -9
  182. package/dist/tools/tool-loop.js.map +1 -1
  183. package/dist/web-dashboard/chat-retry.d.ts +11 -1
  184. package/dist/web-dashboard/chat-retry.d.ts.map +1 -1
  185. package/dist/web-dashboard/chat-retry.js +18 -4
  186. package/dist/web-dashboard/chat-retry.js.map +1 -1
  187. package/dist/web-dashboard/server.d.ts.map +1 -1
  188. package/dist/web-dashboard/server.js +349 -2
  189. package/dist/web-dashboard/server.js.map +1 -1
  190. package/package.json +6 -2
  191. package/src/web-dashboard/public/assets/{index-DeD3L0kG.js → index-Bik91KJh.js} +115 -89
  192. package/src/web-dashboard/public/assets/index-Bik91KJh.js.map +1 -0
  193. package/src/web-dashboard/public/index.html +1 -1
  194. package/src/web-dashboard/public/assets/index-DeD3L0kG.js.map +0 -1
package/dist/cli/chat.js CHANGED
@@ -24,7 +24,7 @@ import { beginIsolation, endIsolation, resolveIsolationRequest, worktreeNotice,
24
24
  import { closeResume, openResume, resolveResumeRequest, } from '../learning/step-checkpoint.js';
25
25
  import { applyActiveModel } from './model.js';
26
26
  import { capabilityReasoningEffort, isMaxCapability } from '../config/capability-mode.js';
27
- import { getProviderFallback, classifyFallbackError, isRetryableError, isTransientForRetry, recordRegistrySuccess, recordRegistryFailure } from '../learning/provider-fallback.js';
27
+ import { getProviderFallback, classifyFallbackError, isRetryableError, isTransientForRetry, isHarnessFault, recordRegistrySuccess, recordRegistryFailure } from '../learning/provider-fallback.js';
28
28
  import { recordActionFailure, } from '../learning/failure-bookkeeping.js';
29
29
  import { getRouterBandit } from '../learning/router-bandit.js';
30
30
  import { UnusableModelResponseError } from '../learning/response-usability.js';
@@ -62,7 +62,9 @@ import { assertAgenticRoute, setWeakModelConsent, resolveWeakModelPolicy, weakRo
62
62
  import { resolvePromptBudget, measurePromptBudget, formatPromptBudgetBreakdown } from '../learning/prompt-budget.js';
63
63
  import { buildTurnReport, formatTurnReport } from '../learning/turn-report.js';
64
64
  import { turnOutcomeObservation } from '../learning/outcome-observation.js';
65
- import { resolveAdapterDefault, hasCredentials } from '../learning/model-selection.js';
65
+ import { resolveAdapterDefault, hasCredentials, preferredModelsFor, rankAvailableProviders } from '../learning/model-selection.js';
66
+ import { sameClassFallback, capabilityClassOf } from '../learning/model-capability.js';
67
+ import { planBlipPatience, EMPTY_PATIENCE_MS, TRANSIENT_BLIP_DELAYS_MS, } from '../learning/retry-patience.js';
66
68
  import { buildLoopProjectContext } from '../tools/loop-project-context.js';
67
69
  import { sweepTransientFailures, collectionRevivalStore } from '../learning/provider-revival.js';
68
70
  import { analyzeComplexity } from '../learning/hybrid-router.js';
@@ -260,12 +262,261 @@ export async function runDeveloperMode(goal, configManager, options) {
260
262
  * caller, never to skip the loop).
261
263
  */
262
264
  /**
263
- * Backoff schedule for a SAME-provider retry on a transient failure. Two extra
264
- * attempts, deliberately short: a capacity spike at a shared endpoint clears in
265
- * seconds, and the user is waiting in the foreground. Long/looping retries belong
266
- * to the background runners, not the interactive turn.
265
+ * Backoff schedule for a SAME-provider retry on a TRANSIENT failure
266
+ * (server/network/timeout): dense early, sparse late — attempts at ≈1s, 3s, 7s,
267
+ * 15s, 30s, 60s.
268
+ *
269
+ * WHY THE SHAPE CHANGED (it was `[5s, 10s, 20s, 30s]`, front-loaded). A fixed
270
+ * front-loaded schedule is wrong in BOTH directions at once:
271
+ *
272
+ * - A TWO-SECOND BLINK WAS NOT CAUGHT. A cloud endpoint routinely returns 200,
273
+ * then a couple of empty/5xx answers for a handful of seconds, then serves
274
+ * normally again (measured repeatedly on `deepseek`). The first retry sat at
275
+ * t=5s, so the wait outlived the blink AND, because exhausting this schedule
276
+ * is what starts the failover walk, a healthy strong model was declared dead
277
+ * and the turn folded onto a weaker one. A retry at t≈1s catches the blink
278
+ * outright.
279
+ * - THE TOTAL STILL HAS TO RIDE OUT A REAL SPIKE. A capacity spike, an
280
+ * upstream batch job or a rolling restart lasts tens of seconds, so the late
281
+ * delays stay sparse and the total stays ~60s. Nothing that used to be
282
+ * ridden out is given up on; the wait is just spent where the evidence is.
283
+ *
284
+ * THIS IS THE NO-EVIDENCE DEFAULT, NOT THE WHOLE POLICY. The schedule used per
285
+ * call comes from {@link planBlipPatience} — which also decides whether walking
286
+ * away is a remedy at all, from the pair's alternatives, its track record, and
287
+ * whether it already failed an earlier step of this turn. This constant is what
288
+ * every caller with no evidence gets, and it is what the `models`/parity
289
+ * harnesses see.
290
+ *
291
+ * Every sleep is CANCELLABLE (see `abortableSleep`), so an Esc during a 30s wait
292
+ * stops the turn immediately instead of after it.
293
+ */
294
+ export const TRANSIENT_RETRY_DELAYS_MS = TRANSIENT_BLIP_DELAYS_MS;
295
+ /**
296
+ * Resolve the backoff schedule actually used (env override → default).
297
+ *
298
+ * `NUVIRA_TRANSIENT_RETRY_DELAYS_MS` takes a comma-separated list of ms
299
+ * (`"5000,10000"`), so how long a foreground turn waits on a flapping provider
300
+ * is a CONFIGURATION decision rather than a property of the build — the same
301
+ * rule the warmup sweep's bounds follow. A headless gateway, a CI contract
302
+ * test or a user on a metered connection can dial it down (or to `0`) without a
303
+ * source edit and a rebuild; nonsense is ignored and the default stands.
304
+ *
305
+ * Read PER CALL, not captured at import, so a test observes the value in force
306
+ * at that moment.
307
+ */
308
+ export function transientRetryDelaysMs(env = process.env) {
309
+ const raw = env.NUVIRA_TRANSIENT_RETRY_DELAYS_MS;
310
+ if (raw === undefined || raw.trim() === '')
311
+ return TRANSIENT_RETRY_DELAYS_MS;
312
+ const parsed = raw
313
+ .split(',')
314
+ .map((part) => Number(part.trim()))
315
+ .filter((n) => Number.isFinite(n) && n >= 0);
316
+ return parsed.length > 0 ? parsed : TRANSIENT_RETRY_DELAYS_MS;
317
+ }
318
+ /**
319
+ * Is the transient schedule overridden by the environment right now?
320
+ *
321
+ * The dial is authoritative when set (it is an explicit configuration decision,
322
+ * and it is what makes attempt COUNTS deterministic in the parity/E2E
323
+ * contracts), so callers ask this BEFORE substituting an evidence-derived
324
+ * schedule — otherwise a test would silently get the learned schedule and its
325
+ * exact call-count assertions would be about a different policy than the one it
326
+ * dialed.
327
+ *
328
+ * A set-but-nonsense value does NOT count as an override: `transientRetryDelaysMs`
329
+ * ignores it and falls back to the shipped schedule, so the evidence-derived
330
+ * schedule would be overridden by nothing.
331
+ */
332
+ export function transientRetryScheduleOverridden(env = process.env) {
333
+ const raw = env.NUVIRA_TRANSIENT_RETRY_DELAYS_MS;
334
+ if (raw === undefined || raw.trim() === '')
335
+ return false;
336
+ return raw.split(',').some((part) => {
337
+ const n = Number(part.trim());
338
+ return Number.isFinite(n) && n >= 0;
339
+ });
340
+ }
341
+ /**
342
+ * Bounded extra same-model patience for an `empty-response` — the "final verse"
343
+ * case, granted ONLY to a pair that has already delivered real work this turn.
344
+ *
345
+ * THE TWO CASES LOOK IDENTICAL TO THE CLASSIFIER AND MUST NOT BE TREATED ALIKE:
346
+ *
347
+ * - a BROKEN pair answers nothing, forever. The codebase already measured the
348
+ * cost of re-asking it: "5 empty responses on one model" in one turn, no
349
+ * failover, which is why `empty-response` was removed from
350
+ * `TRANSIENT_RETRY_TYPES` and why the handoff/walk is what remedies it.
351
+ * - a pair that just finished four GOOD steps and then returns nothing is
352
+ * having a blip at the END of real work — the expensive case, because the
353
+ * turn already paid for the context, the tool results and the edits, and a
354
+ * single empty reply would otherwise discard all of it.
355
+ *
356
+ * The discriminator is EVIDENCE, not a heuristic on the reply. Two halves, both
357
+ * required to be absent before we give up on a pair: a completed tool call in
358
+ * THIS turn (`threadHasDeliveredWork`), and the registry's own record for the
359
+ * pair — verified by TELEMETRY or a spot-check, with a low error rate
360
+ * (`hasProvenTrackRecord` in `retry-patience.ts`, which is where the rule now
361
+ * lives so it cannot drift). So a pair that answers nothing from the start with
362
+ * no track record still fails over immediately, a proven pair's FIRST empty
363
+ * reply gets these two short waits, and a proven pair whose only alternatives
364
+ * would DE-ESCALATE gets the longer
365
+ * `EMPTY_PATIENCE_WAIT_BEATS_FOLDING_MS` schedule rather than folding down a
366
+ * capability band. Bounded by design, and always finite.
367
+ *
368
+ * The value is re-exported from `retry-patience.ts`: ONE table for the whole
369
+ * blip policy, so the CLI and any other caller cannot drift apart.
370
+ */
371
+ export const EMPTY_RESPONSE_PATIENCE_MS = EMPTY_PATIENCE_MS;
372
+ /**
373
+ * Sleep that a CANCELLED turn interrupts.
374
+ *
375
+ * The old retry slept with a bare `setTimeout`, so an Esc during a backoff was
376
+ * only honored when the timer happened to fire — with a 30s step in the
377
+ * schedule that is a 30s delay before the turn admits it was cancelled. This
378
+ * resolves the moment the signal aborts, so the caller's abort checks see the
379
+ * cancellation immediately.
380
+ */
381
+ function abortableSleep(ms, signal) {
382
+ if (!signal)
383
+ return new Promise((resolve) => setTimeout(resolve, ms));
384
+ if (signal.aborted)
385
+ return Promise.resolve();
386
+ return new Promise((resolve) => {
387
+ const finish = () => {
388
+ clearTimeout(timer);
389
+ signal.removeEventListener('abort', finish);
390
+ resolve();
391
+ };
392
+ const timer = setTimeout(finish, ms);
393
+ signal.addEventListener('abort', finish, { once: true });
394
+ });
395
+ }
396
+ /**
397
+ * Has this turn already produced real, OBSERVED work on this thread?
398
+ *
399
+ * Structural, not a counter: an assistant step that ASKED for a tool and the
400
+ * tool result that came back for it. That is the cheapest honest evidence that
401
+ * the pair under us has already served this turn — a pair that has not is given
402
+ * no `empty-response` patience at all, so the "ask a broken model five times"
403
+ * loop stays impossible.
404
+ */
405
+ export function threadHasDeliveredWork(thread) {
406
+ const asked = thread.some((m) => m.role === 'assistant' && (m.toolCalls?.length ?? 0) > 0);
407
+ const answered = thread.some((m) => m.role === 'tool');
408
+ return asked && answered;
409
+ }
410
+ /**
411
+ * The pairs this surface could actually switch to right now, best-first, for
412
+ * {@link planBlipPatience}.
413
+ *
414
+ * DELIBERATELY NARROW. Only providers with at least one registry-VERIFIED model
415
+ * count, and the model offered is the one that provider would actually lead
416
+ * with (`preferredModelsFor`'s health order). A provider we cannot name a model
417
+ * for is not evidence of a same-band alternative — treating a cold-start or
418
+ * unverified provider as one would SHORTEN patience on the strength of a guess,
419
+ * and the whole point of the policy is that waiting is better than an
420
+ * unproven downgrade. Unknown therefore reads as "no known alternative", the
421
+ * conservative direction.
422
+ *
423
+ * Pure read, best-effort: any failure to rank means the same thing (no known
424
+ * alternative), never a thrown turn.
267
425
  */
268
- export const TRANSIENT_RETRY_DELAYS_MS = [1_000, 3_000];
426
+ function failoverAlternatives(configManager, excludeProvider) {
427
+ const out = [];
428
+ try {
429
+ for (const ranked of rankAvailableProviders(configManager)) {
430
+ if (ranked.provider === excludeProvider)
431
+ continue;
432
+ const model = ranked.verifiedModels[0];
433
+ if (!model)
434
+ continue;
435
+ out.push({ provider: ranked.provider, model });
436
+ }
437
+ }
438
+ catch {
439
+ // Best-effort — see the note above: unknown is the conservative reading.
440
+ }
441
+ return out;
442
+ }
443
+ /**
444
+ * Registry evidence for one pair, shaped for {@link planBlipPatience}.
445
+ * `undefined` when nothing is recorded — an untracked pair earns no patience.
446
+ *
447
+ * The `default` sentinel is not a model id, so it has no evidence by definition
448
+ * (the same reasoning that keeps it out of `preferredModelsFor`).
449
+ */
450
+ function registryPatienceEvidence(provider, model) {
451
+ if (!model || model === 'default')
452
+ return undefined;
453
+ try {
454
+ const entry = getModelRegistry().getEntry(provider, model);
455
+ if (!entry)
456
+ return undefined;
457
+ return {
458
+ verified: entry.status === 'verified',
459
+ source: entry.source,
460
+ errorRate: entry.errorRate,
461
+ };
462
+ }
463
+ catch {
464
+ return undefined;
465
+ }
466
+ }
467
+ /**
468
+ * How long a PINNED + STRICT pair is given to answer before the user is asked
469
+ * what to do next (ms).
470
+ *
471
+ * WHY A WAIT AT ALL, and why two minutes. A pinned+strict turn is an explicit
472
+ * quality contract: the user would rather wait than be answered by a model they
473
+ * did not choose. A provider having a slow moment (or an upstream batch job) is
474
+ * exactly the case that contract covers, so the SAME pair is retried inside this
475
+ * window before anything else can happen. Two minutes is the bar the user set: it
476
+ * rides out a bad minute at a shared endpoint, while a genuinely dead provider no
477
+ * longer holds the turn for the ~5 minutes that prompted the complaint. NOTHING
478
+ * is routed to another model inside this window.
479
+ */
480
+ export const PINNED_SAME_PAIR_GRACE_MS = 120_000;
481
+ /**
482
+ * Backoff between retries of the SAME pinned pair. Summed with the call time,
483
+ * these reach the {@link PINNED_SAME_PAIR_GRACE_MS} budget.
484
+ */
485
+ export const PINNED_GRACE_BACKOFF_MS = [5_000, 15_000, 30_000, 45_000];
486
+ /**
487
+ * Failure kinds a WAIT can plausibly fix.
488
+ *
489
+ * A rejected credential, a model the provider does not serve, an over-long prompt
490
+ * or a request-encoding fault are DEFINITIVE: retrying them for two minutes only
491
+ * makes the user wait longer for the same answer. A pinned+strict turn refuses
492
+ * those immediately (the honest strict stop) instead of presenting them as a
493
+ * delay.
494
+ */
495
+ const PINNED_WAITABLE_KINDS = new Set(['server', 'network', 'timeout', 'rate-limit', 'unknown']);
496
+ /**
497
+ * How long the user has to answer the pinned-failure question before the turn
498
+ * stops honestly (ms).
499
+ *
500
+ * Without a bound the turn waits on the question FOREVER: the console's
501
+ * `askQuestion` promise is deliberately never rejected (worst case the model
502
+ * proceeds on best judgment), so a client that renders the card and is then
503
+ * closed — or a headless caller that never answers — would hold the turn (and its
504
+ * `busy` slot) open indefinitely. Measured live while probing the endpoint: the
505
+ * HTTP request sat open until it was cancelled by hand. Five minutes is generous
506
+ * for a person reading the options, and the answer is a choice, not a dependency:
507
+ * stopping with the pin's own error is the honest outcome once nobody answers.
508
+ */
509
+ export const PINNED_ANSWER_TIMEOUT_MS = 300_000;
510
+ /**
511
+ * Message shapes that name a DEFINITIVE model problem — the pair does not exist,
512
+ * or the request shape is wrong. A wait cannot fix either (see
513
+ * `pinnedFailureIsWaitable`).
514
+ *
515
+ * Deliberately NOT blanket-applied: it is consulted only for a failure the
516
+ * classifier could not place (`unknown`), so a DNS/network blip whose text happens
517
+ * to say "not found" still gets its grace window.
518
+ */
519
+ const PINNED_DEFINITIVE_ERROR_RE = /(?:does not exist|not found|no such model|invalid model|unknown model|unsupported model|but you passed|model_not_found)/i;
269
520
  /**
270
521
  * Run one provider attempt, retrying transient failures against the SAME
271
522
  * provider before giving up.
@@ -279,18 +530,48 @@ export const TRANSIENT_RETRY_DELAYS_MS = [1_000, 3_000];
279
530
  *
280
531
  * Never retries: non-transient classes (auth, rate-limit, model/harness faults),
281
532
  * a cancelled turn, or once the schedule is exhausted.
533
+ *
534
+ * `emptyResponseDelaysMs` is the ONE narrowly-scoped exception, and it exists in
535
+ * this helper rather than in `TRANSIENT_RETRY_TYPES` because retryability here
536
+ * depends on the TURN, not only on the error class: pass it only when the pair
537
+ * has a track record this turn (see {@link threadHasDeliveredWork}). Omitted →
538
+ * an empty response is not retried at all, byte for byte the old behaviour.
539
+ *
540
+ * `transientDelaysMs` is the caller's EVIDENCE-DERIVED schedule
541
+ * ({@link planBlipPatience}) for server/network/timeout failures. Omitted → the
542
+ * shipped default / env dial (`transientRetryDelaysMs`), which is what a caller
543
+ * with no evidence (a test, a headless path) gets.
282
544
  */
283
- export async function generateWithTransientRetry(attempt, signal, onRetry) {
284
- for (let i = 0;; i += 1) {
545
+ export async function generateWithTransientRetry(attempt, signal, onRetry, opts = {}) {
546
+ let transientRetries = 0;
547
+ let emptyRetries = 0;
548
+ const emptySchedule = opts.emptyResponseDelaysMs ?? [];
549
+ const transientSchedule = opts.transientDelaysMs ?? transientRetryDelaysMs();
550
+ for (;;) {
285
551
  try {
286
552
  return await attempt();
287
553
  }
288
554
  catch (err) {
289
- const canRetry = i < TRANSIENT_RETRY_DELAYS_MS.length && isTransientForRetry(err);
290
- if (!canRetry || signal?.aborted)
555
+ if (signal?.aborted)
556
+ throw err;
557
+ // The attempt number a human sees: 1-based, counting every retry so far.
558
+ const attemptNumber = transientRetries + emptyRetries + 1;
559
+ let delay;
560
+ if (isTransientForRetry(err)) {
561
+ delay = transientRetries < transientSchedule.length
562
+ ? transientSchedule[transientRetries++]
563
+ : undefined;
564
+ }
565
+ else if (classifyFallbackError(err) === 'empty-response' && emptyRetries < emptySchedule.length) {
566
+ delay = emptySchedule[emptyRetries++];
567
+ }
568
+ if (delay === undefined)
569
+ throw err;
570
+ onRetry?.(attemptNumber, err);
571
+ await abortableSleep(delay, signal);
572
+ // A cancelled turn must not pay for another attempt.
573
+ if (signal?.aborted)
291
574
  throw err;
292
- onRetry?.(i + 1, err);
293
- await new Promise((resolve) => setTimeout(resolve, TRANSIENT_RETRY_DELAYS_MS[i]));
294
575
  }
295
576
  }
296
577
  }
@@ -1907,7 +2188,10 @@ export class ChatCommand extends BaseCommand {
1907
2188
  },
1908
2189
  },
1909
2190
  };
1910
- const callModel = this.buildToolCallModel(message, session, options, mode, ctxOverrides?.onToken, ctxOverrides?.signal, continuationSoftwareText(message, history) ?? undefined);
2191
+ const callModel = this.buildToolCallModel(message, session, options, mode, ctxOverrides?.onToken, ctxOverrides?.signal, continuationSoftwareText(message, history) ?? undefined,
2192
+ // PINNED + STRICT: the escalation asks the USER when a pinned model will not
2193
+ // answer (keep trying / switch to a same-class model / name one / go auto).
2194
+ ctxOverrides?.askUser);
1911
2195
  let result;
1912
2196
  // v1.8x audit — CHAT TRACE CAPTURE: every LLM call in a chat turn is now
1913
2197
  // recorded to ~/.nuvira/memory/reasoning-traces.json (source 'chat'), so
@@ -2188,6 +2472,16 @@ export class ChatCommand extends BaseCommand {
2188
2472
  try {
2189
2473
  const chain = getProviderFallback(this.configManager, this.configManager.getAll().fallback)
2190
2474
  .getFallbackChain(session.type);
2475
+ /**
2476
+ * PINNED + AUTO-FALLBACK stays in the PIN'S CAPABILITY CLASS.
2477
+ *
2478
+ * A user who pins a strong model and leaves auto-fallback on has said
2479
+ * "keep me in this class" — they have not agreed to a small/fast model
2480
+ * because it happens to have the best health score. `sameClassFallback`
2481
+ * chooses within the class first and reports when it must step down, so
2482
+ * the handoff can SAY so instead of arriving as a silent downgrade.
2483
+ */
2484
+ const pinModel = session.model;
2191
2485
  for (const fbType of chain) {
2192
2486
  if (fbType === session.type)
2193
2487
  continue;
@@ -2197,7 +2491,16 @@ export class ChatCommand extends BaseCommand {
2197
2491
  continue;
2198
2492
  try {
2199
2493
  const resolved = resolveProvider(this.configManager, fbType);
2200
- const model = getAutoRouter().resolveModel(fbType, 'chat', this.configManager);
2494
+ const base = getAutoRouter().resolveModel(fbType, 'chat', this.configManager);
2495
+ const pick = sameClassFallback(pinModel, [
2496
+ ...(base ? [base] : []),
2497
+ ...preferredModelsFor(resolved.type),
2498
+ ]);
2499
+ const model = pick?.model ?? base;
2500
+ if (pick?.crossClass) {
2501
+ logger.warn(` ⚠️ ${pinModel ?? 'the pinned model'} is not available anywhere; handing over to ` +
2502
+ `${resolved.type}/${model} (${capabilityClassOf(model)} class — no same-class model was available).`);
2503
+ }
2201
2504
  if (install({ type: resolved.type, provider: resolved.provider, model }))
2202
2505
  return true;
2203
2506
  }
@@ -2395,6 +2698,10 @@ export class ChatCommand extends BaseCommand {
2395
2698
  ...(typeof breadth.poolSize === 'number' ? { poolSize: breadth.poolSize } : {}),
2396
2699
  ...(typeof breadth.poolProviders === 'number' ? { poolProviders: breadth.poolProviders } : {}),
2397
2700
  task: message,
2701
+ // The user's PIN, when there was one — so the report's own advice
2702
+ // respects the quality bar they set instead of inviting a downgrade
2703
+ // (see ExhaustionInput.pinnedModel).
2704
+ ...(session.model && session.model !== 'default' ? { pinnedModel: session.model } : {}),
2398
2705
  // The underlying line is kept as the REASON, so a specific,
2399
2706
  // actionable cause is never buried by the report.
2400
2707
  ...(result.generationFailed && result.content.trim() ? { cause: result.content } : {}),
@@ -2758,6 +3065,281 @@ export class ChatCommand extends BaseCommand {
2758
3065
  traceId: chatTraceId,
2759
3066
  });
2760
3067
  }
3068
+ /**
3069
+ * Providers that could TAKE OVER from a pin, split by capability class.
3070
+ *
3071
+ * Only providers with credentials are considered, and each contributes the
3072
+ * model `sameClassFallback` would choose for it — so the "switch to …" offer
3073
+ * is a pair the router would actually serve, never a name invented here.
3074
+ */
3075
+ pinAlternatives(pinModel, excludeProvider) {
3076
+ const sameClass = [];
3077
+ const crossClass = [];
3078
+ let types = [];
3079
+ try {
3080
+ const all = this.configManager.getAll();
3081
+ types = Object.keys(all.providers ?? {});
3082
+ }
3083
+ catch {
3084
+ return { sameClass, crossClass };
3085
+ }
3086
+ for (const providerType of types) {
3087
+ if (providerType === excludeProvider)
3088
+ continue;
3089
+ try {
3090
+ if (!hasCredentials(this.configManager, providerType))
3091
+ continue;
3092
+ const resolved = resolveProvider(this.configManager, providerType);
3093
+ const base = resolveAdapterDefault(resolved.type, undefined);
3094
+ const pick = sameClassFallback(pinModel, [
3095
+ ...(base ? [base] : []),
3096
+ ...preferredModelsFor(resolved.type),
3097
+ ]);
3098
+ if (!pick)
3099
+ continue;
3100
+ (pick.crossClass ? crossClass : sameClass).push({ provider: resolved.type, model: pick.model });
3101
+ }
3102
+ catch {
3103
+ // Next provider.
3104
+ }
3105
+ }
3106
+ return { sameClass, crossClass };
3107
+ }
3108
+ /**
3109
+ * Read a user-typed choice as `provider/model` (or a bare model id).
3110
+ *
3111
+ * A bare id keeps the PINNED provider — that is what "another model" means when
3112
+ * the user was asked about a pinned turn. A prefixed value is only accepted as a
3113
+ * provider when it RESOLVES to one, so a model id that merely contains a slash
3114
+ * (`openai/gpt-oss-120b`) is not mistaken for a provider name.
3115
+ */
3116
+ parsePinChoice(text, pinnedProvider) {
3117
+ const trimmed = text.trim();
3118
+ if (!trimmed)
3119
+ return null;
3120
+ const sep = trimmed.includes('/') ? '/' : trimmed.includes(':') ? ':' : null;
3121
+ if (sep) {
3122
+ const [head, ...rest] = trimmed.split(sep);
3123
+ const tail = rest.join(sep);
3124
+ if (head && tail) {
3125
+ try {
3126
+ const resolved = resolveProvider(this.configManager, head);
3127
+ return { provider: resolved.type, model: tail };
3128
+ }
3129
+ catch {
3130
+ // Not a provider name — treat the whole value as a model id below.
3131
+ }
3132
+ }
3133
+ }
3134
+ return { provider: pinnedProvider, model: trimmed };
3135
+ }
3136
+ /**
3137
+ * Serve ONE step on a pair the USER named, validated AS A PAIR.
3138
+ *
3139
+ * `resolveRoute` is asked in strict mode, so a pair the provider cannot serve is
3140
+ * REFUSED rather than silently substituted — the same guarantee every other
3141
+ * pinned call gets (issue #9: a model id sent to the wrong provider is the bug
3142
+ * class this covers). Returns null when the pair cannot be used or the call
3143
+ * fails, leaving the honest stop in place rather than switching anyway.
3144
+ */
3145
+ async serveChosenPair(input, providerType, model) {
3146
+ try {
3147
+ const resolved = resolveProvider(this.configManager, providerType);
3148
+ // Validated as a PAIR via `resolveRoute` in strict mode (see above).
3149
+ const route = await resolveRoute({
3150
+ providerType: resolved.type,
3151
+ provider: resolved.provider,
3152
+ model,
3153
+ source: 'chat',
3154
+ strict: true,
3155
+ });
3156
+ const resp = await input.tryGenerate(resolved.provider, resolved.type, route.model);
3157
+ input.session.type = resolved.type;
3158
+ input.session.provider = resolved.provider;
3159
+ input.session.model = route.model;
3160
+ logger.warn(` ✅ continuing on ${resolved.provider.name} / ${route.model} (your choice).`);
3161
+ return resp;
3162
+ }
3163
+ catch {
3164
+ return null;
3165
+ }
3166
+ }
3167
+ /**
3168
+ * PINNED + STRICT and the pin did not answer: wait on the SAME model, then let
3169
+ * the USER decide — never a silent substitution.
3170
+ *
3171
+ * WHY THIS EXISTS. The user pinned a model AND turned strict mode on. That is a
3172
+ * quality contract, and the old behaviour broke it in the one direction they
3173
+ * explicitly forbade: rather than wait, or say what was happening, a failed pin
3174
+ * either dead-ended in "the language model was unavailable" or (non-strict) was
3175
+ * handed to whatever the health ranking liked — including `local/qwen2.5:0.5b`, a
3176
+ * model that cannot call tools at all, so its "answer" was fabricated rather
3177
+ * than merely weaker. Sending that work to a weak model is not a degraded answer;
3178
+ * for an agentic turn it is a wrong one.
3179
+ *
3180
+ * So: the SAME pair is retried inside {@link PINNED_SAME_PAIR_GRACE_MS} first
3181
+ * (nothing is routed anywhere in that window), and if it still does not answer
3182
+ * the user is asked — keep trying, switch to the closest SAME-CLASS model the
3183
+ * agent can find and name, type another model/provider, or release the pin to
3184
+ * auto. The question says outright that nothing has been routed elsewhere.
3185
+ *
3186
+ * Returns a served response when the user's choice works; `null` when the turn
3187
+ * must fail honestly — headless (no one to ask: the strict refusal stands, as
3188
+ * it must, because silently choosing FOR the user is the original defect), or
3189
+ * when the chosen pair could not serve.
3190
+ */
3191
+ async pinnedStrictEscalation(input) {
3192
+ const { session } = input;
3193
+ const pin = `${session.type}/${session.model ?? 'default'}`;
3194
+ const kind = classifyFallbackError(input.err);
3195
+ // A DEFINITIVE failure is not a delay (see PINNED_WAITABLE_KINDS) — and a model
3196
+ // the provider does not serve, or a request-shape fault, is definitive even
3197
+ // though the classifier can only call it `unknown` (measured: a pin to a
3198
+ // nonexistent id spent the whole 2-minute grace window on a 400 that could
3199
+ // never change).
3200
+ if (!PINNED_WAITABLE_KINDS.has(kind))
3201
+ return null;
3202
+ if (kind === 'unknown') {
3203
+ const message = input.err instanceof Error ? input.err.message : String(input.err);
3204
+ if (isHarnessFault(input.err) || PINNED_DEFINITIVE_ERROR_RE.test(message))
3205
+ return null;
3206
+ }
3207
+ const attemptPin = async () => {
3208
+ try {
3209
+ return await input.tryGenerate(session.provider, session.type, session.model);
3210
+ }
3211
+ catch (err) {
3212
+ const k = classifyFallbackError(err);
3213
+ this.recordTurnAttempt({
3214
+ provider: this.lastAttempt?.provider ?? session.type,
3215
+ model: this.lastAttempt?.model ?? session.model ?? 'default',
3216
+ kind: k,
3217
+ reason: describeFailureKind(k),
3218
+ });
3219
+ return null;
3220
+ }
3221
+ };
3222
+ // ── 1. The SAME pair gets its grace window. Nothing is routed anywhere. ──
3223
+ let served = await attemptPin();
3224
+ if (served)
3225
+ return served;
3226
+ const startedAt = Date.now();
3227
+ for (const backoff of PINNED_GRACE_BACKOFF_MS) {
3228
+ if (input.signal?.aborted)
3229
+ return null;
3230
+ if (Date.now() - startedAt + backoff > PINNED_SAME_PAIR_GRACE_MS)
3231
+ break;
3232
+ // Visible on BOTH surfaces: the CLI reads it on the console, the dashboard
3233
+ // renders the same line as a progress step.
3234
+ logger.warn(` ⏳ your pinned ${pin} has not answered (${describeFailureKind(kind)}) — retrying it in ` +
3235
+ `${Math.round(backoff / 1000)}s (${Math.round((Date.now() - startedAt) / 1000)}s of ` +
3236
+ `${Math.round(PINNED_SAME_PAIR_GRACE_MS / 1000)}s). Nothing has been sent to another model.`);
3237
+ await new Promise((resolve) => setTimeout(resolve, backoff));
3238
+ served = await attemptPin();
3239
+ if (served)
3240
+ return served;
3241
+ }
3242
+ // ── 2. ASK. The pin is the user's contract; only they may release it. ────
3243
+ const alternatives = this.pinAlternatives(session.model, session.type);
3244
+ const switchTarget = alternatives.sameClass[0];
3245
+ // Headless (a pipeline, a cron, a queue worker) has no one to ask: the strict
3246
+ // refusal stands. Choosing silently FOR the user is the defect this fixes.
3247
+ if (!input.askUser && !process.stdin.isTTY)
3248
+ return null;
3249
+ const ask = input.askUser ?? (await import('../tools/ask-user.js')).renderAskUser;
3250
+ const choices = [
3251
+ {
3252
+ label: `Keep trying with ${pin}`,
3253
+ description: 'Stay on your pinned model — nothing is routed to another model.',
3254
+ },
3255
+ ];
3256
+ if (switchTarget) {
3257
+ choices.push({
3258
+ label: `Switch to ${switchTarget.provider}/${switchTarget.model}`,
3259
+ description: `The closest same-class model available (${capabilityClassOf(switchTarget.model)}).`,
3260
+ });
3261
+ }
3262
+ choices.push({
3263
+ label: 'Type another model or provider…',
3264
+ description: 'Name any model id, or provider/model, and it is used exactly as given.',
3265
+ });
3266
+ const keepIdx = 0;
3267
+ const switchIdx = switchTarget ? 1 : -1;
3268
+ const otherIdx = switchTarget ? 2 : 1;
3269
+ // NOTE — there is deliberately NO "go to auto" option. It was considered and
3270
+ // removed: it is the one choice that serves a model the user never named, and
3271
+ // a strict pin exists precisely to rule that out. A user who wants routing to
3272
+ // decide turns strict mode off, which is a decision they own and can see.
3273
+ // Nothing in this method can therefore serve a pair the user did not pick.
3274
+ const question = `I have NOT sent this to another model — you pinned ${pin} and strict mode is on, so that choice is yours.\n` +
3275
+ `${pin} did not answer within ${Math.round(PINNED_SAME_PAIR_GRACE_MS / 1000)}s (${describeFailureKind(kind)}).\n` +
3276
+ (switchTarget
3277
+ ? `If you would rather switch, ${switchTarget.provider}/${switchTarget.model} is the closest same-class model available.`
3278
+ : 'No same-class model is available to switch to right now.');
3279
+ let answer = {};
3280
+ /** The bounded-answer timer — cleared or unref'd as soon as it is not needed. */
3281
+ let answerTimer;
3282
+ try {
3283
+ // BOUNDED: an unanswered question must not hold the turn open forever
3284
+ // (see PINNED_ANSWER_TIMEOUT_MS). A late answer is simply ignored — the
3285
+ // honest stop already happened.
3286
+ const raced = await Promise.race([
3287
+ ask(question, choices, false).then((a) => ({ answered: true, a })),
3288
+ new Promise((resolve) => {
3289
+ answerTimer = setTimeout(() => resolve({ answered: false }), PINNED_ANSWER_TIMEOUT_MS);
3290
+ // Never hold the process open for an answer that already arrived: an
3291
+ // un-unref'd 5-minute timer kept the CLI alive long after the turn was
3292
+ // finished (the `chat` command would appear to hang on exit).
3293
+ answerTimer.unref?.();
3294
+ }),
3295
+ ]);
3296
+ if (answerTimer)
3297
+ clearTimeout(answerTimer);
3298
+ if (!raced.answered) {
3299
+ logger.warn(` ⌛ no answer to the pinned-model question after ${Math.round(PINNED_ANSWER_TIMEOUT_MS / 60_000)} minutes — ` +
3300
+ `stopping on ${pin} rather than choosing for you.`);
3301
+ return null;
3302
+ }
3303
+ answer = raced.a;
3304
+ }
3305
+ catch {
3306
+ return null;
3307
+ }
3308
+ const chosen = typeof answer.index === 'number' ? answer.index : -1;
3309
+ if (chosen === keepIdx) {
3310
+ // One more full window on the SAME pair, then the honest stop. Bounded on
3311
+ // purpose: "keep trying" must not become an unbounded loop the user cannot
3312
+ // interrupt — they can ask again, and the dashboard's retry offer queues it.
3313
+ const retryStart = Date.now();
3314
+ for (;;) {
3315
+ if (input.signal?.aborted)
3316
+ return null;
3317
+ const again = await attemptPin();
3318
+ if (again)
3319
+ return again;
3320
+ if (Date.now() - retryStart >= PINNED_SAME_PAIR_GRACE_MS)
3321
+ break;
3322
+ await new Promise((resolve) => setTimeout(resolve, 15_000));
3323
+ }
3324
+ return null;
3325
+ }
3326
+ if (switchIdx >= 0 && chosen === switchIdx && switchTarget) {
3327
+ return await this.serveChosenPair(input, switchTarget.provider, switchTarget.model);
3328
+ }
3329
+ if (chosen === otherIdx) {
3330
+ const custom = (answer.custom ?? '').trim();
3331
+ if (custom) {
3332
+ const pair = this.parsePinChoice(custom, session.type);
3333
+ if (pair) {
3334
+ const servedCustom = await this.serveChosenPair(input, pair.provider, pair.model);
3335
+ if (servedCustom)
3336
+ return servedCustom;
3337
+ }
3338
+ logger.warn(` ⚠️ could not use "${custom}" — check that the model is served by that provider.`);
3339
+ }
3340
+ }
3341
+ return null;
3342
+ }
2761
3343
  /**
2762
3344
  * E3b — the model-call step for the tool loop:
2763
3345
  * native generateTools when the provider supports it, JSON fallback
@@ -2797,7 +3379,14 @@ export class ChatCommand extends BaseCommand {
2797
3379
  * too, or a continuation whose first cloud provider dies re-routes on the
2798
3380
  * signal-free message and can land on a tiny local model.
2799
3381
  */
2800
- routingText) {
3382
+ routingText,
3383
+ /**
3384
+ * PINNED + STRICT escalation needs to ASK the user (see
3385
+ * `pinnedStrictEscalation`). The dashboard console injects a real question
3386
+ * card; a TTY falls back to the CLI prompt; headless has neither, and there
3387
+ * the strict refusal stands rather than anything being chosen for the user.
3388
+ */
3389
+ askUser) {
2801
3390
  return async (messages, schemas, stepOnToken, stepSignal) => {
2802
3391
  // The effective token sink: the caller's stream wins; when a step-level
2803
3392
  // sink is also given (loop passthrough) they are the same channel.
@@ -2907,12 +3496,23 @@ export class ChatCommand extends BaseCommand {
2907
3496
  // One resolution per attempt — the request, the trace, the telemetry
2908
3497
  // and the cache key all read the same value (see above).
2909
3498
  const effectiveModel = resolveEffectiveModel(typ, mdl);
2910
- // Record the ATTEMPTED model immediately so a failed step's trace and
2911
- // telemetry name the model that failed, instead of "unknown".
2912
- if (effectiveModel)
2913
- session.model = effectiveModel;
2914
- // G5 — and name the attempt itself, so the trace recorder reports the
2915
- // real provider/model (post-failover) rather than the session default.
3499
+ // Name the ATTEMPT itself — the trace recorder and the failure report
3500
+ // read `lastAttempt` — but NEVER install its model on the session
3501
+ // before the call succeeds.
3502
+ //
3503
+ // MEASURED (trace-1791732980335-omkv62, 2026-10-11). This used to assign
3504
+ // `session.model = effectiveModel` here, BEFORE the request. A pinned
3505
+ // `deepseek` turn whose step failed walked the fallback chain, and the
3506
+ // walk's last candidate — which also FAILED — left its own model on the
3507
+ // session. The loop's NEXT step then called the pinned provider with
3508
+ // that foreign id (`deepseek` + `wire-stub-model`), which the API
3509
+ // rejected outright: "The supported API model names are deepseek-flash,
3510
+ // deepseek-v4-pro, but you passed wire-stub-model". The turn burned its
3511
+ // whole failover walk (25 attempts) and ended in a model-shortage report
3512
+ // while healthy models sat in the pool. A failed attempt must never
3513
+ // become the model of the next request; the success paths
3514
+ // (`answered()`, and the walks that install what they served) are the
3515
+ // ones that own `session.model`.
2916
3516
  this.lastAttempt = { provider: typ, model: effectiveModel };
2917
3517
  const nativeKey = `${typ}|${effectiveModel ?? ''}`;
2918
3518
  // Answer-quality resilience: a CONFUSED reply — the model talking about
@@ -3025,32 +3625,91 @@ export class ChatCommand extends BaseCommand {
3025
3625
  // R2 — this IS the fallback transport, so say so: the same fact the
3026
3626
  // subagent child announces, and what makes a chat turn comparable with it.
3027
3627
  return answered({ content: text, toolCalls: calls, transport: 'json' });
3628
+ }; // ── HOW PATIENT TO BE, AND WHY ────────────────────────────────────────
3629
+ // A fixed schedule cannot tell a 2-second blink from a dead endpoint, so
3630
+ // the schedule is chosen per pair from evidence the caller ALREADY HAS:
3631
+ // is a same-or-better-band candidate reachable, does this pair have a
3632
+ // track record, and has it already failed an EARLIER step of this turn.
3633
+ // Both ways of getting this wrong are expensive — fail fast and a
3634
+ // blinking strong model is silently replaced by a weaker one nobody
3635
+ // chose; wait blindly and every user of a dead provider pays the full
3636
+ // backoff. The plan's `reason` goes to the debug log, because "why was
3637
+ // that turn slow / why did it change model" is asked after the fact.
3638
+ const patiencePair = {
3639
+ provider: session.type,
3640
+ model: this.lastAttempt?.model ?? session.model ?? 'default',
3028
3641
  };
3642
+ const patience = planBlipPatience({
3643
+ current: patiencePair,
3644
+ alternatives: failoverAlternatives(this.configManager, patiencePair.provider),
3645
+ evidence: registryPatienceEvidence(patiencePair.provider, patiencePair.model),
3646
+ // An entry here means an EARLIER step of this same turn already failed
3647
+ // on this pair — the same pair failing twice is a pattern, not a blip,
3648
+ // and patience collapses there whatever the alternatives look like.
3649
+ failedEarlierStep: this.turnAttempts.some((a) => a.provider === patiencePair.provider && a.model === patiencePair.model),
3650
+ deliveredWorkThisTurn: threadHasDeliveredWork(messages),
3651
+ });
3029
3652
  try {
3030
- // Same-provider transient retry FIRST (see the helper's contract): a
3031
- // 503 spike at a shared endpoint must not become a dead run. Failover
3032
- // only helps if a DIFFERENT provider exists — and it also hides the real
3033
- // failure from the user while parking a healthy provider for 120s.
3034
- return await generateWithTransientRetry(() => tryGenerate(session.provider, session.type, session.model), abort, (attempt, err) => logger.warn(` ⏳ ${session.provider.name} transient failure (attempt ${attempt}) — retrying shortly: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`));
3653
+ logger.debug(`[patience] ${patience.reason}`);
3654
+ }
3655
+ catch {
3656
+ // Logging must never break a turn.
3657
+ }
3658
+ try {
3659
+ return await generateWithTransientRetry(() => tryGenerate(session.provider, session.type, session.model), abort, (attempt, err) => logger.warn(` ⏳ ${session.provider.name} transient failure (attempt ${attempt}) — retrying shortly: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`),
3660
+ // Empty-response patience from the plan: granted for a proven pair
3661
+ // (registry track record or real work this turn), LONGER when every
3662
+ // alternative would de-escalate a capability band. A pair with neither
3663
+ // gets none — the measured "5 empty responses on one model" loop stays
3664
+ // impossible — while a pair that just finished real steps never has
3665
+ // them thrown away by a single empty reply.
3666
+ {
3667
+ // The env dial stays authoritative when set (tests, headless
3668
+ // gateways, metered connections) — it is an explicit configuration
3669
+ // decision, and the attempt COUNTS in the parity/E2E contracts are
3670
+ // derived from it.
3671
+ transientDelaysMs: transientRetryScheduleOverridden()
3672
+ ? undefined
3673
+ : patience.transientDelaysMs,
3674
+ emptyResponseDelaysMs: patience.emptyResponseDelaysMs,
3675
+ });
3035
3676
  }
3036
3677
  catch (err) {
3037
3678
  // P4 — the model this turn STARTED on failed. Recorded with a human
3038
3679
  // phrase (never the raw provider text, which is how a JSON quota dump
3039
3680
  // once reached a user verbatim) so the exhaustion report can name it.
3040
3681
  const primaryKind = classifyFallbackError(err);
3682
+ // The pair that FAILED is the one the attempt named — `session.model`
3683
+ // still holds the pair the turn RAN on (see `tryGenerate`).
3041
3684
  this.recordTurnAttempt({
3042
- provider: session.type,
3043
- model: session.model ?? 'default',
3685
+ provider: this.lastAttempt?.provider ?? session.type,
3686
+ model: this.lastAttempt?.model ?? session.model ?? 'default',
3044
3687
  kind: primaryKind,
3045
3688
  reason: describeFailureKind(primaryKind),
3046
3689
  });
3690
+ // ── LEARN the failure, don't just survive it ───────────────────────────
3691
+ // A failed attempt was recorded in an in-memory per-turn list only, so
3692
+ // the registry's health score, the quota timeline and the shared
3693
+ // breaker all stayed untouched: the model kept its `verified` badge and
3694
+ // was re-picked on the next turn, and a degraded turn left no on-disk
3695
+ // evidence of WHICH candidate failed or WHY. `recordAutoProviderFailure`
3696
+ // runs the full shared composition (model-scoped session exclusion,
3697
+ // registry write-through, quota-timeline event, shared breaker) exactly
3698
+ // as the empty-response handoff already does.
3699
+ try {
3700
+ this.recordAutoProviderFailure(this.lastAttempt?.provider ?? session.type, err, this.lastAttempt?.model ?? session.model);
3701
+ }
3702
+ catch {
3703
+ // Best-effort — bookkeeping must never break the turn.
3704
+ }
3047
3705
  // Auto mode: fail over across the ranked candidates (never stuck).
3048
3706
  if (mode.auto) {
3049
3707
  const firstType = session.type;
3050
3708
  // Observable telemetry, identical to the shared single-shot runner
3051
3709
  // (the E2E repair-failover contract): one warn per walk, one
3052
- // success log per landed candidate.
3053
- logger.warn(` ⚠️ ${session.provider.name} failed — trying the next auto candidate...`);
3710
+ // success log per landed candidate — and now the failure KIND in the
3711
+ // line, so the console says what went wrong instead of only that it did.
3712
+ logger.warn(` ⚠️ ${session.provider.name} failed (${describeFailureKind(primaryKind)}) — trying the next auto candidate...`);
3054
3713
  const failed = new Set([session.type]);
3055
3714
  // Intent-aware escalation for the FAILOVER walk (the live junk path):
3056
3715
  // a mid-turn failure used to re-route on the bare message alone, so a
@@ -3128,6 +3787,21 @@ export class ChatCommand extends BaseCommand {
3128
3787
  kind: nextKind,
3129
3788
  reason: describeFailureKind(nextKind),
3130
3789
  });
3790
+ // ── DIAGNOSABILITY: every failed candidate gets a reason LINE and
3791
+ // a REGISTRY write, not only an in-memory entry. A live turn
3792
+ // walked gemini → openrouter → groq → local and left NO on-disk
3793
+ // record of why the first three died — the walk was undebuggable
3794
+ // and those candidates never lost health, so the router kept
3795
+ // offering them. `recordAutoProviderFailure` writes the pair's
3796
+ // own row (and, for a rate-limit, parks it for the real reset
3797
+ // window) while leaving a healthy provider's siblings routable.
3798
+ try {
3799
+ this.recordAutoProviderFailure(next.type, nextErr, next.model);
3800
+ }
3801
+ catch {
3802
+ // Best-effort — bookkeeping must never break the turn.
3803
+ }
3804
+ logger.warn(` ⚠️ ${next.type}/${next.model ?? 'default'} failed (${describeFailureKind(nextKind)}) — trying the next auto candidate...`);
3131
3805
  // Next candidate.
3132
3806
  }
3133
3807
  }
@@ -3169,7 +3843,47 @@ export class ChatCommand extends BaseCommand {
3169
3843
  // the walk now installs what it serves (see below), and the warning
3170
3844
  // must still name the provider that actually failed.
3171
3845
  const pinnedType = session.type;
3172
- for (const fbType of ordered) {
3846
+ /**
3847
+ * The model the USER pinned — for the same-class rule below.
3848
+ */
3849
+ const pinnedModel = this.lastAttempt?.model ?? session.model;
3850
+ /**
3851
+ * PINNED + AUTO-FALLBACK: providers that can hold the PIN'S
3852
+ * CAPABILITY CLASS are tried FIRST, and only then the ones that can
3853
+ * only step down.
3854
+ *
3855
+ * WHY (the user's own rule): "if a strong model is selected then only
3856
+ * strong should be the priority". The chain order was health/registry
3857
+ * order, so a provider whose only usable model was a small local one
3858
+ * could be reached while a provider with a same-class strong model sat
3859
+ * later in the chain — the exact silent downgrade this ordering
3860
+ * prevents. `crossClass` is what keeps it honest: when nothing in the
3861
+ * pin's class can serve, the step-down still happens, but it is named
3862
+ * in the log AND in the answer (see `routingNotice`).
3863
+ */
3864
+ const sameClassFirst = [];
3865
+ const crossClass = [];
3866
+ for (const t of ordered) {
3867
+ if (t === session.type)
3868
+ continue;
3869
+ let stepsDown = true;
3870
+ try {
3871
+ const r = resolveProvider(this.configManager, t);
3872
+ const base = resolveEffectiveModel(r.type, undefined);
3873
+ const pick = sameClassFallback(pinnedModel, [
3874
+ ...(base ? [base] : []),
3875
+ ...preferredModelsFor(r.type),
3876
+ ]);
3877
+ stepsDown = pick?.crossClass ?? true;
3878
+ }
3879
+ catch {
3880
+ // A provider we cannot describe cannot be promised a same-class
3881
+ // model — it waits for the second pass rather than being dropped.
3882
+ stepsDown = true;
3883
+ }
3884
+ (stepsDown ? crossClass : sameClassFirst).push(t);
3885
+ }
3886
+ for (const fbType of [...sameClassFirst, ...crossClass]) {
3173
3887
  if (fbType === session.type)
3174
3888
  continue;
3175
3889
  // ADMIN POLICY: never fall back to a provider the policy rules
@@ -3193,6 +3907,10 @@ export class ChatCommand extends BaseCommand {
3193
3907
  // gate `loop-executor`'s pinned pool applies.
3194
3908
  if (!hasCredentials(this.configManager, fbType))
3195
3909
  continue;
3910
+ // The model THIS candidate actually used — hoisted so the failure
3911
+ // handler below can name the pair without relying on block-scoped
3912
+ // bindings from the `try` body.
3913
+ let fbAttemptedModel;
3196
3914
  try {
3197
3915
  const resolved = resolveProvider(this.configManager, fbType);
3198
3916
  // The fallback provider gets ITS OWN model, not the primary's
@@ -3206,7 +3924,25 @@ export class ChatCommand extends BaseCommand {
3206
3924
  // the dashboard console and the gateway chat engine - looked
3207
3925
  // dead.) Undefined = that provider's configured/adapter
3208
3926
  // default, which tryGenerate resolves per attempt.
3209
- const fallbackModel = resolveEffectiveModel(resolved.type, undefined);
3927
+ /**
3928
+ * The fallback provider gets a model from the PIN'S CLASS when
3929
+ * it has one — never merely its healthiest adapter default, which
3930
+ * is how a strong pin could be answered by a weak one (see
3931
+ * `sameClassFallback`). A class step-down is LOGGED, so a
3932
+ * reduced-quality handoff is never silent.
3933
+ */
3934
+ const baseModel = resolveEffectiveModel(resolved.type, undefined);
3935
+ const pick = sameClassFallback(pinnedModel, [
3936
+ ...(baseModel ? [baseModel] : []),
3937
+ ...preferredModelsFor(resolved.type),
3938
+ ]);
3939
+ if (pick?.crossClass) {
3940
+ logger.warn(` ⚠️ no ${capabilityClassOf(pinnedModel)}-class model available to take over — ` +
3941
+ `${resolved.type}/${pick.model} (${capabilityClassOf(pick.model)} class) is standing in for ` +
3942
+ `${pinnedType}/${pinnedModel ?? 'default'}.`);
3943
+ }
3944
+ const fallbackModel = pick?.model ?? baseModel;
3945
+ fbAttemptedModel = fallbackModel;
3210
3946
  const resp = await tryGenerate(resolved.provider, resolved.type, fallbackModel);
3211
3947
  // ── The pair that ANSWERED is the pair the turn ran on ───────
3212
3948
  // This walk used to serve the fallback and leave `session` still
@@ -3241,7 +3977,27 @@ export class ChatCommand extends BaseCommand {
3241
3977
  logger.warn(` 🔀 ${pinnedType} was unavailable — answered from ${resolved.provider.name} (${fallbackModel ?? 'default'})`);
3242
3978
  return resp;
3243
3979
  }
3244
- catch {
3980
+ catch (fbErr) {
3981
+ // DIAGNOSABILITY (pinned walk): name and LEARN each failed
3982
+ // fallback candidate instead of swallowing it. The silent catch
3983
+ // meant a pinned turn that stepped through three dead providers
3984
+ // reported only the primary error, and none of those providers
3985
+ // lost health — the same undebuggable-walk defect the auto walk
3986
+ // had.
3987
+ const fbKind = classifyFallbackError(fbErr);
3988
+ this.recordTurnAttempt({
3989
+ provider: fbType,
3990
+ model: fbAttemptedModel ?? 'default',
3991
+ kind: fbKind,
3992
+ reason: describeFailureKind(fbKind),
3993
+ });
3994
+ try {
3995
+ this.recordAutoProviderFailure(fbType, fbErr, fbAttemptedModel);
3996
+ }
3997
+ catch {
3998
+ // Best-effort — bookkeeping must never break the turn.
3999
+ }
4000
+ logger.warn(` ⚠️ ${fbType}/${fbAttemptedModel ?? 'default'} failed (${describeFailureKind(fbKind)}) — trying the next fallback...`);
3245
4001
  // Next fallback candidate.
3246
4002
  }
3247
4003
  }
@@ -3262,6 +4018,21 @@ export class ChatCommand extends BaseCommand {
3262
4018
  .join('; ')}`);
3263
4019
  }
3264
4020
  }
4021
+ else if (strictModelMode()) {
4022
+ /**
4023
+ * PINNED + STRICT — wait on the SAME model, then ASK. Never substitute:
4024
+ * that is the whole meaning of the pin. See `pinnedStrictEscalation`.
4025
+ */
4026
+ const decided = await this.pinnedStrictEscalation({
4027
+ session,
4028
+ err,
4029
+ tryGenerate,
4030
+ ...(askUser ? { askUser } : {}),
4031
+ signal: abort,
4032
+ });
4033
+ if (decided)
4034
+ return decided;
4035
+ }
3265
4036
  // Answer-quality resilience: every candidate failed (or none was
3266
4037
  // tried) and the error carries the model's raw confused reply —
3267
4038
  // deliver THAT instead of failing the whole turn. A confusing answer