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.
- package/README.md +13 -13
- package/dist/agent-sdk/src/register.d.ts +23 -7
- package/dist/agent-sdk/src/register.d.ts.map +1 -1
- package/dist/agent-sdk/src/register.js +159 -5
- package/dist/agent-sdk/src/register.js.map +1 -1
- package/dist/agent-sdk/src/scaffold.d.ts +1 -1
- package/dist/agent-sdk/src/scaffold.js +1 -1
- package/dist/cli/agent.d.ts.map +1 -1
- package/dist/cli/agent.js +16 -3
- package/dist/cli/agent.js.map +1 -1
- package/dist/cli/chat.d.ts +202 -6
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +805 -34
- package/dist/cli/chat.js.map +1 -1
- package/dist/cli/config.d.ts +10 -0
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +39 -0
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +18 -3
- package/dist/cli/doctor.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/mcp.d.ts +50 -0
- package/dist/cli/mcp.d.ts.map +1 -1
- package/dist/cli/mcp.js +496 -49
- package/dist/cli/mcp.js.map +1 -1
- package/dist/cli/memory.d.ts.map +1 -1
- package/dist/cli/memory.js +3 -2
- package/dist/cli/memory.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/cli/plugins.d.ts.map +1 -1
- package/dist/cli/plugins.js +26 -9
- package/dist/cli/plugins.js.map +1 -1
- package/dist/cli/sdk.d.ts.map +1 -1
- package/dist/cli/sdk.js +10 -3
- package/dist/cli/sdk.js.map +1 -1
- package/dist/cli/tool-install-prompt.d.ts.map +1 -1
- package/dist/cli/tool-install-prompt.js +23 -1
- package/dist/cli/tool-install-prompt.js.map +1 -1
- package/dist/cli/tools.d.ts +16 -0
- package/dist/cli/tools.d.ts.map +1 -1
- package/dist/cli/tools.js +101 -1
- package/dist/cli/tools.js.map +1 -1
- package/dist/config/manager.d.ts +6 -0
- package/dist/config/manager.d.ts.map +1 -1
- package/dist/config/manager.js +66 -0
- package/dist/config/manager.js.map +1 -1
- package/dist/config/types.d.ts +18 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.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/exhaustion-report.d.ts +12 -0
- package/dist/learning/exhaustion-report.d.ts.map +1 -1
- package/dist/learning/exhaustion-report.js +3 -1
- package/dist/learning/exhaustion-report.js.map +1 -1
- package/dist/learning/model-capability.d.ts +33 -0
- package/dist/learning/model-capability.d.ts.map +1 -1
- package/dist/learning/model-capability.js +51 -0
- package/dist/learning/model-capability.js.map +1 -1
- package/dist/learning/model-first-router.d.ts +21 -22
- package/dist/learning/model-first-router.d.ts.map +1 -1
- package/dist/learning/model-first-router.js +61 -54
- package/dist/learning/model-first-router.js.map +1 -1
- package/dist/learning/model-selection.d.ts +27 -0
- package/dist/learning/model-selection.d.ts.map +1 -1
- package/dist/learning/model-selection.js +46 -0
- package/dist/learning/model-selection.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/reasoning-trace.d.ts +1 -1
- package/dist/learning/reasoning-trace.d.ts.map +1 -1
- package/dist/learning/reasoning-trace.js.map +1 -1
- package/dist/learning/resilient-call.d.ts +15 -1
- package/dist/learning/resilient-call.d.ts.map +1 -1
- package/dist/learning/resilient-call.js +38 -22
- package/dist/learning/resilient-call.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/learning/working-state.d.ts +7 -0
- package/dist/learning/working-state.d.ts.map +1 -1
- package/dist/learning/working-state.js +23 -1
- package/dist/learning/working-state.js.map +1 -1
- package/dist/mcp/catalog.d.ts +206 -26
- package/dist/mcp/catalog.d.ts.map +1 -1
- package/dist/mcp/catalog.js +664 -52
- package/dist/mcp/catalog.js.map +1 -1
- package/dist/mcp/client.d.ts +6 -0
- package/dist/mcp/client.d.ts.map +1 -1
- package/dist/mcp/client.js +35 -5
- package/dist/mcp/client.js.map +1 -1
- package/dist/mcp/custom-server.d.ts +120 -0
- package/dist/mcp/custom-server.d.ts.map +1 -0
- package/dist/mcp/custom-server.js +330 -0
- package/dist/mcp/custom-server.js.map +1 -0
- package/dist/mcp/manager.d.ts +9 -1
- package/dist/mcp/manager.d.ts.map +1 -1
- package/dist/mcp/manager.js +15 -2
- package/dist/mcp/manager.js.map +1 -1
- package/dist/mcp/mcp-oauth.d.ts +28 -0
- package/dist/mcp/mcp-oauth.d.ts.map +1 -1
- package/dist/mcp/mcp-oauth.js +63 -8
- package/dist/mcp/mcp-oauth.js.map +1 -1
- package/dist/mcp/oauth-authorize.d.ts +51 -0
- package/dist/mcp/oauth-authorize.d.ts.map +1 -0
- package/dist/mcp/oauth-authorize.js +133 -0
- package/dist/mcp/oauth-authorize.js.map +1 -0
- package/dist/mcp/oauth-connect.d.ts +53 -0
- package/dist/mcp/oauth-connect.d.ts.map +1 -0
- package/dist/mcp/oauth-connect.js +103 -0
- package/dist/mcp/oauth-connect.js.map +1 -0
- package/dist/mcp/pin-verifier.d.ts +44 -0
- package/dist/mcp/pin-verifier.d.ts.map +1 -0
- package/dist/mcp/pin-verifier.js +102 -0
- package/dist/mcp/pin-verifier.js.map +1 -0
- package/dist/mcp/prereqs.d.ts +145 -0
- package/dist/mcp/prereqs.d.ts.map +1 -0
- package/dist/mcp/prereqs.js +245 -0
- package/dist/mcp/prereqs.js.map +1 -0
- package/dist/mcp/runtime.d.ts +59 -0
- package/dist/mcp/runtime.d.ts.map +1 -0
- package/dist/mcp/runtime.js +77 -0
- package/dist/mcp/runtime.js.map +1 -0
- package/dist/mcp/server.d.ts +21 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +29 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/settings.d.ts +164 -0
- package/dist/mcp/settings.d.ts.map +1 -0
- package/dist/mcp/settings.js +358 -0
- package/dist/mcp/settings.js.map +1 -0
- package/dist/mcp/types.d.ts +62 -0
- package/dist/mcp/types.d.ts.map +1 -1
- package/dist/mcp/types.js.map +1 -1
- package/dist/memory/vector-store.d.ts +12 -0
- package/dist/memory/vector-store.d.ts.map +1 -1
- package/dist/memory/vector-store.js +23 -0
- package/dist/memory/vector-store.js.map +1 -1
- package/dist/plugins/agent-plugin.d.ts +35 -0
- package/dist/plugins/agent-plugin.d.ts.map +1 -1
- package/dist/plugins/agent-plugin.js +142 -24
- package/dist/plugins/agent-plugin.js.map +1 -1
- package/dist/sandbox/manager.d.ts +12 -0
- package/dist/sandbox/manager.d.ts.map +1 -1
- package/dist/sandbox/manager.js +43 -2
- package/dist/sandbox/manager.js.map +1 -1
- package/dist/tools/modality/browser.d.ts +39 -3
- package/dist/tools/modality/browser.d.ts.map +1 -1
- package/dist/tools/modality/browser.js +113 -16
- package/dist/tools/modality/browser.js.map +1 -1
- package/dist/tools/optional-deps.d.ts +48 -0
- package/dist/tools/optional-deps.d.ts.map +1 -0
- package/dist/tools/optional-deps.js +42 -0
- package/dist/tools/optional-deps.js.map +1 -0
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +68 -11
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/remediation-ladder.d.ts.map +1 -1
- package/dist/tools/remediation-ladder.js +9 -1
- package/dist/tools/remediation-ladder.js.map +1 -1
- 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 +317 -9
- package/dist/tools/tool-loop.js.map +1 -1
- package/dist/web-dashboard/chat-retry.d.ts +11 -1
- package/dist/web-dashboard/chat-retry.d.ts.map +1 -1
- package/dist/web-dashboard/chat-retry.js +18 -4
- package/dist/web-dashboard/chat-retry.js.map +1 -1
- package/dist/web-dashboard/server.d.ts.map +1 -1
- package/dist/web-dashboard/server.js +349 -2
- package/dist/web-dashboard/server.js.map +1 -1
- package/package.json +6 -2
- package/src/web-dashboard/public/assets/{index-DeD3L0kG.js → index-Bik91KJh.js} +115 -89
- package/src/web-dashboard/public/assets/index-Bik91KJh.js.map +1 -0
- package/src/web-dashboard/public/index.html +1 -1
- 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
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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
|
|
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
|
-
//
|
|
2911
|
-
//
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
//
|
|
2915
|
-
//
|
|
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
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
//
|
|
3034
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|