neoagent 3.2.1-beta.7 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "3.2.1-beta.7",
3
+ "version": "3.2.1-beta.8",
4
4
  "description": "Self-hosted AI agent for long-running tasks, automation, messaging, device control, and local memory",
5
5
  "license": "AGPL-3.0-only",
6
6
  "main": "server/index.js",
@@ -68,7 +68,7 @@
68
68
  "dependencies": {
69
69
  "@anthropic-ai/sdk": "^0.39.0",
70
70
  "@bitwarden/cli": "^2026.6.0",
71
- "@google/generative-ai": "^0.24.0",
71
+ "@google/genai": "^2.13.0",
72
72
  "@modelcontextprotocol/sdk": "^1.29.0",
73
73
  "@remotion/cli": "^4.0.459",
74
74
  "@slidev/cli": "^52.18.0",
@@ -56,6 +56,13 @@ function registerApiRoutes(app) {
56
56
  if (isBillingEnabled()) {
57
57
  app.use('/api/billing/webhook', require('../routes/billing_webhook'));
58
58
  app.use('/api/billing', require('../routes/billing'));
59
+ } else {
60
+ // The Flutter client probes this public endpoint to decide whether to show
61
+ // billing. Return an explicit disabled state instead of generating a noisy
62
+ // 404 on every client startup.
63
+ app.get('/api/billing/plans', (_req, res) => {
64
+ res.json({ enabled: false, plans: null });
65
+ });
59
66
  }
60
67
 
61
68
  setupTelnyxWebhook(app);
@@ -1 +1 @@
1
- 4e3d49d31cbb561d89adf60e16759ebe
1
+ 38fc05bea2f5bf14dda4c0f58b31ff9a
@@ -33,10 +33,10 @@ addEventListener("message", eventListener);
33
33
  if (!window._flutter) {
34
34
  window._flutter = {};
35
35
  }
36
- _flutter.buildConfig = {"engineRevision":"69c8c61792f04cc809dfef0c910414fb9afc06cd","builds":[{"compileTarget":"dart2js","renderer":"canvaskit","mainJsPath":"main.dart.js"},{}]};
36
+ _flutter.buildConfig = {"engineRevision":"0cd610717bde95fd88343c64f81c11ba4e5c0010","builds":[{"compileTarget":"dart2js","renderer":"canvaskit","mainJsPath":"main.dart.js"},{}]};
37
37
 
38
38
  _flutter.loader.load({
39
39
  serviceWorkerSettings: {
40
- serviceWorkerVersion: "3827219452" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
40
+ serviceWorkerVersion: "222530411" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
41
41
  }
42
42
  });
@@ -44367,7 +44367,7 @@ $S:4}
44367
44367
  A.bnI.prototype={
44368
44368
  $1(a){var s=A.fp().b
44369
44369
  s=s==null?null:s.canvasKitBaseUrl
44370
- return(s==null?"https://www.gstatic.com/flutter-canvaskit/69c8c61792f04cc809dfef0c910414fb9afc06cd/":s)+a},
44370
+ return(s==null?"https://www.gstatic.com/flutter-canvaskit/0cd610717bde95fd88343c64f81c11ba4e5c0010/":s)+a},
44371
44371
  $S:42}
44372
44372
  A.a2F.prototype={
44373
44373
  gF(a){var s=this.a
@@ -144506,7 +144506,7 @@ if(r){r=s.d
144506
144506
  r===$&&A.b()
144507
144507
  p.push(A.ju(q,A.jx(!1,new A.Y(B.wP,A.cU(new A.cH(B.kE,new A.acU(r,q),q),q,q),q),!1,B.J,!0),q,q,0,0,0,q))}if(!s.ay){r=s.e
144508
144508
  r===$&&A.b()
144509
- r=B.b.t("mry3rvmk-b952e5d").length!==0&&r.b}else r=!1
144509
+ r=B.b.t("mryrwx0x-27df61b").length!==0&&r.b}else r=!1
144510
144510
  if(r){r=s.d
144511
144511
  r===$&&A.b()
144512
144512
  r=r.aU&&!r.ag?84:0
@@ -150393,7 +150393,7 @@ $S:0}
150393
150393
  A.a3j.prototype={}
150394
150394
  A.WC.prototype={
150395
150395
  r9(a){var s=this
150396
- if(B.b.t("mry3rvmk-b952e5d").length===0||s.a!=null)return
150396
+ if(B.b.t("mryrwx0x-27df61b").length===0||s.a!=null)return
150397
150397
  s.Co()
150398
150398
  s.a=A.mL(B.Yh,new A.bmK(s))},
150399
150399
  Co(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f
@@ -150411,7 +150411,7 @@ if(!t.f.b(k)){s=1
150411
150411
  break}i=J.a0(k,"buildId")
150412
150412
  h=i==null?null:B.b.t(J.q(i))
150413
150413
  j=h==null?"":h
150414
- if(J.bv(j)===0||J.e(j,"mry3rvmk-b952e5d")){s=1
150414
+ if(J.bv(j)===0||J.e(j,"mryrwx0x-27df61b")){s=1
150415
150415
  break}n.b=!0
150416
150416
  n.H()
150417
150417
  p=2
@@ -150428,7 +150428,7 @@ case 2:return A.i(o.at(-1),r)}})
150428
150428
  return A.k($async$Co,r)},
150429
150429
  wV(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f,e,d,c,b,a,a0,a1
150430
150430
  var $async$wV=A.h(function(a2,a3){if(a2===1){o.push(a3)
150431
- s=p}for(;;)switch(s){case 0:if(B.b.t("mry3rvmk-b952e5d").length===0||n.c){s=1
150431
+ s=p}for(;;)switch(s){case 0:if(B.b.t("mryrwx0x-27df61b").length===0||n.c){s=1
150432
150432
  break}n.c=!0
150433
150433
  n.H()
150434
150434
  p=4
@@ -2,7 +2,10 @@
2
2
 
3
3
  const { normalizeCompletionConfidence } = require('../completion');
4
4
  const { normalizeOutgoingMessage } = require('../messagingFallback');
5
- const { isDeferredWorkReply } = require('../terminal_reply');
5
+ const {
6
+ isDeferredWorkReply,
7
+ isTerminalQuestionOrBlockerReply,
8
+ } = require('../terminal_reply');
6
9
  const {
7
10
  summarizeAvailableTools,
8
11
  summarizeToolExecutions,
@@ -243,13 +246,19 @@ function normalizeCompletionDecision(raw, fallbackStatus = 'continue') {
243
246
  }
244
247
 
245
248
  function enforceTerminalReplyDecision(decision, lastReply) {
246
- if (decision?.status === 'continue' || !isDeferredWorkReply(lastReply)) {
247
- return decision;
249
+ if (isDeferredWorkReply(lastReply)) {
250
+ return {
251
+ status: 'continue',
252
+ reason: 'The latest reply only announces or promises unfinished work; the run must continue or return a concrete blocker.',
253
+ };
248
254
  }
249
- return {
250
- status: 'continue',
251
- reason: 'The latest reply only announces or promises unfinished work; the run must continue or return a concrete blocker.',
252
- };
255
+ if (decision?.status === 'continue' && isTerminalQuestionOrBlockerReply(lastReply)) {
256
+ return {
257
+ status: 'blocked',
258
+ reason: 'The latest reply asks for user input or states a concrete blocker, so the run must wait instead of repeating it.',
259
+ };
260
+ }
261
+ return decision;
253
262
  }
254
263
 
255
264
  // Intentionally lightweight (200-token cap, self-contained) so the model can
@@ -422,13 +422,20 @@ async function getFailureFallbackModelId(
422
422
  preferredFallbackId = null,
423
423
  failureError = null,
424
424
  signal = null,
425
+ excludedModelIds = [],
425
426
  ) {
426
427
  const { getSupportedModels } = require('../models');
427
428
  const aiSettings = getAiSettings(userId, agentId);
428
429
  const models = await getSupportedModels(userId, agentId, { signal });
430
+ const excluded = new Set(
431
+ [...excludedModelIds]
432
+ .map((id) => String(id || '').trim())
433
+ .filter(Boolean),
434
+ );
429
435
  const availableModels = models.filter(
430
436
  (model) => model.available !== false
431
- && !isModelCoolingDown(userId, agentId, model.id),
437
+ && !isModelCoolingDown(userId, agentId, model.id)
438
+ && !excluded.has(model.id),
432
439
  );
433
440
  const configuredEnabledIds = Array.isArray(aiSettings.enabled_models)
434
441
  ? aiSettings.enabled_models.map((id) => String(id).trim()).filter(Boolean)
@@ -438,15 +445,26 @@ async function getFailureFallbackModelId(
438
445
  ? availableModels.filter((model) => enabledIds.includes(model.id))
439
446
  : availableModels;
440
447
  const fallbackSearchPool = pool;
441
- const currentModel = resolveModelSelection(pool, currentModelId)
442
- || resolveModelSelection(availableModels, currentModelId);
443
-
444
- // When the failure is a provider-level rate limit, the preferred fallback is
445
- // likely on the same provider and will hit the same limit. Skip it and prefer
446
- // a fallback from a different provider instead.
447
- 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
+ );
448
466
 
449
- if (preferredFallbackId && !isProviderRateLimit) {
467
+ if (preferredFallbackId && !isProviderScopedFailure) {
450
468
  const preferred = resolveModelSelection(fallbackSearchPool, preferredFallbackId)
451
469
  || resolveModelSelection(availableModels, preferredFallbackId);
452
470
  if (preferred && preferred.id !== currentModel?.id) return preferred.id;
@@ -458,8 +476,7 @@ async function getFailureFallbackModelId(
458
476
  if (differentProvider) return differentProvider.id;
459
477
  }
460
478
 
461
- // If no different-provider model exists, still try the preferred fallback
462
- // even on rate limits (it's better than nothing).
479
+ // If no different-provider model exists, still try the preferred fallback.
463
480
  if (preferredFallbackId) {
464
481
  const preferred = resolveModelSelection(fallbackSearchPool, preferredFallbackId)
465
482
  || resolveModelSelection(availableModels, preferredFallbackId);
@@ -491,6 +508,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
491
508
  let model = null;
492
509
  let modelSelectionId = null;
493
510
  let providerName = null;
511
+ const modelTurnFailedModelSelectionIds = new Set();
494
512
  let messages = [];
495
513
  let iteration = 0;
496
514
  let totalTokens = 0;
@@ -606,8 +624,17 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
606
624
  }
607
625
  db.prepare('UPDATE agent_runs SET model = ?, updated_at = datetime(\'now\') WHERE id = ?')
608
626
  .run(modelSelectionId, runId);
609
- const switchToFallbackModel = async (failedSelectionId, error, phase) => {
627
+ const recordFailedModel = (failedSelectionId, error, excludedModels) => {
628
+ excludedModels.add(failedSelectionId);
610
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);
611
638
  const fallbackModelId = await getFailureFallbackModelId(
612
639
  userId,
613
640
  agentId,
@@ -615,9 +642,21 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
615
642
  aiSettings.fallback_model_id,
616
643
  error,
617
644
  engine.getRunMeta(runId)?.abortController?.signal,
645
+ excludedModels,
618
646
  );
619
647
  if (!fallbackModelId || fallbackModelId === failedSelectionId) return false;
620
- 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 });
621
660
  engine.emit(userId, 'run:interim', {
622
661
  runId,
623
662
  message: `Model service failed on ${failedSelectionId}; retrying with ${fallbackModelId}.`,
@@ -647,17 +686,28 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
647
686
  return true;
648
687
  };
649
688
  const runWithModelFallback = async (phase, fn) => {
650
- try {
651
- const result = await fn();
652
- recordModelSuccess(userId, agentId, modelSelectionId);
653
- return result;
654
- } catch (err) {
655
- const failedSelectionId = modelSelectionId;
656
- const switched = await switchToFallbackModel(failedSelectionId, err, phase);
657
- if (!switched) throw err;
658
- const result = await fn();
659
- recordModelSuccess(userId, agentId, modelSelectionId);
660
- return result;
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
+ }
661
711
  }
662
712
  };
663
713
 
@@ -1483,102 +1533,69 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1483
1533
  let responseModel = model;
1484
1534
  let streamContent = '';
1485
1535
 
1486
- const tryModelCall = async (retryForFallback = true) => {
1487
- try {
1488
- const modelCall = await engine.requestModelResponse({
1489
- provider,
1490
- providerName,
1491
- model,
1492
- messages,
1493
- tools,
1494
- options: {
1495
- ...options,
1496
- userId,
1497
- agentId,
1498
- runId,
1499
- phase: 'model_turn',
1500
- signal: engine.getRunMeta(runId)?.abortController?.signal,
1501
- },
1502
- runId,
1503
- iteration,
1504
- });
1505
- response = modelCall.response;
1506
- responseModel = modelCall.responseModel;
1507
- streamContent = modelCall.streamContent;
1508
- recordModelSuccess(userId, agentId, modelSelectionId);
1509
- } catch (err) {
1510
- console.error(`[Engine] Model call failed (${model}):`, err.message);
1511
- recordModelFailure(userId, agentId, modelSelectionId, err);
1512
- const fallbackModelId = retryForFallback
1513
- ? await getFailureFallbackModelId(
1514
- userId,
1515
- agentId,
1516
- modelSelectionId,
1517
- aiSettings.fallback_model_id,
1518
- err,
1519
- engine.getRunMeta(runId)?.abortController?.signal,
1520
- )
1521
- : null;
1522
- if (fallbackModelId) {
1523
- const failedModel = model;
1524
- console.log(`[Engine] Attempting fallback to: ${fallbackModelId}`);
1525
- const fallback = await getProviderForUser(
1526
- userId,
1527
- userMessage,
1528
- triggerType === 'subagent',
1529
- fallbackModelId,
1530
- {
1531
- ...providerStatusConfig,
1532
- signal: engine.getRunMeta(runId)?.abortController?.signal,
1533
- }
1534
- );
1535
- provider = fallback.provider;
1536
- model = fallback.model;
1537
- modelSelectionId = fallback.modelSelectionId;
1538
- providerName = fallback.providerName;
1539
- db.prepare('UPDATE agent_runs SET model = ?, updated_at = datetime(\'now\') WHERE id = ?')
1540
- .run(modelSelectionId, runId);
1541
- Object.assign(engine.getRunMeta(runId) || {}, {
1542
- model,
1543
- modelSelectionId,
1544
- providerName,
1545
- });
1546
-
1547
- const retryMessages = sanitizeConversationMessages([
1548
- ...messages,
1549
- {
1550
- role: 'system',
1551
- content: buildModelFailureLoopPrompt({
1552
- failedModel,
1553
- nextModel: model,
1554
- errorMessage: err.message
1555
- })
1556
- }
1557
- ]);
1558
-
1559
- const fallbackCall = await engine.requestModelResponse({
1536
+ const tryModelCall = async () => {
1537
+ let requestMessages = messages;
1538
+ let requestPhase = 'model_turn';
1539
+ while (true) {
1540
+ try {
1541
+ const modelCall = await engine.requestModelResponse({
1560
1542
  provider,
1561
1543
  providerName,
1562
1544
  model,
1563
- messages: retryMessages,
1545
+ messages: requestMessages,
1564
1546
  tools,
1565
1547
  options: {
1566
1548
  ...options,
1567
1549
  userId,
1568
1550
  agentId,
1569
1551
  runId,
1570
- phase: 'model_turn_fallback',
1552
+ phase: requestPhase,
1571
1553
  signal: engine.getRunMeta(runId)?.abortController?.signal,
1572
1554
  },
1573
1555
  runId,
1574
1556
  iteration,
1575
1557
  });
1576
- response = fallbackCall.response;
1577
- responseModel = fallbackCall.responseModel;
1578
- streamContent = fallbackCall.streamContent;
1558
+ response = modelCall.response;
1559
+ responseModel = modelCall.responseModel;
1560
+ streamContent = modelCall.streamContent;
1579
1561
  recordModelSuccess(userId, agentId, modelSelectionId);
1580
- } else {
1581
- throw err;
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(
1578
+ modelSelectionId,
1579
+ err,
1580
+ 'model turn',
1581
+ modelTurnFailedModelSelectionIds,
1582
+ );
1583
+ if (!switched) throw err;
1584
+
1585
+ modelFailureRecoveries += 1;
1586
+ failedStepCount += 1;
1587
+ requestPhase = 'model_turn_fallback';
1588
+ requestMessages = sanitizeConversationMessages([
1589
+ ...messages,
1590
+ {
1591
+ role: 'system',
1592
+ content: buildModelFailureLoopPrompt({
1593
+ failedModel,
1594
+ nextModel: model,
1595
+ errorMessage: err.message
1596
+ })
1597
+ }
1598
+ ]);
1582
1599
  }
1583
1600
  }
1584
1601
  };
@@ -1601,30 +1618,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1601
1618
  continue;
1602
1619
  }
1603
1620
  if (lifecycleControl?.action === 'stop' || lifecycleControl?.action === 'interrupt') break;
1604
- const modelError = String(err?.message || 'Model call failed');
1605
-
1606
- if (modelFailureRecoveries < loopPolicy.maxModelFailureRecoveries) {
1607
- const failedModel = model;
1608
- const switched = await switchToFallbackModel(modelSelectionId, err, 'model turn');
1609
- if (!switched) throw err;
1610
- modelFailureRecoveries += 1;
1611
- failedStepCount += 1;
1612
- messages.push({
1613
- role: 'system',
1614
- content: buildModelFailureLoopPrompt({
1615
- failedModel,
1616
- nextModel: model,
1617
- errorMessage: modelError
1618
- })
1619
- });
1620
- engine.emit(userId, 'run:interim', {
1621
- runId,
1622
- message: 'Model call failed; adapting and retrying autonomously.',
1623
- phase: 'recovering'
1624
- });
1625
- continue;
1626
- }
1627
-
1628
1621
  throw err;
1629
1622
  }
1630
1623
 
@@ -1,6 +1,15 @@
1
1
  'use strict';
2
2
 
3
+ const { isAbortError } = require('../../utils/abort');
4
+ const {
5
+ getErrorCode,
6
+ getHttpStatus,
7
+ isTransientIoError,
8
+ retryAfterMilliseconds,
9
+ } = require('../../utils/retry');
10
+
3
11
  const DEFAULT_COOLDOWN_MS = 15 * 60 * 1000;
12
+ const DEFAULT_RECOVERY_COOLDOWN_MS = 60 * 1000;
4
13
  const failures = new Map();
5
14
 
6
15
  function cacheKey(userId, agentId, modelSelectionId) {
@@ -11,28 +20,57 @@ function cacheKey(userId, agentId, modelSelectionId) {
11
20
  ].join(':');
12
21
  }
13
22
 
14
- function readCooldownMs() {
23
+ function readPermanentCooldownMs() {
15
24
  const configured = Number(process.env.NEOAGENT_MODEL_NOT_FOUND_COOLDOWN_MS);
16
25
  if (!Number.isFinite(configured)) return DEFAULT_COOLDOWN_MS;
17
26
  return Math.max(1_000, Math.min(configured, 24 * 60 * 60 * 1000));
18
27
  }
19
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
+
20
35
  function isPermanentModelFailure(error) {
21
- const status = Number(
22
- error?.status
23
- ?? error?.statusCode
24
- ?? error?.response?.status,
25
- );
36
+ const status = getHttpStatus(error);
26
37
  if (status === 404) return true;
27
38
  return /\b404\b.*\b(model|nim|provider|request)\b|\b(model|nim)\b.*\b404\b/i
28
39
  .test(String(error?.message || ''));
29
40
  }
30
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
+
31
68
  function recordModelFailure(userId, agentId, modelSelectionId, error, now = Date.now()) {
32
69
  const modelId = String(modelSelectionId || '').trim();
33
- if (!modelId || !isPermanentModelFailure(error)) return false;
70
+ const cooldownMs = resolveCooldownMs(error, now);
71
+ if (!modelId || cooldownMs <= 0) return false;
34
72
  failures.set(cacheKey(userId, agentId, modelId), {
35
- expiresAt: now + readCooldownMs(),
73
+ expiresAt: now + cooldownMs,
36
74
  });
37
75
  return true;
38
76
  }
@@ -64,6 +102,7 @@ module.exports = {
64
102
  clearModelFailureCache,
65
103
  isModelCoolingDown,
66
104
  isPermanentModelFailure,
105
+ isRecoverableModelFailure,
67
106
  recordModelFailure,
68
107
  recordModelSuccess,
69
108
  };
@@ -1,7 +1,63 @@
1
- const { GoogleGenerativeAI } = require('@google/generative-ai');
1
+ 'use strict';
2
+
3
+ const { GoogleGenAI } = require('@google/genai');
2
4
  const { BaseProvider } = require('./base');
3
5
  const { fetchResponseText } = require('../../network/http');
4
6
 
7
+ function parseToolArguments(value) {
8
+ try {
9
+ const parsed = JSON.parse(value || '{}');
10
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
11
+ ? parsed
12
+ : {};
13
+ } catch {
14
+ return {};
15
+ }
16
+ }
17
+
18
+ function normalizeUsage(usage) {
19
+ if (!usage) return null;
20
+ return {
21
+ inputTokens: usage.promptTokenCount || 0,
22
+ outputTokens: usage.candidatesTokenCount || 0,
23
+ reasoningTokens: usage.thoughtsTokenCount || 0,
24
+ cachedReadTokens: usage.cachedContentTokenCount || 0,
25
+ cacheWriteTokens: 0,
26
+ promptTokens: usage.promptTokenCount || 0,
27
+ completionTokens: usage.candidatesTokenCount || 0,
28
+ totalTokens: usage.totalTokenCount || 0
29
+ };
30
+ }
31
+
32
+ function collectResponseParts(response, toolCalls, seenToolCalls = null) {
33
+ let content = '';
34
+ for (const candidate of response?.candidates || []) {
35
+ for (const part of candidate.content?.parts || []) {
36
+ if (part.text && part.thought !== true) content += part.text;
37
+ if (!part.functionCall?.name) continue;
38
+
39
+ const args = part.functionCall.args || {};
40
+ const providerCallId = String(part.functionCall.id || '').trim();
41
+ const signature = providerCallId
42
+ || `${part.functionCall.name}:${JSON.stringify(args)}`;
43
+ if (seenToolCalls?.has(signature)) continue;
44
+ seenToolCalls?.add(signature);
45
+
46
+ toolCalls.push({
47
+ id: providerCallId
48
+ || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
49
+ type: 'function',
50
+ function: {
51
+ name: part.functionCall.name,
52
+ arguments: JSON.stringify(args),
53
+ thought_signature: part.thoughtSignature
54
+ }
55
+ });
56
+ }
57
+ }
58
+ return content;
59
+ }
60
+
5
61
  class GoogleProvider extends BaseProvider {
6
62
  constructor(config = {}) {
7
63
  super(config);
@@ -29,7 +85,7 @@ class GoogleProvider extends BaseProvider {
29
85
  'gemini-1.5-flash': 1048576,
30
86
  };
31
87
  this.apiKey = config.apiKey || process.env.GOOGLE_AI_KEY;
32
- this.genAI = new GoogleGenerativeAI(this.apiKey);
88
+ this.genAI = new GoogleGenAI({ apiKey: this.apiKey });
33
89
  }
34
90
 
35
91
  async listModels(signal = null) {
@@ -78,11 +134,23 @@ class GoogleProvider extends BaseProvider {
78
134
  functionDeclarations: tools.map(tool => ({
79
135
  name: tool.name,
80
136
  description: tool.description,
81
- parameters: tool.parameters || { type: 'object', properties: {} }
137
+ parametersJsonSchema: tool.parameters || { type: 'object', properties: {} }
82
138
  }))
83
139
  }];
84
140
  }
85
141
 
142
+ buildGenerateConfig(systemInstruction, tools, options) {
143
+ const config = {};
144
+ if (systemInstruction) config.systemInstruction = systemInstruction;
145
+ if (tools.length > 0) config.tools = this.formatTools(tools);
146
+ if (options.signal) config.abortSignal = options.signal;
147
+ const maxOutputTokens = Number(options.maxTokens);
148
+ if (Number.isFinite(maxOutputTokens) && maxOutputTokens > 0) {
149
+ config.maxOutputTokens = Math.floor(maxOutputTokens);
150
+ }
151
+ return config;
152
+ }
153
+
86
154
  convertMessages(messages) {
87
155
  let systemInstruction = '';
88
156
  const history = [];
@@ -93,16 +161,16 @@ class GoogleProvider extends BaseProvider {
93
161
  continue;
94
162
  }
95
163
  if (msg.role === 'tool') {
164
+ const functionResponse = {
165
+ name: msg.name || 'tool',
166
+ response: { result: msg.content }
167
+ };
168
+ if (msg.tool_call_id) functionResponse.id = msg.tool_call_id;
96
169
  history.push({
97
- // Gemini chat history only accepts user/model roles. Tool results are
98
- // represented as functionResponse parts on a user turn.
170
+ // The current Gemini API represents tool results as functionResponse
171
+ // parts on a user turn.
99
172
  role: 'user',
100
- parts: [{
101
- functionResponse: {
102
- name: msg.name || 'tool',
103
- response: { result: msg.content }
104
- }
105
- }]
173
+ parts: [{ functionResponse }]
106
174
  });
107
175
  continue;
108
176
  }
@@ -113,9 +181,10 @@ class GoogleProvider extends BaseProvider {
113
181
  const functionCallPart = {
114
182
  functionCall: {
115
183
  name: tc.function.name,
116
- args: JSON.parse(tc.function.arguments || '{}')
184
+ args: parseToolArguments(tc.function.arguments)
117
185
  }
118
186
  };
187
+ if (tc.id) functionCallPart.functionCall.id = tc.id;
119
188
  if (tc.function.thought_signature) {
120
189
  functionCallPart.thoughtSignature = tc.function.thought_signature;
121
190
  }
@@ -149,8 +218,8 @@ class GoogleProvider extends BaseProvider {
149
218
  normalizedHistory.push({ role: currentRole, parts: currentParts });
150
219
  }
151
220
 
152
- // Structured helpers can consist entirely of system instructions. Gemini's
153
- // chat API still requires a user turn to trigger generation.
221
+ // Structured helpers can consist entirely of system instructions. Gemini
222
+ // still requires a user turn to trigger generation.
154
223
  if (
155
224
  normalizedHistory.length === 0
156
225
  || normalizedHistory[normalizedHistory.length - 1].role === 'model'
@@ -174,53 +243,18 @@ class GoogleProvider extends BaseProvider {
174
243
  async chat(messages, tools = [], options = {}) {
175
244
  const model = options.model || this.config.model || this.getDefaultModel();
176
245
  const { systemInstruction, history } = this.convertMessages(messages);
177
-
178
- const genModel = this.genAI.getGenerativeModel({
246
+ const response = await this.genAI.models.generateContent({
179
247
  model,
180
- systemInstruction: systemInstruction || undefined,
181
- tools: tools.length > 0 ? this.formatTools(tools) : undefined
248
+ contents: history,
249
+ config: this.buildGenerateConfig(systemInstruction, tools, options)
182
250
  });
183
-
184
- const lastMessage = history.pop();
185
- const chat = genModel.startChat({ history });
186
- const result = await chat.sendMessage(lastMessage.parts, { signal: options.signal });
187
- const response = result.response;
188
-
189
- let content = '';
190
251
  const toolCalls = [];
191
-
192
- for (const candidate of response.candidates || []) {
193
- for (const part of candidate.content?.parts || []) {
194
- if (part.text) content += part.text;
195
- if (part.functionCall) {
196
- toolCalls.push({
197
- id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
198
- type: 'function',
199
- function: {
200
- name: part.functionCall.name,
201
- arguments: JSON.stringify(part.functionCall.args || {}),
202
- thought_signature: part.thoughtSignature
203
- }
204
- });
205
- }
206
- }
207
- }
208
-
209
- const usage = response.usageMetadata;
252
+ const content = collectResponseParts(response, toolCalls);
210
253
  return {
211
254
  content,
212
255
  toolCalls,
213
256
  finishReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
214
- usage: usage ? {
215
- inputTokens: usage.promptTokenCount || 0,
216
- outputTokens: usage.candidatesTokenCount || 0,
217
- reasoningTokens: usage.thoughtsTokenCount || 0,
218
- cachedReadTokens: usage.cachedContentTokenCount || 0,
219
- cacheWriteTokens: 0,
220
- promptTokens: usage.promptTokenCount || 0,
221
- completionTokens: usage.candidatesTokenCount || 0,
222
- totalTokens: usage.totalTokenCount || 0
223
- } : null,
257
+ usage: normalizeUsage(response.usageMetadata),
224
258
  model
225
259
  };
226
260
  }
@@ -228,60 +262,32 @@ class GoogleProvider extends BaseProvider {
228
262
  async *stream(messages, tools = [], options = {}) {
229
263
  const model = options.model || this.config.model || this.getDefaultModel();
230
264
  const { systemInstruction, history } = this.convertMessages(messages);
231
-
232
- const genModel = this.genAI.getGenerativeModel({
265
+ const responseStream = await this.genAI.models.generateContentStream({
233
266
  model,
234
- systemInstruction: systemInstruction || undefined,
235
- tools: tools.length > 0 ? this.formatTools(tools) : undefined
267
+ contents: history,
268
+ config: this.buildGenerateConfig(systemInstruction, tools, options)
236
269
  });
237
-
238
- const lastMessage = history.pop();
239
- const chat = genModel.startChat({ history });
240
- const result = await chat.sendMessageStream(lastMessage.parts, { signal: options.signal });
241
-
242
270
  let content = '';
243
271
  const toolCalls = [];
272
+ const seenToolCalls = new Set();
273
+ let usage = null;
244
274
 
245
- for await (const chunk of result.stream) {
246
- for (const candidate of chunk.candidates || []) {
247
- for (const part of candidate.content?.parts || []) {
248
- if (part.text) {
249
- content += part.text;
250
- yield { type: 'content', content: part.text };
251
- }
252
- if (part.functionCall) {
253
- toolCalls.push({
254
- id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
255
- type: 'function',
256
- function: {
257
- name: part.functionCall.name,
258
- arguments: JSON.stringify(part.functionCall.args || {}),
259
- thought_signature: part.thoughtSignature
260
- }
261
- });
262
- }
263
- }
275
+ for await (const chunk of responseStream) {
276
+ const chunkContent = collectResponseParts(chunk, toolCalls, seenToolCalls);
277
+ if (chunkContent) {
278
+ content += chunkContent;
279
+ yield { type: 'content', content: chunkContent };
264
280
  }
281
+ if (chunk.usageMetadata) usage = chunk.usageMetadata;
265
282
  }
266
283
 
267
- const finalResponse = await result.response;
268
- const usage = finalResponse.usageMetadata;
269
-
270
284
  yield {
271
285
  type: 'done',
272
286
  content,
273
287
  toolCalls,
274
288
  finishReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
275
- usage: usage ? {
276
- inputTokens: usage.promptTokenCount || 0,
277
- outputTokens: usage.candidatesTokenCount || 0,
278
- reasoningTokens: usage.thoughtsTokenCount || 0,
279
- cachedReadTokens: usage.cachedContentTokenCount || 0,
280
- cacheWriteTokens: 0,
281
- promptTokens: usage.promptTokenCount || 0,
282
- completionTokens: usage.candidatesTokenCount || 0,
283
- totalTokens: usage.totalTokenCount || 0
284
- } : null
289
+ usage: normalizeUsage(usage),
290
+ model
285
291
  };
286
292
  }
287
293
  }
@@ -10,7 +10,13 @@ function normalizeReply(content) {
10
10
  }
11
11
 
12
12
  function hasExternalBlocker(text) {
13
- return /\b(?:blocked|cannot|can't|could not|couldn't|unable to|do not have access|don't have access|missing (?:access|credentials|permission|information)|need you to|requires? your|waiting for your|please (?:provide|send|choose|confirm|authorize|approve))\b/.test(text);
13
+ return /\b(?:blocked|cannot|can't|could not|couldn't|unable to|do not have access|don't have access|missing (?:access|credentials|permission|information)|need you to|requires? your|waiting for your|please (?:provide|send|choose|confirm|authorize|approve)|blockiert|kann nicht|konnte nicht|mir fehlt|ich brauche von dir|warte auf dein(?:e|en)?|bitte (?:gib|schick|sende|nenn|bestätige|waehle|wähle|genehmige))\b/.test(text);
14
+ }
15
+
16
+ function isTerminalQuestionOrBlockerReply(content) {
17
+ const text = normalizeReply(content);
18
+ if (!text) return false;
19
+ return /[??]/.test(text) || hasExternalBlocker(text);
14
20
  }
15
21
 
16
22
  /**
@@ -36,10 +42,16 @@ function isDeferredWorkReply(content) {
36
42
  /^(?:working on it|checking now|on it)[.!…]*$/,
37
43
  /\bi(?:'ll| will)\s+(?:get back to you|update you|report back|let you know|keep you posted)\b/,
38
44
  /\b(?:i(?:'ll| will)\s+follow up|stay tuned)\b/,
45
+ /^(?:(?:klar|okay|ok|alles klar)[,.!]?\s+)?ich\s+(?:(?:arbeite|pruefe|prüfe|schaue|untersuche|teste|debugge|starte)\s+(?:(?:gerade|aktuell|jetzt|noch)\b|(?:das|dies|die logs?|den code|deine anfrage|deinen auftrag)\b)|(?:kuemmere|kümmere)\s+mich\s+(?:gerade|aktuell|jetzt|noch)\b)/,
46
+ /^(?:(?:klar|okay|ok|alles klar)[,.!]?\s+)?ich\s+(?:werde|wuerde|würde)\s+(?:jetzt\s+)?(?:pruefen|prüfen|nachsehen|untersuchen|testen|debuggen|starten|fixen|erledigen)\b/,
47
+ /^(?:(?:klar|okay|ok|alles klar)[,.!]?\s+)?(?:lass|lasst)\s+mich\s+(?:(?:das|dies|die logs?|den code)\s+)?(?:pruefen|prüfen|nachsehen|untersuchen|testen|debuggen)\b/,
48
+ /^(?:gib mir|gebt mir)\s+(?:einen\s+)?(?:moment|augenblick)|^(?:bin dran|mache ich)[.!…]*$/,
49
+ /\bich\s+(?:melde mich|gebe dir bescheid|halte dich auf dem laufenden)\b/,
39
50
  ];
40
51
  return patterns.some((pattern) => pattern.test(text));
41
52
  }
42
53
 
43
54
  module.exports = {
44
55
  isDeferredWorkReply,
56
+ isTerminalQuestionOrBlockerReply,
45
57
  };