agent-nuvira 3.5.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.
- package/README.md +13 -13
- package/dist/agent-sdk/src/scaffold.d.ts +1 -1
- package/dist/agent-sdk/src/scaffold.js +1 -1
- package/dist/cli/chat.d.ts +117 -6
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +344 -20
- package/dist/cli/chat.js.map +1 -1
- package/dist/cli/duties.d.ts +11 -0
- package/dist/cli/duties.d.ts.map +1 -1
- package/dist/cli/duties.js +31 -0
- package/dist/cli/duties.js.map +1 -1
- package/dist/cli/models.d.ts.map +1 -1
- package/dist/cli/models.js +55 -0
- package/dist/cli/models.js.map +1 -1
- package/dist/learning/auto-router.d.ts +28 -0
- package/dist/learning/auto-router.d.ts.map +1 -1
- package/dist/learning/auto-router.js +84 -5
- package/dist/learning/auto-router.js.map +1 -1
- package/dist/learning/duty-lease.d.ts +163 -0
- package/dist/learning/duty-lease.d.ts.map +1 -0
- package/dist/learning/duty-lease.js +301 -0
- package/dist/learning/duty-lease.js.map +1 -0
- package/dist/learning/model-first-router.d.ts +14 -0
- package/dist/learning/model-first-router.d.ts.map +1 -1
- package/dist/learning/model-first-router.js +54 -17
- package/dist/learning/model-first-router.js.map +1 -1
- package/dist/learning/model-warmup.d.ts +92 -0
- package/dist/learning/model-warmup.d.ts.map +1 -1
- package/dist/learning/model-warmup.js +137 -2
- package/dist/learning/model-warmup.js.map +1 -1
- package/dist/learning/retry-patience.d.ts +139 -0
- package/dist/learning/retry-patience.d.ts.map +1 -0
- package/dist/learning/retry-patience.js +169 -0
- package/dist/learning/retry-patience.js.map +1 -0
- package/dist/tools/tool-loop.d.ts +41 -3
- package/dist/tools/tool-loop.d.ts.map +1 -1
- package/dist/tools/tool-loop.js +260 -4
- package/dist/tools/tool-loop.js.map +1 -1
- package/package.json +4 -1
- package/src/web-dashboard/public/assets/{index-yky0dw5e.js → index-Bik91KJh.js} +2 -2
- package/src/web-dashboard/public/assets/{index-yky0dw5e.js.map → index-Bik91KJh.js.map} +1 -1
- package/src/web-dashboard/public/index.html +1 -1
package/dist/cli/chat.js
CHANGED
|
@@ -62,8 +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, preferredModelsFor } from '../learning/model-selection.js';
|
|
65
|
+
import { resolveAdapterDefault, hasCredentials, preferredModelsFor, rankAvailableProviders } from '../learning/model-selection.js';
|
|
66
66
|
import { sameClassFallback, capabilityClassOf } from '../learning/model-capability.js';
|
|
67
|
+
import { planBlipPatience, EMPTY_PATIENCE_MS, TRANSIENT_BLIP_DELAYS_MS, } from '../learning/retry-patience.js';
|
|
67
68
|
import { buildLoopProjectContext } from '../tools/loop-project-context.js';
|
|
68
69
|
import { sweepTransientFailures, collectionRevivalStore } from '../learning/provider-revival.js';
|
|
69
70
|
import { analyzeComplexity } from '../learning/hybrid-router.js';
|
|
@@ -261,12 +262,208 @@ export async function runDeveloperMode(goal, configManager, options) {
|
|
|
261
262
|
* caller, never to skip the loop).
|
|
262
263
|
*/
|
|
263
264
|
/**
|
|
264
|
-
* Backoff schedule for a SAME-provider retry on a
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
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.
|
|
268
331
|
*/
|
|
269
|
-
export
|
|
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.
|
|
425
|
+
*/
|
|
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
|
+
}
|
|
270
467
|
/**
|
|
271
468
|
* How long a PINNED + STRICT pair is given to answer before the user is asked
|
|
272
469
|
* what to do next (ms).
|
|
@@ -333,18 +530,48 @@ const PINNED_DEFINITIVE_ERROR_RE = /(?:does not exist|not found|no such model|in
|
|
|
333
530
|
*
|
|
334
531
|
* Never retries: non-transient classes (auth, rate-limit, model/harness faults),
|
|
335
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.
|
|
336
544
|
*/
|
|
337
|
-
export async function generateWithTransientRetry(attempt, signal, onRetry) {
|
|
338
|
-
|
|
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 (;;) {
|
|
339
551
|
try {
|
|
340
552
|
return await attempt();
|
|
341
553
|
}
|
|
342
554
|
catch (err) {
|
|
343
|
-
|
|
344
|
-
|
|
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)
|
|
345
574
|
throw err;
|
|
346
|
-
onRetry?.(i + 1, err);
|
|
347
|
-
await new Promise((resolve) => setTimeout(resolve, TRANSIENT_RETRY_DELAYS_MS[i]));
|
|
348
575
|
}
|
|
349
576
|
}
|
|
350
577
|
}
|
|
@@ -3398,13 +3625,54 @@ export class ChatCommand extends BaseCommand {
|
|
|
3398
3625
|
// R2 — this IS the fallback transport, so say so: the same fact the
|
|
3399
3626
|
// subagent child announces, and what makes a chat turn comparable with it.
|
|
3400
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',
|
|
3401
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
|
+
});
|
|
3652
|
+
try {
|
|
3653
|
+
logger.debug(`[patience] ${patience.reason}`);
|
|
3654
|
+
}
|
|
3655
|
+
catch {
|
|
3656
|
+
// Logging must never break a turn.
|
|
3657
|
+
}
|
|
3402
3658
|
try {
|
|
3403
|
-
|
|
3404
|
-
//
|
|
3405
|
-
//
|
|
3406
|
-
//
|
|
3407
|
-
|
|
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
|
+
});
|
|
3408
3676
|
}
|
|
3409
3677
|
catch (err) {
|
|
3410
3678
|
// P4 — the model this turn STARTED on failed. Recorded with a human
|
|
@@ -3419,13 +3687,29 @@ export class ChatCommand extends BaseCommand {
|
|
|
3419
3687
|
kind: primaryKind,
|
|
3420
3688
|
reason: describeFailureKind(primaryKind),
|
|
3421
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
|
+
}
|
|
3422
3705
|
// Auto mode: fail over across the ranked candidates (never stuck).
|
|
3423
3706
|
if (mode.auto) {
|
|
3424
3707
|
const firstType = session.type;
|
|
3425
3708
|
// Observable telemetry, identical to the shared single-shot runner
|
|
3426
3709
|
// (the E2E repair-failover contract): one warn per walk, one
|
|
3427
|
-
// success log per landed candidate
|
|
3428
|
-
|
|
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...`);
|
|
3429
3713
|
const failed = new Set([session.type]);
|
|
3430
3714
|
// Intent-aware escalation for the FAILOVER walk (the live junk path):
|
|
3431
3715
|
// a mid-turn failure used to re-route on the bare message alone, so a
|
|
@@ -3503,6 +3787,21 @@ export class ChatCommand extends BaseCommand {
|
|
|
3503
3787
|
kind: nextKind,
|
|
3504
3788
|
reason: describeFailureKind(nextKind),
|
|
3505
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...`);
|
|
3506
3805
|
// Next candidate.
|
|
3507
3806
|
}
|
|
3508
3807
|
}
|
|
@@ -3608,6 +3907,10 @@ export class ChatCommand extends BaseCommand {
|
|
|
3608
3907
|
// gate `loop-executor`'s pinned pool applies.
|
|
3609
3908
|
if (!hasCredentials(this.configManager, fbType))
|
|
3610
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;
|
|
3611
3914
|
try {
|
|
3612
3915
|
const resolved = resolveProvider(this.configManager, fbType);
|
|
3613
3916
|
// The fallback provider gets ITS OWN model, not the primary's
|
|
@@ -3639,6 +3942,7 @@ export class ChatCommand extends BaseCommand {
|
|
|
3639
3942
|
`${pinnedType}/${pinnedModel ?? 'default'}.`);
|
|
3640
3943
|
}
|
|
3641
3944
|
const fallbackModel = pick?.model ?? baseModel;
|
|
3945
|
+
fbAttemptedModel = fallbackModel;
|
|
3642
3946
|
const resp = await tryGenerate(resolved.provider, resolved.type, fallbackModel);
|
|
3643
3947
|
// ── The pair that ANSWERED is the pair the turn ran on ───────
|
|
3644
3948
|
// This walk used to serve the fallback and leave `session` still
|
|
@@ -3673,7 +3977,27 @@ export class ChatCommand extends BaseCommand {
|
|
|
3673
3977
|
logger.warn(` 🔀 ${pinnedType} was unavailable — answered from ${resolved.provider.name} (${fallbackModel ?? 'default'})`);
|
|
3674
3978
|
return resp;
|
|
3675
3979
|
}
|
|
3676
|
-
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...`);
|
|
3677
4001
|
// Next fallback candidate.
|
|
3678
4002
|
}
|
|
3679
4003
|
}
|