neoagent 3.2.1-beta.6 → 3.2.1-beta.8

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.
@@ -59,6 +59,11 @@ const {
59
59
  normalizeModelSelections,
60
60
  resolveModelSelection,
61
61
  } = require('../model_identity');
62
+ const {
63
+ isModelCoolingDown,
64
+ recordModelFailure,
65
+ recordModelSuccess,
66
+ } = require('../model_failure_cache');
62
67
  const { getProviderForUser } = require('../provider_selector');
63
68
  const { IterationBudget } = require('./iteration_budget');
64
69
  const {
@@ -417,11 +422,21 @@ async function getFailureFallbackModelId(
417
422
  preferredFallbackId = null,
418
423
  failureError = null,
419
424
  signal = null,
425
+ excludedModelIds = [],
420
426
  ) {
421
427
  const { getSupportedModels } = require('../models');
422
428
  const aiSettings = getAiSettings(userId, agentId);
423
429
  const models = await getSupportedModels(userId, agentId, { signal });
424
- const availableModels = models.filter((model) => model.available !== false);
430
+ const excluded = new Set(
431
+ [...excludedModelIds]
432
+ .map((id) => String(id || '').trim())
433
+ .filter(Boolean),
434
+ );
435
+ const availableModels = models.filter(
436
+ (model) => model.available !== false
437
+ && !isModelCoolingDown(userId, agentId, model.id)
438
+ && !excluded.has(model.id),
439
+ );
425
440
  const configuredEnabledIds = Array.isArray(aiSettings.enabled_models)
426
441
  ? aiSettings.enabled_models.map((id) => String(id).trim()).filter(Boolean)
427
442
  : [];
@@ -430,15 +445,26 @@ async function getFailureFallbackModelId(
430
445
  ? availableModels.filter((model) => enabledIds.includes(model.id))
431
446
  : availableModels;
432
447
  const fallbackSearchPool = pool;
433
- const currentModel = resolveModelSelection(pool, currentModelId)
434
- || resolveModelSelection(availableModels, currentModelId);
435
-
436
- // When the failure is a provider-level rate limit, the preferred fallback is
437
- // likely on the same provider and will hit the same limit. Skip it and prefer
438
- // a fallback from a different provider instead.
439
- const isProviderRateLimit = /429|rate.?limit|free-models-per/i.test(String(failureError?.message || ''));
448
+ const currentModel = resolveModelSelection(models, currentModelId);
449
+
450
+ // Provider-wide failures (credentials, throttling, or service outages) are
451
+ // likely to affect another model on the same provider. Prefer a different
452
+ // provider before consulting the configured fallback.
453
+ const failureStatus = Number(
454
+ failureError?.status
455
+ ?? failureError?.statusCode
456
+ ?? failureError?.response?.status,
457
+ );
458
+ const isProviderScopedFailure = (
459
+ failureStatus === 401
460
+ || failureStatus === 403
461
+ || failureStatus === 429
462
+ || (failureStatus >= 500 && failureStatus < 600)
463
+ || /rate.?limit|free-models-per|service unavailable|provider unavailable|authentication|api key/i
464
+ .test(String(failureError?.message || ''))
465
+ );
440
466
 
441
- if (preferredFallbackId && !isProviderRateLimit) {
467
+ if (preferredFallbackId && !isProviderScopedFailure) {
442
468
  const preferred = resolveModelSelection(fallbackSearchPool, preferredFallbackId)
443
469
  || resolveModelSelection(availableModels, preferredFallbackId);
444
470
  if (preferred && preferred.id !== currentModel?.id) return preferred.id;
@@ -450,8 +476,7 @@ async function getFailureFallbackModelId(
450
476
  if (differentProvider) return differentProvider.id;
451
477
  }
452
478
 
453
- // If no different-provider model exists, still try the preferred fallback
454
- // even on rate limits (it's better than nothing).
479
+ // If no different-provider model exists, still try the preferred fallback.
455
480
  if (preferredFallbackId) {
456
481
  const preferred = resolveModelSelection(fallbackSearchPool, preferredFallbackId)
457
482
  || resolveModelSelection(availableModels, preferredFallbackId);
@@ -483,6 +508,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
483
508
  let model = null;
484
509
  let modelSelectionId = null;
485
510
  let providerName = null;
511
+ const modelTurnFailedModelSelectionIds = new Set();
486
512
  let messages = [];
487
513
  let iteration = 0;
488
514
  let totalTokens = 0;
@@ -598,7 +624,17 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
598
624
  }
599
625
  db.prepare('UPDATE agent_runs SET model = ?, updated_at = datetime(\'now\') WHERE id = ?')
600
626
  .run(modelSelectionId, runId);
601
- const switchToFallbackModel = async (failedSelectionId, error, phase) => {
627
+ const recordFailedModel = (failedSelectionId, error, excludedModels) => {
628
+ excludedModels.add(failedSelectionId);
629
+ recordModelFailure(userId, agentId, failedSelectionId, error);
630
+ };
631
+ const switchToFallbackModel = async (
632
+ failedSelectionId,
633
+ error,
634
+ phase,
635
+ excludedModels,
636
+ ) => {
637
+ recordFailedModel(failedSelectionId, error, excludedModels);
602
638
  const fallbackModelId = await getFailureFallbackModelId(
603
639
  userId,
604
640
  agentId,
@@ -606,9 +642,21 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
606
642
  aiSettings.fallback_model_id,
607
643
  error,
608
644
  engine.getRunMeta(runId)?.abortController?.signal,
645
+ excludedModels,
609
646
  );
610
647
  if (!fallbackModelId || fallbackModelId === failedSelectionId) return false;
611
- console.log(`[Engine] ${phase} failed on ${failedSelectionId}; attempting fallback to: ${fallbackModelId}`);
648
+ const failureSummary = summarizeForLog(error?.message || error, 180);
649
+ console.log(
650
+ `[Engine] ${phase} failed on ${failedSelectionId}: ${failureSummary}; attempting fallback to: ${fallbackModelId}`
651
+ );
652
+ engine.recordRunEvent(userId, runId, 'model_fallback', {
653
+ phase,
654
+ failedModel: failedSelectionId,
655
+ fallbackModel: fallbackModelId,
656
+ errorCode: error?.code || null,
657
+ errorStatus: error?.status || error?.statusCode || error?.response?.status || null,
658
+ error: failureSummary,
659
+ }, { agentId });
612
660
  engine.emit(userId, 'run:interim', {
613
661
  runId,
614
662
  message: `Model service failed on ${failedSelectionId}; retrying with ${fallbackModelId}.`,
@@ -638,13 +686,28 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
638
686
  return true;
639
687
  };
640
688
  const runWithModelFallback = async (phase, fn) => {
641
- try {
642
- return await fn();
643
- } catch (err) {
644
- const failedSelectionId = modelSelectionId;
645
- const switched = await switchToFallbackModel(failedSelectionId, err, phase);
646
- if (!switched) throw err;
647
- return await fn();
689
+ let recoveries = 0;
690
+ const failedModels = new Set();
691
+ while (true) {
692
+ try {
693
+ const result = await fn();
694
+ recordModelSuccess(userId, agentId, modelSelectionId);
695
+ return result;
696
+ } catch (err) {
697
+ if (recoveries >= loopPolicy.maxModelFailureRecoveries) {
698
+ recordFailedModel(modelSelectionId, err, failedModels);
699
+ throw err;
700
+ }
701
+ const failedSelectionId = modelSelectionId;
702
+ const switched = await switchToFallbackModel(
703
+ failedSelectionId,
704
+ err,
705
+ phase,
706
+ failedModels,
707
+ );
708
+ if (!switched) throw err;
709
+ recoveries += 1;
710
+ }
648
711
  }
649
712
  };
650
713
 
@@ -1470,66 +1533,59 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1470
1533
  let responseModel = model;
1471
1534
  let streamContent = '';
1472
1535
 
1473
- const tryModelCall = async (retryForFallback = true) => {
1474
- try {
1475
- const modelCall = await engine.requestModelResponse({
1476
- provider,
1477
- providerName,
1478
- model,
1479
- messages,
1480
- tools,
1481
- options: {
1482
- ...options,
1483
- userId,
1484
- agentId,
1536
+ const tryModelCall = async () => {
1537
+ let requestMessages = messages;
1538
+ let requestPhase = 'model_turn';
1539
+ while (true) {
1540
+ try {
1541
+ const modelCall = await engine.requestModelResponse({
1542
+ provider,
1543
+ providerName,
1544
+ model,
1545
+ messages: requestMessages,
1546
+ tools,
1547
+ options: {
1548
+ ...options,
1549
+ userId,
1550
+ agentId,
1551
+ runId,
1552
+ phase: requestPhase,
1553
+ signal: engine.getRunMeta(runId)?.abortController?.signal,
1554
+ },
1485
1555
  runId,
1486
- phase: 'model_turn',
1487
- signal: engine.getRunMeta(runId)?.abortController?.signal,
1488
- },
1489
- runId,
1490
- iteration,
1491
- });
1492
- response = modelCall.response;
1493
- responseModel = modelCall.responseModel;
1494
- streamContent = modelCall.streamContent;
1495
- } catch (err) {
1496
- console.error(`[Engine] Model call failed (${model}):`, err.message);
1497
- const fallbackModelId = retryForFallback
1498
- ? await getFailureFallbackModelId(
1499
- userId,
1500
- agentId,
1556
+ iteration,
1557
+ });
1558
+ response = modelCall.response;
1559
+ responseModel = modelCall.responseModel;
1560
+ streamContent = modelCall.streamContent;
1561
+ recordModelSuccess(userId, agentId, modelSelectionId);
1562
+ return;
1563
+ } catch (err) {
1564
+ console.error(`[Engine] Model call failed (${model}):`, err.message);
1565
+ const runSignal = engine.getRunMeta(runId)?.abortController?.signal;
1566
+ if (isAbortError(err) || runSignal?.aborted) throw err;
1567
+ if (modelFailureRecoveries >= loopPolicy.maxModelFailureRecoveries) {
1568
+ recordFailedModel(
1569
+ modelSelectionId,
1570
+ err,
1571
+ modelTurnFailedModelSelectionIds,
1572
+ );
1573
+ throw err;
1574
+ }
1575
+
1576
+ const failedModel = model;
1577
+ const switched = await switchToFallbackModel(
1501
1578
  modelSelectionId,
1502
- aiSettings.fallback_model_id,
1503
1579
  err,
1504
- engine.getRunMeta(runId)?.abortController?.signal,
1505
- )
1506
- : null;
1507
- if (fallbackModelId) {
1508
- const failedModel = model;
1509
- console.log(`[Engine] Attempting fallback to: ${fallbackModelId}`);
1510
- const fallback = await getProviderForUser(
1511
- userId,
1512
- userMessage,
1513
- triggerType === 'subagent',
1514
- fallbackModelId,
1515
- {
1516
- ...providerStatusConfig,
1517
- signal: engine.getRunMeta(runId)?.abortController?.signal,
1518
- }
1580
+ 'model turn',
1581
+ modelTurnFailedModelSelectionIds,
1519
1582
  );
1520
- provider = fallback.provider;
1521
- model = fallback.model;
1522
- modelSelectionId = fallback.modelSelectionId;
1523
- providerName = fallback.providerName;
1524
- db.prepare('UPDATE agent_runs SET model = ?, updated_at = datetime(\'now\') WHERE id = ?')
1525
- .run(modelSelectionId, runId);
1526
- Object.assign(engine.getRunMeta(runId) || {}, {
1527
- model,
1528
- modelSelectionId,
1529
- providerName,
1530
- });
1583
+ if (!switched) throw err;
1531
1584
 
1532
- const retryMessages = sanitizeConversationMessages([
1585
+ modelFailureRecoveries += 1;
1586
+ failedStepCount += 1;
1587
+ requestPhase = 'model_turn_fallback';
1588
+ requestMessages = sanitizeConversationMessages([
1533
1589
  ...messages,
1534
1590
  {
1535
1591
  role: 'system',
@@ -1540,29 +1596,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1540
1596
  })
1541
1597
  }
1542
1598
  ]);
1543
-
1544
- const fallbackCall = await engine.requestModelResponse({
1545
- provider,
1546
- providerName,
1547
- model,
1548
- messages: retryMessages,
1549
- tools,
1550
- options: {
1551
- ...options,
1552
- userId,
1553
- agentId,
1554
- runId,
1555
- phase: 'model_turn_fallback',
1556
- signal: engine.getRunMeta(runId)?.abortController?.signal,
1557
- },
1558
- runId,
1559
- iteration,
1560
- });
1561
- response = fallbackCall.response;
1562
- responseModel = fallbackCall.responseModel;
1563
- streamContent = fallbackCall.streamContent;
1564
- } else {
1565
- throw err;
1566
1599
  }
1567
1600
  }
1568
1601
  };
@@ -1585,30 +1618,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1585
1618
  continue;
1586
1619
  }
1587
1620
  if (lifecycleControl?.action === 'stop' || lifecycleControl?.action === 'interrupt') break;
1588
- const modelError = String(err?.message || 'Model call failed');
1589
-
1590
- if (modelFailureRecoveries < loopPolicy.maxModelFailureRecoveries) {
1591
- const failedModel = model;
1592
- const switched = await switchToFallbackModel(modelSelectionId, err, 'model turn');
1593
- if (!switched) throw err;
1594
- modelFailureRecoveries += 1;
1595
- failedStepCount += 1;
1596
- messages.push({
1597
- role: 'system',
1598
- content: buildModelFailureLoopPrompt({
1599
- failedModel,
1600
- nextModel: model,
1601
- errorMessage: modelError
1602
- })
1603
- });
1604
- engine.emit(userId, 'run:interim', {
1605
- runId,
1606
- message: 'Model call failed; adapting and retrying autonomously.',
1607
- phase: 'recovering'
1608
- });
1609
- continue;
1610
- }
1611
-
1612
1621
  throw err;
1613
1622
  }
1614
1623
 
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+
3
+ const { isAbortError } = require('../../utils/abort');
4
+ const {
5
+ getErrorCode,
6
+ getHttpStatus,
7
+ isTransientIoError,
8
+ retryAfterMilliseconds,
9
+ } = require('../../utils/retry');
10
+
11
+ const DEFAULT_COOLDOWN_MS = 15 * 60 * 1000;
12
+ const DEFAULT_RECOVERY_COOLDOWN_MS = 60 * 1000;
13
+ const failures = new Map();
14
+
15
+ function cacheKey(userId, agentId, modelSelectionId) {
16
+ return [
17
+ String(userId ?? ''),
18
+ String(agentId ?? 'main'),
19
+ String(modelSelectionId || '').trim(),
20
+ ].join(':');
21
+ }
22
+
23
+ function readPermanentCooldownMs() {
24
+ const configured = Number(process.env.NEOAGENT_MODEL_NOT_FOUND_COOLDOWN_MS);
25
+ if (!Number.isFinite(configured)) return DEFAULT_COOLDOWN_MS;
26
+ return Math.max(1_000, Math.min(configured, 24 * 60 * 60 * 1000));
27
+ }
28
+
29
+ function readRecoveryCooldownMs() {
30
+ const configured = Number(process.env.NEOAGENT_MODEL_RECOVERY_COOLDOWN_MS);
31
+ if (!Number.isFinite(configured)) return DEFAULT_RECOVERY_COOLDOWN_MS;
32
+ return Math.max(1_000, Math.min(configured, 15 * 60 * 1000));
33
+ }
34
+
35
+ function isPermanentModelFailure(error) {
36
+ const status = getHttpStatus(error);
37
+ if (status === 404) return true;
38
+ return /\b404\b.*\b(model|nim|provider|request)\b|\b(model|nim)\b.*\b404\b/i
39
+ .test(String(error?.message || ''));
40
+ }
41
+
42
+ function isRecoverableModelFailure(error) {
43
+ if (!error || isAbortError(error) || isPermanentModelFailure(error)) return false;
44
+ const status = getHttpStatus(error);
45
+ if (status === 401 || status === 403 || status === 429 || (status >= 500 && status < 600)) {
46
+ return true;
47
+ }
48
+ const code = String(getErrorCode(error) || '');
49
+ if (code === 'MODEL_EMPTY_RESPONSE' || code === 'MODEL_CALL_TIMEOUT') return true;
50
+ if (isTransientIoError(error)) return true;
51
+ return /\bempty response|temporarily unavailable|overloaded|rate.?limit|timed? ?out|network error\b/i
52
+ .test(String(error?.message || ''));
53
+ }
54
+
55
+ function resolveCooldownMs(error, now) {
56
+ if (isPermanentModelFailure(error)) return readPermanentCooldownMs();
57
+ if (!isRecoverableModelFailure(error)) return 0;
58
+
59
+ const configured = readRecoveryCooldownMs();
60
+ const retryAfter = retryAfterMilliseconds(
61
+ error?.headers || error?.response?.headers,
62
+ now,
63
+ );
64
+ if (!Number.isFinite(retryAfter)) return configured;
65
+ return Math.max(configured, Math.min(retryAfter, 15 * 60 * 1000));
66
+ }
67
+
68
+ function recordModelFailure(userId, agentId, modelSelectionId, error, now = Date.now()) {
69
+ const modelId = String(modelSelectionId || '').trim();
70
+ const cooldownMs = resolveCooldownMs(error, now);
71
+ if (!modelId || cooldownMs <= 0) return false;
72
+ failures.set(cacheKey(userId, agentId, modelId), {
73
+ expiresAt: now + cooldownMs,
74
+ });
75
+ return true;
76
+ }
77
+
78
+ function recordModelSuccess(userId, agentId, modelSelectionId) {
79
+ const modelId = String(modelSelectionId || '').trim();
80
+ if (!modelId) return false;
81
+ return failures.delete(cacheKey(userId, agentId, modelId));
82
+ }
83
+
84
+ function isModelCoolingDown(userId, agentId, modelSelectionId, now = Date.now()) {
85
+ const modelId = String(modelSelectionId || '').trim();
86
+ if (!modelId) return false;
87
+ const key = cacheKey(userId, agentId, modelId);
88
+ const entry = failures.get(key);
89
+ if (!entry) return false;
90
+ if (entry.expiresAt <= now) {
91
+ failures.delete(key);
92
+ return false;
93
+ }
94
+ return true;
95
+ }
96
+
97
+ function clearModelFailureCache() {
98
+ failures.clear();
99
+ }
100
+
101
+ module.exports = {
102
+ clearModelFailureCache,
103
+ isModelCoolingDown,
104
+ isPermanentModelFailure,
105
+ isRecoverableModelFailure,
106
+ recordModelFailure,
107
+ recordModelSuccess,
108
+ };
@@ -264,7 +264,7 @@ async function getProviderHealthCatalog(userId, agentId = null, options = {}) {
264
264
  let statusLabel = provider.statusLabel;
265
265
  let availabilityReason = provider.availabilityReason;
266
266
 
267
- if (provider.id === 'ollama' && provider.enabled) {
267
+ if (provider.id === 'ollama' && provider.enabled && options.probeLocal !== false) {
268
268
  const probe = await probeOllama(
269
269
  provider.baseUrl || AI_PROVIDER_DEFINITIONS.ollama.defaultBaseUrl,
270
270
  1500,
@@ -7,6 +7,7 @@ const {
7
7
  normalizeModelSelections,
8
8
  resolveModelSelection,
9
9
  } = require('./model_identity');
10
+ const { isModelCoolingDown } = require('./model_failure_cache');
10
11
 
11
12
  function buildSelection(model, userId, providerConfig) {
12
13
  return {
@@ -30,12 +31,29 @@ async function getProviderForUser(
30
31
  signal: providerConfig.signal,
31
32
  });
32
33
  const selectableModels = models.filter((model) => model.available !== false);
34
+ const readyModels = selectableModels.filter(
35
+ (model) => !isModelCoolingDown(userId, agentId, model.id),
36
+ );
33
37
 
34
38
  if (modelOverride && typeof modelOverride === 'string') {
35
39
  const requested = resolveModelSelection(selectableModels, modelOverride);
36
40
  if (!requested) {
37
41
  throw new Error(`Requested model '${modelOverride.trim()}' is not available.`);
38
42
  }
43
+ if (isModelCoolingDown(userId, agentId, requested.id) && readyModels.length > 0) {
44
+ const configuredFallback = resolveModelSelection(
45
+ readyModels,
46
+ aiSettings.fallback_model_id,
47
+ );
48
+ const fallback = configuredFallback
49
+ || readyModels.find((model) => model.provider !== requested.provider)
50
+ || readyModels[0];
51
+ providerConfig.onStatus?.({
52
+ phase: 'model_fallback',
53
+ message: `Skipping recently unavailable model ${requested.id}; using ${fallback.id}.`,
54
+ });
55
+ return buildSelection(fallback, userId, providerConfig);
56
+ }
39
57
  return buildSelection(requested, userId, providerConfig);
40
58
  }
41
59
 
@@ -43,9 +61,35 @@ async function getProviderForUser(
43
61
  ? aiSettings.enabled_models.map((id) => String(id).trim()).filter(Boolean)
44
62
  : [];
45
63
  const enabledIds = normalizeModelSelections(selectableModels, configuredEnabledIds);
46
- const availableModels = configuredEnabledIds.length > 0
47
- ? selectableModels.filter((model) => enabledIds.includes(model.id))
48
- : selectableModels;
64
+ let availableModels = configuredEnabledIds.length > 0
65
+ ? readyModels.filter((model) => enabledIds.includes(model.id))
66
+ : readyModels;
67
+
68
+ if (
69
+ availableModels.length === 0
70
+ && configuredEnabledIds.length > 0
71
+ && enabledIds.length > 0
72
+ ) {
73
+ const configuredFallback = resolveModelSelection(
74
+ readyModels,
75
+ aiSettings.fallback_model_id,
76
+ );
77
+ if (configuredFallback) {
78
+ providerConfig.onStatus?.({
79
+ phase: 'model_fallback',
80
+ message: `Enabled models are temporarily unavailable; using ${configuredFallback.id}.`,
81
+ });
82
+ availableModels = [configuredFallback];
83
+ }
84
+ }
85
+
86
+ // If every configured model is cooling down and there is no alternative,
87
+ // allow one retry instead of making the installation permanently unavailable.
88
+ if (availableModels.length === 0 && readyModels.length === 0) {
89
+ availableModels = configuredEnabledIds.length > 0
90
+ ? selectableModels.filter((model) => enabledIds.includes(model.id))
91
+ : selectableModels;
92
+ }
49
93
 
50
94
  if (availableModels.length === 0) {
51
95
  const message = configuredEnabledIds.length > 0
@@ -64,6 +108,17 @@ async function getProviderForUser(
64
108
  let selectedModel = fallbackModel;
65
109
  if (userSelectedDefault !== 'auto') {
66
110
  selectedModel = resolveModelSelection(availableModels, userSelectedDefault) || fallbackModel;
111
+ const configuredDefault = resolveModelSelection(selectableModels, userSelectedDefault);
112
+ if (
113
+ configuredDefault
114
+ && configuredDefault.id !== selectedModel.id
115
+ && isModelCoolingDown(userId, agentId, configuredDefault.id)
116
+ ) {
117
+ providerConfig.onStatus?.({
118
+ phase: 'model_fallback',
119
+ message: `Skipping recently unavailable model ${configuredDefault.id}; using ${selectedModel.id}.`,
120
+ });
121
+ }
67
122
  } else {
68
123
  const selectionHint = providerConfig.selectionHint && typeof providerConfig.selectionHint === 'object'
69
124
  ? providerConfig.selectionHint