agent-nuvira 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/dist/agent-sdk/src/register.d.ts +23 -7
  2. package/dist/agent-sdk/src/register.d.ts.map +1 -1
  3. package/dist/agent-sdk/src/register.js +159 -5
  4. package/dist/agent-sdk/src/register.js.map +1 -1
  5. package/dist/agent-sdk/src/scaffold.d.ts +1 -1
  6. package/dist/agent-sdk/src/scaffold.js +1 -1
  7. package/dist/cli/agent.d.ts.map +1 -1
  8. package/dist/cli/agent.js +16 -3
  9. package/dist/cli/agent.js.map +1 -1
  10. package/dist/cli/chat.d.ts +85 -0
  11. package/dist/cli/chat.d.ts.map +1 -1
  12. package/dist/cli/chat.js +462 -15
  13. package/dist/cli/chat.js.map +1 -1
  14. package/dist/cli/config.d.ts +10 -0
  15. package/dist/cli/config.d.ts.map +1 -1
  16. package/dist/cli/config.js +39 -0
  17. package/dist/cli/config.js.map +1 -1
  18. package/dist/cli/doctor.d.ts.map +1 -1
  19. package/dist/cli/doctor.js +18 -3
  20. package/dist/cli/doctor.js.map +1 -1
  21. package/dist/cli/mcp.d.ts +50 -0
  22. package/dist/cli/mcp.d.ts.map +1 -1
  23. package/dist/cli/mcp.js +496 -49
  24. package/dist/cli/mcp.js.map +1 -1
  25. package/dist/cli/memory.d.ts.map +1 -1
  26. package/dist/cli/memory.js +3 -2
  27. package/dist/cli/memory.js.map +1 -1
  28. package/dist/cli/plugins.d.ts.map +1 -1
  29. package/dist/cli/plugins.js +26 -9
  30. package/dist/cli/plugins.js.map +1 -1
  31. package/dist/cli/sdk.d.ts.map +1 -1
  32. package/dist/cli/sdk.js +10 -3
  33. package/dist/cli/sdk.js.map +1 -1
  34. package/dist/cli/tool-install-prompt.d.ts.map +1 -1
  35. package/dist/cli/tool-install-prompt.js +23 -1
  36. package/dist/cli/tool-install-prompt.js.map +1 -1
  37. package/dist/cli/tools.d.ts +16 -0
  38. package/dist/cli/tools.d.ts.map +1 -1
  39. package/dist/cli/tools.js +101 -1
  40. package/dist/cli/tools.js.map +1 -1
  41. package/dist/config/manager.d.ts +6 -0
  42. package/dist/config/manager.d.ts.map +1 -1
  43. package/dist/config/manager.js +66 -0
  44. package/dist/config/manager.js.map +1 -1
  45. package/dist/config/types.d.ts +18 -0
  46. package/dist/config/types.d.ts.map +1 -1
  47. package/dist/index.d.ts +2 -2
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +4 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/learning/exhaustion-report.d.ts +12 -0
  52. package/dist/learning/exhaustion-report.d.ts.map +1 -1
  53. package/dist/learning/exhaustion-report.js +3 -1
  54. package/dist/learning/exhaustion-report.js.map +1 -1
  55. package/dist/learning/model-capability.d.ts +33 -0
  56. package/dist/learning/model-capability.d.ts.map +1 -1
  57. package/dist/learning/model-capability.js +51 -0
  58. package/dist/learning/model-capability.js.map +1 -1
  59. package/dist/learning/model-first-router.d.ts +7 -22
  60. package/dist/learning/model-first-router.d.ts.map +1 -1
  61. package/dist/learning/model-first-router.js +7 -37
  62. package/dist/learning/model-first-router.js.map +1 -1
  63. package/dist/learning/model-selection.d.ts +27 -0
  64. package/dist/learning/model-selection.d.ts.map +1 -1
  65. package/dist/learning/model-selection.js +46 -0
  66. package/dist/learning/model-selection.js.map +1 -1
  67. package/dist/learning/reasoning-trace.d.ts +1 -1
  68. package/dist/learning/reasoning-trace.d.ts.map +1 -1
  69. package/dist/learning/reasoning-trace.js.map +1 -1
  70. package/dist/learning/resilient-call.d.ts +15 -1
  71. package/dist/learning/resilient-call.d.ts.map +1 -1
  72. package/dist/learning/resilient-call.js +38 -22
  73. package/dist/learning/resilient-call.js.map +1 -1
  74. package/dist/learning/working-state.d.ts +7 -0
  75. package/dist/learning/working-state.d.ts.map +1 -1
  76. package/dist/learning/working-state.js +23 -1
  77. package/dist/learning/working-state.js.map +1 -1
  78. package/dist/mcp/catalog.d.ts +206 -26
  79. package/dist/mcp/catalog.d.ts.map +1 -1
  80. package/dist/mcp/catalog.js +664 -52
  81. package/dist/mcp/catalog.js.map +1 -1
  82. package/dist/mcp/client.d.ts +6 -0
  83. package/dist/mcp/client.d.ts.map +1 -1
  84. package/dist/mcp/client.js +35 -5
  85. package/dist/mcp/client.js.map +1 -1
  86. package/dist/mcp/custom-server.d.ts +120 -0
  87. package/dist/mcp/custom-server.d.ts.map +1 -0
  88. package/dist/mcp/custom-server.js +330 -0
  89. package/dist/mcp/custom-server.js.map +1 -0
  90. package/dist/mcp/manager.d.ts +9 -1
  91. package/dist/mcp/manager.d.ts.map +1 -1
  92. package/dist/mcp/manager.js +15 -2
  93. package/dist/mcp/manager.js.map +1 -1
  94. package/dist/mcp/mcp-oauth.d.ts +28 -0
  95. package/dist/mcp/mcp-oauth.d.ts.map +1 -1
  96. package/dist/mcp/mcp-oauth.js +63 -8
  97. package/dist/mcp/mcp-oauth.js.map +1 -1
  98. package/dist/mcp/oauth-authorize.d.ts +51 -0
  99. package/dist/mcp/oauth-authorize.d.ts.map +1 -0
  100. package/dist/mcp/oauth-authorize.js +133 -0
  101. package/dist/mcp/oauth-authorize.js.map +1 -0
  102. package/dist/mcp/oauth-connect.d.ts +53 -0
  103. package/dist/mcp/oauth-connect.d.ts.map +1 -0
  104. package/dist/mcp/oauth-connect.js +103 -0
  105. package/dist/mcp/oauth-connect.js.map +1 -0
  106. package/dist/mcp/pin-verifier.d.ts +44 -0
  107. package/dist/mcp/pin-verifier.d.ts.map +1 -0
  108. package/dist/mcp/pin-verifier.js +102 -0
  109. package/dist/mcp/pin-verifier.js.map +1 -0
  110. package/dist/mcp/prereqs.d.ts +145 -0
  111. package/dist/mcp/prereqs.d.ts.map +1 -0
  112. package/dist/mcp/prereqs.js +245 -0
  113. package/dist/mcp/prereqs.js.map +1 -0
  114. package/dist/mcp/runtime.d.ts +59 -0
  115. package/dist/mcp/runtime.d.ts.map +1 -0
  116. package/dist/mcp/runtime.js +77 -0
  117. package/dist/mcp/runtime.js.map +1 -0
  118. package/dist/mcp/server.d.ts +21 -0
  119. package/dist/mcp/server.d.ts.map +1 -1
  120. package/dist/mcp/server.js +29 -1
  121. package/dist/mcp/server.js.map +1 -1
  122. package/dist/mcp/settings.d.ts +164 -0
  123. package/dist/mcp/settings.d.ts.map +1 -0
  124. package/dist/mcp/settings.js +358 -0
  125. package/dist/mcp/settings.js.map +1 -0
  126. package/dist/mcp/types.d.ts +62 -0
  127. package/dist/mcp/types.d.ts.map +1 -1
  128. package/dist/mcp/types.js.map +1 -1
  129. package/dist/memory/vector-store.d.ts +12 -0
  130. package/dist/memory/vector-store.d.ts.map +1 -1
  131. package/dist/memory/vector-store.js +23 -0
  132. package/dist/memory/vector-store.js.map +1 -1
  133. package/dist/plugins/agent-plugin.d.ts +35 -0
  134. package/dist/plugins/agent-plugin.d.ts.map +1 -1
  135. package/dist/plugins/agent-plugin.js +142 -24
  136. package/dist/plugins/agent-plugin.js.map +1 -1
  137. package/dist/sandbox/manager.d.ts +12 -0
  138. package/dist/sandbox/manager.d.ts.map +1 -1
  139. package/dist/sandbox/manager.js +43 -2
  140. package/dist/sandbox/manager.js.map +1 -1
  141. package/dist/tools/modality/browser.d.ts +39 -3
  142. package/dist/tools/modality/browser.d.ts.map +1 -1
  143. package/dist/tools/modality/browser.js +113 -16
  144. package/dist/tools/modality/browser.js.map +1 -1
  145. package/dist/tools/optional-deps.d.ts +48 -0
  146. package/dist/tools/optional-deps.d.ts.map +1 -0
  147. package/dist/tools/optional-deps.js +42 -0
  148. package/dist/tools/optional-deps.js.map +1 -0
  149. package/dist/tools/registry.d.ts.map +1 -1
  150. package/dist/tools/registry.js +68 -11
  151. package/dist/tools/registry.js.map +1 -1
  152. package/dist/tools/remediation-ladder.d.ts.map +1 -1
  153. package/dist/tools/remediation-ladder.js +9 -1
  154. package/dist/tools/remediation-ladder.js.map +1 -1
  155. package/dist/tools/tool-loop.d.ts.map +1 -1
  156. package/dist/tools/tool-loop.js +57 -5
  157. package/dist/tools/tool-loop.js.map +1 -1
  158. package/dist/web-dashboard/chat-retry.d.ts +11 -1
  159. package/dist/web-dashboard/chat-retry.d.ts.map +1 -1
  160. package/dist/web-dashboard/chat-retry.js +18 -4
  161. package/dist/web-dashboard/chat-retry.js.map +1 -1
  162. package/dist/web-dashboard/server.d.ts.map +1 -1
  163. package/dist/web-dashboard/server.js +349 -2
  164. package/dist/web-dashboard/server.js.map +1 -1
  165. package/package.json +3 -2
  166. package/src/web-dashboard/public/assets/{index-DeD3L0kG.js → index-yky0dw5e.js} +115 -89
  167. package/src/web-dashboard/public/assets/index-yky0dw5e.js.map +1 -0
  168. package/src/web-dashboard/public/index.html +1 -1
  169. 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,8 @@ 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 } from '../learning/model-selection.js';
66
+ import { sameClassFallback, capabilityClassOf } from '../learning/model-capability.js';
66
67
  import { buildLoopProjectContext } from '../tools/loop-project-context.js';
67
68
  import { sweepTransientFailures, collectionRevivalStore } from '../learning/provider-revival.js';
68
69
  import { analyzeComplexity } from '../learning/hybrid-router.js';
@@ -266,6 +267,59 @@ export async function runDeveloperMode(goal, configManager, options) {
266
267
  * to the background runners, not the interactive turn.
267
268
  */
268
269
  export const TRANSIENT_RETRY_DELAYS_MS = [1_000, 3_000];
270
+ /**
271
+ * How long a PINNED + STRICT pair is given to answer before the user is asked
272
+ * what to do next (ms).
273
+ *
274
+ * WHY A WAIT AT ALL, and why two minutes. A pinned+strict turn is an explicit
275
+ * quality contract: the user would rather wait than be answered by a model they
276
+ * did not choose. A provider having a slow moment (or an upstream batch job) is
277
+ * exactly the case that contract covers, so the SAME pair is retried inside this
278
+ * window before anything else can happen. Two minutes is the bar the user set: it
279
+ * rides out a bad minute at a shared endpoint, while a genuinely dead provider no
280
+ * longer holds the turn for the ~5 minutes that prompted the complaint. NOTHING
281
+ * is routed to another model inside this window.
282
+ */
283
+ export const PINNED_SAME_PAIR_GRACE_MS = 120_000;
284
+ /**
285
+ * Backoff between retries of the SAME pinned pair. Summed with the call time,
286
+ * these reach the {@link PINNED_SAME_PAIR_GRACE_MS} budget.
287
+ */
288
+ export const PINNED_GRACE_BACKOFF_MS = [5_000, 15_000, 30_000, 45_000];
289
+ /**
290
+ * Failure kinds a WAIT can plausibly fix.
291
+ *
292
+ * A rejected credential, a model the provider does not serve, an over-long prompt
293
+ * or a request-encoding fault are DEFINITIVE: retrying them for two minutes only
294
+ * makes the user wait longer for the same answer. A pinned+strict turn refuses
295
+ * those immediately (the honest strict stop) instead of presenting them as a
296
+ * delay.
297
+ */
298
+ const PINNED_WAITABLE_KINDS = new Set(['server', 'network', 'timeout', 'rate-limit', 'unknown']);
299
+ /**
300
+ * How long the user has to answer the pinned-failure question before the turn
301
+ * stops honestly (ms).
302
+ *
303
+ * Without a bound the turn waits on the question FOREVER: the console's
304
+ * `askQuestion` promise is deliberately never rejected (worst case the model
305
+ * proceeds on best judgment), so a client that renders the card and is then
306
+ * closed — or a headless caller that never answers — would hold the turn (and its
307
+ * `busy` slot) open indefinitely. Measured live while probing the endpoint: the
308
+ * HTTP request sat open until it was cancelled by hand. Five minutes is generous
309
+ * for a person reading the options, and the answer is a choice, not a dependency:
310
+ * stopping with the pin's own error is the honest outcome once nobody answers.
311
+ */
312
+ export const PINNED_ANSWER_TIMEOUT_MS = 300_000;
313
+ /**
314
+ * Message shapes that name a DEFINITIVE model problem — the pair does not exist,
315
+ * or the request shape is wrong. A wait cannot fix either (see
316
+ * `pinnedFailureIsWaitable`).
317
+ *
318
+ * Deliberately NOT blanket-applied: it is consulted only for a failure the
319
+ * classifier could not place (`unknown`), so a DNS/network blip whose text happens
320
+ * to say "not found" still gets its grace window.
321
+ */
322
+ 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
323
  /**
270
324
  * Run one provider attempt, retrying transient failures against the SAME
271
325
  * provider before giving up.
@@ -1907,7 +1961,10 @@ export class ChatCommand extends BaseCommand {
1907
1961
  },
1908
1962
  },
1909
1963
  };
1910
- const callModel = this.buildToolCallModel(message, session, options, mode, ctxOverrides?.onToken, ctxOverrides?.signal, continuationSoftwareText(message, history) ?? undefined);
1964
+ const callModel = this.buildToolCallModel(message, session, options, mode, ctxOverrides?.onToken, ctxOverrides?.signal, continuationSoftwareText(message, history) ?? undefined,
1965
+ // PINNED + STRICT: the escalation asks the USER when a pinned model will not
1966
+ // answer (keep trying / switch to a same-class model / name one / go auto).
1967
+ ctxOverrides?.askUser);
1911
1968
  let result;
1912
1969
  // v1.8x audit — CHAT TRACE CAPTURE: every LLM call in a chat turn is now
1913
1970
  // recorded to ~/.nuvira/memory/reasoning-traces.json (source 'chat'), so
@@ -2188,6 +2245,16 @@ export class ChatCommand extends BaseCommand {
2188
2245
  try {
2189
2246
  const chain = getProviderFallback(this.configManager, this.configManager.getAll().fallback)
2190
2247
  .getFallbackChain(session.type);
2248
+ /**
2249
+ * PINNED + AUTO-FALLBACK stays in the PIN'S CAPABILITY CLASS.
2250
+ *
2251
+ * A user who pins a strong model and leaves auto-fallback on has said
2252
+ * "keep me in this class" — they have not agreed to a small/fast model
2253
+ * because it happens to have the best health score. `sameClassFallback`
2254
+ * chooses within the class first and reports when it must step down, so
2255
+ * the handoff can SAY so instead of arriving as a silent downgrade.
2256
+ */
2257
+ const pinModel = session.model;
2191
2258
  for (const fbType of chain) {
2192
2259
  if (fbType === session.type)
2193
2260
  continue;
@@ -2197,7 +2264,16 @@ export class ChatCommand extends BaseCommand {
2197
2264
  continue;
2198
2265
  try {
2199
2266
  const resolved = resolveProvider(this.configManager, fbType);
2200
- const model = getAutoRouter().resolveModel(fbType, 'chat', this.configManager);
2267
+ const base = getAutoRouter().resolveModel(fbType, 'chat', this.configManager);
2268
+ const pick = sameClassFallback(pinModel, [
2269
+ ...(base ? [base] : []),
2270
+ ...preferredModelsFor(resolved.type),
2271
+ ]);
2272
+ const model = pick?.model ?? base;
2273
+ if (pick?.crossClass) {
2274
+ logger.warn(` ⚠️ ${pinModel ?? 'the pinned model'} is not available anywhere; handing over to ` +
2275
+ `${resolved.type}/${model} (${capabilityClassOf(model)} class — no same-class model was available).`);
2276
+ }
2201
2277
  if (install({ type: resolved.type, provider: resolved.provider, model }))
2202
2278
  return true;
2203
2279
  }
@@ -2395,6 +2471,10 @@ export class ChatCommand extends BaseCommand {
2395
2471
  ...(typeof breadth.poolSize === 'number' ? { poolSize: breadth.poolSize } : {}),
2396
2472
  ...(typeof breadth.poolProviders === 'number' ? { poolProviders: breadth.poolProviders } : {}),
2397
2473
  task: message,
2474
+ // The user's PIN, when there was one — so the report's own advice
2475
+ // respects the quality bar they set instead of inviting a downgrade
2476
+ // (see ExhaustionInput.pinnedModel).
2477
+ ...(session.model && session.model !== 'default' ? { pinnedModel: session.model } : {}),
2398
2478
  // The underlying line is kept as the REASON, so a specific,
2399
2479
  // actionable cause is never buried by the report.
2400
2480
  ...(result.generationFailed && result.content.trim() ? { cause: result.content } : {}),
@@ -2758,6 +2838,281 @@ export class ChatCommand extends BaseCommand {
2758
2838
  traceId: chatTraceId,
2759
2839
  });
2760
2840
  }
2841
+ /**
2842
+ * Providers that could TAKE OVER from a pin, split by capability class.
2843
+ *
2844
+ * Only providers with credentials are considered, and each contributes the
2845
+ * model `sameClassFallback` would choose for it — so the "switch to …" offer
2846
+ * is a pair the router would actually serve, never a name invented here.
2847
+ */
2848
+ pinAlternatives(pinModel, excludeProvider) {
2849
+ const sameClass = [];
2850
+ const crossClass = [];
2851
+ let types = [];
2852
+ try {
2853
+ const all = this.configManager.getAll();
2854
+ types = Object.keys(all.providers ?? {});
2855
+ }
2856
+ catch {
2857
+ return { sameClass, crossClass };
2858
+ }
2859
+ for (const providerType of types) {
2860
+ if (providerType === excludeProvider)
2861
+ continue;
2862
+ try {
2863
+ if (!hasCredentials(this.configManager, providerType))
2864
+ continue;
2865
+ const resolved = resolveProvider(this.configManager, providerType);
2866
+ const base = resolveAdapterDefault(resolved.type, undefined);
2867
+ const pick = sameClassFallback(pinModel, [
2868
+ ...(base ? [base] : []),
2869
+ ...preferredModelsFor(resolved.type),
2870
+ ]);
2871
+ if (!pick)
2872
+ continue;
2873
+ (pick.crossClass ? crossClass : sameClass).push({ provider: resolved.type, model: pick.model });
2874
+ }
2875
+ catch {
2876
+ // Next provider.
2877
+ }
2878
+ }
2879
+ return { sameClass, crossClass };
2880
+ }
2881
+ /**
2882
+ * Read a user-typed choice as `provider/model` (or a bare model id).
2883
+ *
2884
+ * A bare id keeps the PINNED provider — that is what "another model" means when
2885
+ * the user was asked about a pinned turn. A prefixed value is only accepted as a
2886
+ * provider when it RESOLVES to one, so a model id that merely contains a slash
2887
+ * (`openai/gpt-oss-120b`) is not mistaken for a provider name.
2888
+ */
2889
+ parsePinChoice(text, pinnedProvider) {
2890
+ const trimmed = text.trim();
2891
+ if (!trimmed)
2892
+ return null;
2893
+ const sep = trimmed.includes('/') ? '/' : trimmed.includes(':') ? ':' : null;
2894
+ if (sep) {
2895
+ const [head, ...rest] = trimmed.split(sep);
2896
+ const tail = rest.join(sep);
2897
+ if (head && tail) {
2898
+ try {
2899
+ const resolved = resolveProvider(this.configManager, head);
2900
+ return { provider: resolved.type, model: tail };
2901
+ }
2902
+ catch {
2903
+ // Not a provider name — treat the whole value as a model id below.
2904
+ }
2905
+ }
2906
+ }
2907
+ return { provider: pinnedProvider, model: trimmed };
2908
+ }
2909
+ /**
2910
+ * Serve ONE step on a pair the USER named, validated AS A PAIR.
2911
+ *
2912
+ * `resolveRoute` is asked in strict mode, so a pair the provider cannot serve is
2913
+ * REFUSED rather than silently substituted — the same guarantee every other
2914
+ * pinned call gets (issue #9: a model id sent to the wrong provider is the bug
2915
+ * class this covers). Returns null when the pair cannot be used or the call
2916
+ * fails, leaving the honest stop in place rather than switching anyway.
2917
+ */
2918
+ async serveChosenPair(input, providerType, model) {
2919
+ try {
2920
+ const resolved = resolveProvider(this.configManager, providerType);
2921
+ // Validated as a PAIR via `resolveRoute` in strict mode (see above).
2922
+ const route = await resolveRoute({
2923
+ providerType: resolved.type,
2924
+ provider: resolved.provider,
2925
+ model,
2926
+ source: 'chat',
2927
+ strict: true,
2928
+ });
2929
+ const resp = await input.tryGenerate(resolved.provider, resolved.type, route.model);
2930
+ input.session.type = resolved.type;
2931
+ input.session.provider = resolved.provider;
2932
+ input.session.model = route.model;
2933
+ logger.warn(` ✅ continuing on ${resolved.provider.name} / ${route.model} (your choice).`);
2934
+ return resp;
2935
+ }
2936
+ catch {
2937
+ return null;
2938
+ }
2939
+ }
2940
+ /**
2941
+ * PINNED + STRICT and the pin did not answer: wait on the SAME model, then let
2942
+ * the USER decide — never a silent substitution.
2943
+ *
2944
+ * WHY THIS EXISTS. The user pinned a model AND turned strict mode on. That is a
2945
+ * quality contract, and the old behaviour broke it in the one direction they
2946
+ * explicitly forbade: rather than wait, or say what was happening, a failed pin
2947
+ * either dead-ended in "the language model was unavailable" or (non-strict) was
2948
+ * handed to whatever the health ranking liked — including `local/qwen2.5:0.5b`, a
2949
+ * model that cannot call tools at all, so its "answer" was fabricated rather
2950
+ * than merely weaker. Sending that work to a weak model is not a degraded answer;
2951
+ * for an agentic turn it is a wrong one.
2952
+ *
2953
+ * So: the SAME pair is retried inside {@link PINNED_SAME_PAIR_GRACE_MS} first
2954
+ * (nothing is routed anywhere in that window), and if it still does not answer
2955
+ * the user is asked — keep trying, switch to the closest SAME-CLASS model the
2956
+ * agent can find and name, type another model/provider, or release the pin to
2957
+ * auto. The question says outright that nothing has been routed elsewhere.
2958
+ *
2959
+ * Returns a served response when the user's choice works; `null` when the turn
2960
+ * must fail honestly — headless (no one to ask: the strict refusal stands, as
2961
+ * it must, because silently choosing FOR the user is the original defect), or
2962
+ * when the chosen pair could not serve.
2963
+ */
2964
+ async pinnedStrictEscalation(input) {
2965
+ const { session } = input;
2966
+ const pin = `${session.type}/${session.model ?? 'default'}`;
2967
+ const kind = classifyFallbackError(input.err);
2968
+ // A DEFINITIVE failure is not a delay (see PINNED_WAITABLE_KINDS) — and a model
2969
+ // the provider does not serve, or a request-shape fault, is definitive even
2970
+ // though the classifier can only call it `unknown` (measured: a pin to a
2971
+ // nonexistent id spent the whole 2-minute grace window on a 400 that could
2972
+ // never change).
2973
+ if (!PINNED_WAITABLE_KINDS.has(kind))
2974
+ return null;
2975
+ if (kind === 'unknown') {
2976
+ const message = input.err instanceof Error ? input.err.message : String(input.err);
2977
+ if (isHarnessFault(input.err) || PINNED_DEFINITIVE_ERROR_RE.test(message))
2978
+ return null;
2979
+ }
2980
+ const attemptPin = async () => {
2981
+ try {
2982
+ return await input.tryGenerate(session.provider, session.type, session.model);
2983
+ }
2984
+ catch (err) {
2985
+ const k = classifyFallbackError(err);
2986
+ this.recordTurnAttempt({
2987
+ provider: this.lastAttempt?.provider ?? session.type,
2988
+ model: this.lastAttempt?.model ?? session.model ?? 'default',
2989
+ kind: k,
2990
+ reason: describeFailureKind(k),
2991
+ });
2992
+ return null;
2993
+ }
2994
+ };
2995
+ // ── 1. The SAME pair gets its grace window. Nothing is routed anywhere. ──
2996
+ let served = await attemptPin();
2997
+ if (served)
2998
+ return served;
2999
+ const startedAt = Date.now();
3000
+ for (const backoff of PINNED_GRACE_BACKOFF_MS) {
3001
+ if (input.signal?.aborted)
3002
+ return null;
3003
+ if (Date.now() - startedAt + backoff > PINNED_SAME_PAIR_GRACE_MS)
3004
+ break;
3005
+ // Visible on BOTH surfaces: the CLI reads it on the console, the dashboard
3006
+ // renders the same line as a progress step.
3007
+ logger.warn(` ⏳ your pinned ${pin} has not answered (${describeFailureKind(kind)}) — retrying it in ` +
3008
+ `${Math.round(backoff / 1000)}s (${Math.round((Date.now() - startedAt) / 1000)}s of ` +
3009
+ `${Math.round(PINNED_SAME_PAIR_GRACE_MS / 1000)}s). Nothing has been sent to another model.`);
3010
+ await new Promise((resolve) => setTimeout(resolve, backoff));
3011
+ served = await attemptPin();
3012
+ if (served)
3013
+ return served;
3014
+ }
3015
+ // ── 2. ASK. The pin is the user's contract; only they may release it. ────
3016
+ const alternatives = this.pinAlternatives(session.model, session.type);
3017
+ const switchTarget = alternatives.sameClass[0];
3018
+ // Headless (a pipeline, a cron, a queue worker) has no one to ask: the strict
3019
+ // refusal stands. Choosing silently FOR the user is the defect this fixes.
3020
+ if (!input.askUser && !process.stdin.isTTY)
3021
+ return null;
3022
+ const ask = input.askUser ?? (await import('../tools/ask-user.js')).renderAskUser;
3023
+ const choices = [
3024
+ {
3025
+ label: `Keep trying with ${pin}`,
3026
+ description: 'Stay on your pinned model — nothing is routed to another model.',
3027
+ },
3028
+ ];
3029
+ if (switchTarget) {
3030
+ choices.push({
3031
+ label: `Switch to ${switchTarget.provider}/${switchTarget.model}`,
3032
+ description: `The closest same-class model available (${capabilityClassOf(switchTarget.model)}).`,
3033
+ });
3034
+ }
3035
+ choices.push({
3036
+ label: 'Type another model or provider…',
3037
+ description: 'Name any model id, or provider/model, and it is used exactly as given.',
3038
+ });
3039
+ const keepIdx = 0;
3040
+ const switchIdx = switchTarget ? 1 : -1;
3041
+ const otherIdx = switchTarget ? 2 : 1;
3042
+ // NOTE — there is deliberately NO "go to auto" option. It was considered and
3043
+ // removed: it is the one choice that serves a model the user never named, and
3044
+ // a strict pin exists precisely to rule that out. A user who wants routing to
3045
+ // decide turns strict mode off, which is a decision they own and can see.
3046
+ // Nothing in this method can therefore serve a pair the user did not pick.
3047
+ const question = `I have NOT sent this to another model — you pinned ${pin} and strict mode is on, so that choice is yours.\n` +
3048
+ `${pin} did not answer within ${Math.round(PINNED_SAME_PAIR_GRACE_MS / 1000)}s (${describeFailureKind(kind)}).\n` +
3049
+ (switchTarget
3050
+ ? `If you would rather switch, ${switchTarget.provider}/${switchTarget.model} is the closest same-class model available.`
3051
+ : 'No same-class model is available to switch to right now.');
3052
+ let answer = {};
3053
+ /** The bounded-answer timer — cleared or unref'd as soon as it is not needed. */
3054
+ let answerTimer;
3055
+ try {
3056
+ // BOUNDED: an unanswered question must not hold the turn open forever
3057
+ // (see PINNED_ANSWER_TIMEOUT_MS). A late answer is simply ignored — the
3058
+ // honest stop already happened.
3059
+ const raced = await Promise.race([
3060
+ ask(question, choices, false).then((a) => ({ answered: true, a })),
3061
+ new Promise((resolve) => {
3062
+ answerTimer = setTimeout(() => resolve({ answered: false }), PINNED_ANSWER_TIMEOUT_MS);
3063
+ // Never hold the process open for an answer that already arrived: an
3064
+ // un-unref'd 5-minute timer kept the CLI alive long after the turn was
3065
+ // finished (the `chat` command would appear to hang on exit).
3066
+ answerTimer.unref?.();
3067
+ }),
3068
+ ]);
3069
+ if (answerTimer)
3070
+ clearTimeout(answerTimer);
3071
+ if (!raced.answered) {
3072
+ logger.warn(` ⌛ no answer to the pinned-model question after ${Math.round(PINNED_ANSWER_TIMEOUT_MS / 60_000)} minutes — ` +
3073
+ `stopping on ${pin} rather than choosing for you.`);
3074
+ return null;
3075
+ }
3076
+ answer = raced.a;
3077
+ }
3078
+ catch {
3079
+ return null;
3080
+ }
3081
+ const chosen = typeof answer.index === 'number' ? answer.index : -1;
3082
+ if (chosen === keepIdx) {
3083
+ // One more full window on the SAME pair, then the honest stop. Bounded on
3084
+ // purpose: "keep trying" must not become an unbounded loop the user cannot
3085
+ // interrupt — they can ask again, and the dashboard's retry offer queues it.
3086
+ const retryStart = Date.now();
3087
+ for (;;) {
3088
+ if (input.signal?.aborted)
3089
+ return null;
3090
+ const again = await attemptPin();
3091
+ if (again)
3092
+ return again;
3093
+ if (Date.now() - retryStart >= PINNED_SAME_PAIR_GRACE_MS)
3094
+ break;
3095
+ await new Promise((resolve) => setTimeout(resolve, 15_000));
3096
+ }
3097
+ return null;
3098
+ }
3099
+ if (switchIdx >= 0 && chosen === switchIdx && switchTarget) {
3100
+ return await this.serveChosenPair(input, switchTarget.provider, switchTarget.model);
3101
+ }
3102
+ if (chosen === otherIdx) {
3103
+ const custom = (answer.custom ?? '').trim();
3104
+ if (custom) {
3105
+ const pair = this.parsePinChoice(custom, session.type);
3106
+ if (pair) {
3107
+ const servedCustom = await this.serveChosenPair(input, pair.provider, pair.model);
3108
+ if (servedCustom)
3109
+ return servedCustom;
3110
+ }
3111
+ logger.warn(` ⚠️ could not use "${custom}" — check that the model is served by that provider.`);
3112
+ }
3113
+ }
3114
+ return null;
3115
+ }
2761
3116
  /**
2762
3117
  * E3b — the model-call step for the tool loop:
2763
3118
  * native generateTools when the provider supports it, JSON fallback
@@ -2797,7 +3152,14 @@ export class ChatCommand extends BaseCommand {
2797
3152
  * too, or a continuation whose first cloud provider dies re-routes on the
2798
3153
  * signal-free message and can land on a tiny local model.
2799
3154
  */
2800
- routingText) {
3155
+ routingText,
3156
+ /**
3157
+ * PINNED + STRICT escalation needs to ASK the user (see
3158
+ * `pinnedStrictEscalation`). The dashboard console injects a real question
3159
+ * card; a TTY falls back to the CLI prompt; headless has neither, and there
3160
+ * the strict refusal stands rather than anything being chosen for the user.
3161
+ */
3162
+ askUser) {
2801
3163
  return async (messages, schemas, stepOnToken, stepSignal) => {
2802
3164
  // The effective token sink: the caller's stream wins; when a step-level
2803
3165
  // sink is also given (loop passthrough) they are the same channel.
@@ -2907,12 +3269,23 @@ export class ChatCommand extends BaseCommand {
2907
3269
  // One resolution per attempt — the request, the trace, the telemetry
2908
3270
  // and the cache key all read the same value (see above).
2909
3271
  const effectiveModel = resolveEffectiveModel(typ, mdl);
2910
- // Record the ATTEMPTED model immediately so a failed step's trace and
2911
- // telemetry name the model that failed, instead of "unknown".
2912
- if (effectiveModel)
2913
- session.model = effectiveModel;
2914
- // G5 — and name the attempt itself, so the trace recorder reports the
2915
- // real provider/model (post-failover) rather than the session default.
3272
+ // Name the ATTEMPT itself — the trace recorder and the failure report
3273
+ // read `lastAttempt` — but NEVER install its model on the session
3274
+ // before the call succeeds.
3275
+ //
3276
+ // MEASURED (trace-1791732980335-omkv62, 2026-10-11). This used to assign
3277
+ // `session.model = effectiveModel` here, BEFORE the request. A pinned
3278
+ // `deepseek` turn whose step failed walked the fallback chain, and the
3279
+ // walk's last candidate — which also FAILED — left its own model on the
3280
+ // session. The loop's NEXT step then called the pinned provider with
3281
+ // that foreign id (`deepseek` + `wire-stub-model`), which the API
3282
+ // rejected outright: "The supported API model names are deepseek-flash,
3283
+ // deepseek-v4-pro, but you passed wire-stub-model". The turn burned its
3284
+ // whole failover walk (25 attempts) and ended in a model-shortage report
3285
+ // while healthy models sat in the pool. A failed attempt must never
3286
+ // become the model of the next request; the success paths
3287
+ // (`answered()`, and the walks that install what they served) are the
3288
+ // ones that own `session.model`.
2916
3289
  this.lastAttempt = { provider: typ, model: effectiveModel };
2917
3290
  const nativeKey = `${typ}|${effectiveModel ?? ''}`;
2918
3291
  // Answer-quality resilience: a CONFUSED reply — the model talking about
@@ -3038,9 +3411,11 @@ export class ChatCommand extends BaseCommand {
3038
3411
  // phrase (never the raw provider text, which is how a JSON quota dump
3039
3412
  // once reached a user verbatim) so the exhaustion report can name it.
3040
3413
  const primaryKind = classifyFallbackError(err);
3414
+ // The pair that FAILED is the one the attempt named — `session.model`
3415
+ // still holds the pair the turn RAN on (see `tryGenerate`).
3041
3416
  this.recordTurnAttempt({
3042
- provider: session.type,
3043
- model: session.model ?? 'default',
3417
+ provider: this.lastAttempt?.provider ?? session.type,
3418
+ model: this.lastAttempt?.model ?? session.model ?? 'default',
3044
3419
  kind: primaryKind,
3045
3420
  reason: describeFailureKind(primaryKind),
3046
3421
  });
@@ -3169,7 +3544,47 @@ export class ChatCommand extends BaseCommand {
3169
3544
  // the walk now installs what it serves (see below), and the warning
3170
3545
  // must still name the provider that actually failed.
3171
3546
  const pinnedType = session.type;
3172
- for (const fbType of ordered) {
3547
+ /**
3548
+ * The model the USER pinned — for the same-class rule below.
3549
+ */
3550
+ const pinnedModel = this.lastAttempt?.model ?? session.model;
3551
+ /**
3552
+ * PINNED + AUTO-FALLBACK: providers that can hold the PIN'S
3553
+ * CAPABILITY CLASS are tried FIRST, and only then the ones that can
3554
+ * only step down.
3555
+ *
3556
+ * WHY (the user's own rule): "if a strong model is selected then only
3557
+ * strong should be the priority". The chain order was health/registry
3558
+ * order, so a provider whose only usable model was a small local one
3559
+ * could be reached while a provider with a same-class strong model sat
3560
+ * later in the chain — the exact silent downgrade this ordering
3561
+ * prevents. `crossClass` is what keeps it honest: when nothing in the
3562
+ * pin's class can serve, the step-down still happens, but it is named
3563
+ * in the log AND in the answer (see `routingNotice`).
3564
+ */
3565
+ const sameClassFirst = [];
3566
+ const crossClass = [];
3567
+ for (const t of ordered) {
3568
+ if (t === session.type)
3569
+ continue;
3570
+ let stepsDown = true;
3571
+ try {
3572
+ const r = resolveProvider(this.configManager, t);
3573
+ const base = resolveEffectiveModel(r.type, undefined);
3574
+ const pick = sameClassFallback(pinnedModel, [
3575
+ ...(base ? [base] : []),
3576
+ ...preferredModelsFor(r.type),
3577
+ ]);
3578
+ stepsDown = pick?.crossClass ?? true;
3579
+ }
3580
+ catch {
3581
+ // A provider we cannot describe cannot be promised a same-class
3582
+ // model — it waits for the second pass rather than being dropped.
3583
+ stepsDown = true;
3584
+ }
3585
+ (stepsDown ? crossClass : sameClassFirst).push(t);
3586
+ }
3587
+ for (const fbType of [...sameClassFirst, ...crossClass]) {
3173
3588
  if (fbType === session.type)
3174
3589
  continue;
3175
3590
  // ADMIN POLICY: never fall back to a provider the policy rules
@@ -3206,7 +3621,24 @@ export class ChatCommand extends BaseCommand {
3206
3621
  // the dashboard console and the gateway chat engine - looked
3207
3622
  // dead.) Undefined = that provider's configured/adapter
3208
3623
  // default, which tryGenerate resolves per attempt.
3209
- const fallbackModel = resolveEffectiveModel(resolved.type, undefined);
3624
+ /**
3625
+ * The fallback provider gets a model from the PIN'S CLASS when
3626
+ * it has one — never merely its healthiest adapter default, which
3627
+ * is how a strong pin could be answered by a weak one (see
3628
+ * `sameClassFallback`). A class step-down is LOGGED, so a
3629
+ * reduced-quality handoff is never silent.
3630
+ */
3631
+ const baseModel = resolveEffectiveModel(resolved.type, undefined);
3632
+ const pick = sameClassFallback(pinnedModel, [
3633
+ ...(baseModel ? [baseModel] : []),
3634
+ ...preferredModelsFor(resolved.type),
3635
+ ]);
3636
+ if (pick?.crossClass) {
3637
+ logger.warn(` ⚠️ no ${capabilityClassOf(pinnedModel)}-class model available to take over — ` +
3638
+ `${resolved.type}/${pick.model} (${capabilityClassOf(pick.model)} class) is standing in for ` +
3639
+ `${pinnedType}/${pinnedModel ?? 'default'}.`);
3640
+ }
3641
+ const fallbackModel = pick?.model ?? baseModel;
3210
3642
  const resp = await tryGenerate(resolved.provider, resolved.type, fallbackModel);
3211
3643
  // ── The pair that ANSWERED is the pair the turn ran on ───────
3212
3644
  // This walk used to serve the fallback and leave `session` still
@@ -3262,6 +3694,21 @@ export class ChatCommand extends BaseCommand {
3262
3694
  .join('; ')}`);
3263
3695
  }
3264
3696
  }
3697
+ else if (strictModelMode()) {
3698
+ /**
3699
+ * PINNED + STRICT — wait on the SAME model, then ASK. Never substitute:
3700
+ * that is the whole meaning of the pin. See `pinnedStrictEscalation`.
3701
+ */
3702
+ const decided = await this.pinnedStrictEscalation({
3703
+ session,
3704
+ err,
3705
+ tryGenerate,
3706
+ ...(askUser ? { askUser } : {}),
3707
+ signal: abort,
3708
+ });
3709
+ if (decided)
3710
+ return decided;
3711
+ }
3265
3712
  // Answer-quality resilience: every candidate failed (or none was
3266
3713
  // tried) and the error carries the model's raw confused reply —
3267
3714
  // deliver THAT instead of failing the whole turn. A confusing answer