neoagent 3.0.1-beta.2 → 3.0.1-beta.21

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 (94) hide show
  1. package/.env.example +19 -0
  2. package/README.md +20 -3
  3. package/docs/benchmarking.md +102 -0
  4. package/docs/billing.md +224 -0
  5. package/docs/configuration.md +22 -0
  6. package/docs/getting-started.md +10 -8
  7. package/docs/index.md +1 -0
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +4 -1
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +316 -0
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1594 -224
  14. package/flutter_app/lib/main_controller.dart +263 -26
  15. package/flutter_app/lib/main_devices.dart +1 -1
  16. package/flutter_app/lib/main_install.dart +1147 -0
  17. package/flutter_app/lib/main_navigation.dart +12 -0
  18. package/flutter_app/lib/main_operations.dart +134 -9
  19. package/flutter_app/lib/main_settings.dart +148 -52
  20. package/flutter_app/lib/main_shared.dart +1 -0
  21. package/flutter_app/lib/src/backend_client.dart +86 -1
  22. package/landing/index.html +2 -2
  23. package/lib/manager.js +184 -66
  24. package/lib/schema_migrations.js +84 -0
  25. package/package.json +10 -4
  26. package/server/admin/access.js +12 -7
  27. package/server/admin/admin.css +78 -0
  28. package/server/admin/admin.js +511 -23
  29. package/server/admin/billing.js +415 -0
  30. package/server/admin/index.html +120 -13
  31. package/server/admin/users.js +15 -15
  32. package/server/db/database.js +13 -21
  33. package/server/db/sessions_db.js +8 -0
  34. package/server/http/middleware.js +4 -4
  35. package/server/http/routes.js +9 -0
  36. package/server/http/static.js +4 -2
  37. package/server/middleware/requireBilling.js +12 -0
  38. package/server/public/.last_build_id +1 -1
  39. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  40. package/server/public/flutter_bootstrap.js +1 -1
  41. package/server/public/main.dart.js +89347 -85449
  42. package/server/routes/account.js +53 -0
  43. package/server/routes/admin.js +515 -63
  44. package/server/routes/agents.js +203 -21
  45. package/server/routes/billing.js +127 -0
  46. package/server/routes/billing_webhook.js +43 -0
  47. package/server/routes/memory.js +1 -4
  48. package/server/routes/settings.js +1 -2
  49. package/server/routes/skills.js +1 -1
  50. package/server/routes/triggers.js +2 -2
  51. package/server/services/account/erasure.js +263 -0
  52. package/server/services/account/sessions.js +1 -9
  53. package/server/services/ai/loop/agent_engine_core.js +42 -0
  54. package/server/services/ai/loop/blank_recovery.js +36 -0
  55. package/server/services/ai/loop/completion_judge.js +42 -0
  56. package/server/services/ai/loop/conversation_loop.js +248 -34
  57. package/server/services/ai/loop/progress_monitor.js +6 -6
  58. package/server/services/ai/loopPolicy.js +37 -44
  59. package/server/services/ai/messagingFallback.js +25 -1
  60. package/server/services/ai/models.js +18 -0
  61. package/server/services/ai/preModelCompaction.js +25 -5
  62. package/server/services/ai/rate_limits.js +28 -4
  63. package/server/services/ai/systemPrompt.js +23 -5
  64. package/server/services/ai/taskAnalysis.js +2 -0
  65. package/server/services/ai/tools.js +231 -20
  66. package/server/services/android/controller.js +6 -2
  67. package/server/services/billing/billing_email.js +106 -0
  68. package/server/services/billing/config.js +17 -0
  69. package/server/services/billing/plans.js +121 -0
  70. package/server/services/billing/stripe_client.js +21 -0
  71. package/server/services/billing/subscriptions.js +462 -0
  72. package/server/services/billing/trial_guard.js +111 -0
  73. package/server/services/browser/contentExtractor.js +629 -0
  74. package/server/services/browser/controller.js +4 -8
  75. package/server/services/desktop/gateway.js +7 -4
  76. package/server/services/integrations/google/calendar.js +30 -2
  77. package/server/services/integrations/secrets.js +7 -4
  78. package/server/services/manager.js +11 -0
  79. package/server/services/messaging/automation.js +1 -1
  80. package/server/services/messaging/telnyx.js +12 -11
  81. package/server/services/messaging/whatsapp.js +1 -1
  82. package/server/services/recordings/manager.js +13 -7
  83. package/server/services/runtime/backends/local-vm.js +40 -22
  84. package/server/services/runtime/docker-vm-manager.js +157 -266
  85. package/server/services/runtime/guest_bootstrap.js +17 -5
  86. package/server/services/runtime/guest_image.js +182 -0
  87. package/server/services/runtime/manager.js +0 -1
  88. package/server/services/runtime/validation.js +3 -8
  89. package/server/services/tasks/runtime.js +103 -15
  90. package/server/services/tasks/schedule_utils.js +5 -5
  91. package/server/services/voice/runtimeManager.js +19 -10
  92. package/server/services/wearable/gateway.js +8 -5
  93. package/server/services/websocket.js +26 -8
  94. package/server/services/workspace/manager.js +37 -3
@@ -49,7 +49,7 @@ const {
49
49
  selectDeliverableWorkflow,
50
50
  validateDeliverableExecution,
51
51
  } = require('../deliverables');
52
- const { buildLoopPolicy, resolveToolResultLimits } = require('../loopPolicy');
52
+ const { buildLoopPolicy, resolveToolResultLimits, resolveChurnNudgeThreshold } = require('../loopPolicy');
53
53
  const { globalHooks } = require('../hooks');
54
54
  const { normalizeCompletionConfidence, shouldAcceptTaskComplete } = require('../completion');
55
55
  const { enforceRateLimits } = require('../rate_limits');
@@ -58,7 +58,10 @@ const { shortenRunId, summarizeForLog } = require('../logFormat');
58
58
  const { IterationBudget } = require('./iteration_budget');
59
59
  const {
60
60
  buildBlankAfterToolFailureGuidance,
61
+ buildRecoverableToolFailureGuidance,
62
+ isRecoverableInternalToolFailure,
61
63
  shouldContinueAfterBlankToolFailure,
64
+ shouldContinueAfterRecoverableToolFailure,
62
65
  } = require('./blank_recovery');
63
66
  const {
64
67
  shouldSendMessagingErrorFallback,
@@ -210,6 +213,9 @@ function buildErrorPatternGuidance(key, count) {
210
213
  // multiple iterations before self-correcting.
211
214
  const immediateGuides = {
212
215
  eisdir: 'That path is a directory or outside the workspace file-tool boundary. Use list_directory for workspace directories. Keep source files in the shared workspace before reading them with file tools.',
216
+ enoent: 'That path does not exist. Do not keep retrying the same missing file. Locate the correct file first with list_directory/search_files or verify whether the evidence only exists in the user-provided logs.',
217
+ outside_workspace: 'That path is outside the shared workspace. Use the workspace root and its file tools, or rely on the user-provided evidence if the file only exists on another server.',
218
+ bad_cwd: 'The current working directory is wrong for that path. Reconfirm the workspace root with pwd/list_directory before reading files.',
213
219
  owner_repo_format: 'The parameter "owner_repo" expects a single combined string like "NeoLabs-Systems/NeoAgent" — not separate owner/repo fields. Pass the full "owner/repo" as one value.',
214
220
  };
215
221
  if (immediateGuides[key]) {
@@ -284,6 +290,24 @@ function summarizeReadTargets(toolExecutions = []) {
284
290
  return targets.join('; ');
285
291
  }
286
292
 
293
+ function buildNoProgressWrapupPrompt({ readOnlyCount = 0, alreadyRead = '', platform = null } = {}) {
294
+ return [
295
+ `No-progress limit reached after ${Math.max(0, Number(readOnlyCount) || 0)} consecutive turns without substantive progress.`,
296
+ alreadyRead ? `Already inspected/searched: ${alreadyRead}.` : '',
297
+ 'This is the final turn for this run. Do not call tools.',
298
+ 'From the existing evidence, return the concrete result, a no-op/no-change answer, or a clear blocker.',
299
+ 'If an external site, service, permission, unavailable target, or missing user input prevents completion, say that clearly and stop.',
300
+ buildMaxIterationWrapupPrompt(platform),
301
+ ].filter(Boolean).join('\n\n');
302
+ }
303
+
304
+ function isDeliveryTerminated(runMeta, deliveryState) {
305
+ return runMeta?.noResponse === true
306
+ || deliveryState?.noResponse === true
307
+ || runMeta?.finalDeliverySent === true
308
+ || deliveryState?.finalDeliverySent === true;
309
+ }
310
+
287
311
  function cloneInterimHistory(history = []) {
288
312
  if (!Array.isArray(history)) return [];
289
313
  return history.map((item) => ({
@@ -483,6 +507,7 @@ async function getFailureFallbackModelId(userId, agentId, currentModelId, prefer
483
507
  const pool = enabledIds.length > 0
484
508
  ? availableModels.filter((model) => enabledIds.includes(model.id))
485
509
  : availableModels;
510
+ const fallbackSearchPool = pool.length > 0 ? pool : availableModels;
486
511
  const currentModel = pool.find((model) => model.id === currentModelId)
487
512
  || availableModels.find((model) => model.id === currentModelId)
488
513
  || null;
@@ -493,27 +518,26 @@ async function getFailureFallbackModelId(userId, agentId, currentModelId, prefer
493
518
  const isProviderRateLimit = /429|rate.?limit|free-models-per/i.test(String(failureError?.message || ''));
494
519
 
495
520
  if (preferredFallbackId && preferredFallbackId !== currentModelId && !isProviderRateLimit) {
496
- const preferred = pool.find((model) => model.id === preferredFallbackId)
521
+ const preferred = fallbackSearchPool.find((model) => model.id === preferredFallbackId)
497
522
  || availableModels.find((model) => model.id === preferredFallbackId);
498
523
  if (preferred) return preferred.id;
499
524
  }
500
525
 
501
526
  if (currentModel?.provider) {
502
- const differentProvider = pool.find((model) => model.id !== currentModelId && model.provider !== currentModel.provider)
503
- || availableModels.find((model) => model.id !== currentModelId && model.provider !== currentModel.provider);
527
+ const differentProvider = fallbackSearchPool.find((model) =>
528
+ model.id !== currentModelId && model.provider !== currentModel.provider);
504
529
  if (differentProvider) return differentProvider.id;
505
530
  }
506
531
 
507
532
  // If no different-provider model exists, still try the preferred fallback
508
533
  // even on rate limits (it's better than nothing).
509
534
  if (preferredFallbackId && preferredFallbackId !== currentModelId) {
510
- const preferred = pool.find((model) => model.id === preferredFallbackId)
535
+ const preferred = fallbackSearchPool.find((model) => model.id === preferredFallbackId)
511
536
  || availableModels.find((model) => model.id === preferredFallbackId);
512
537
  if (preferred) return preferred.id;
513
538
  }
514
539
 
515
- const differentModel = pool.find((model) => model.id !== currentModelId)
516
- || availableModels.find((model) => model.id !== currentModelId);
540
+ const differentModel = fallbackSearchPool.find((model) => model.id !== currentModelId);
517
541
  return differentModel?.id || null;
518
542
  }
519
543
 
@@ -529,13 +553,25 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
529
553
  const agentId = resolveAgentId(userId, options.agentId || options.agent_id || null);
530
554
  ensureDefaultAiSettings(userId, agentId);
531
555
  const aiSettings = getAiSettings(userId, agentId);
532
-
533
- enforceRateLimits(userId);
534
-
535
556
  const runId = options.runId || uuidv4();
536
557
  const conversationId = options.conversationId;
537
558
  const app = options.app || engine.app;
538
559
  const triggerSource = options.triggerSource || 'web';
560
+ let provider = null;
561
+ let model = null;
562
+ let providerName = null;
563
+ let messages = [];
564
+ let iteration = 0;
565
+ let totalTokens = 0;
566
+ let lastContent = '';
567
+ let stepIndex = 0;
568
+ let failedStepCount = 0;
569
+ let toolExecutions = [];
570
+ let deliverableWorkflow = null;
571
+
572
+ const { releaseReservation } = enforceRateLimits(userId);
573
+
574
+ try {
539
575
  const historyWindow = Math.max(
540
576
  1,
541
577
  Number(options.historyWindow || aiSettings.chat_history_window) || aiSettings.chat_history_window,
@@ -563,9 +599,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
563
599
  _modelOverride,
564
600
  providerStatusConfig
565
601
  );
566
- let provider = selectedProvider.provider;
567
- let model = selectedProvider.model;
568
- let providerName = selectedProvider.providerName;
602
+ provider = selectedProvider.provider;
603
+ model = selectedProvider.model;
604
+ providerName = selectedProvider.providerName;
569
605
  const switchToFallbackModel = async (failedModel, error, phase) => {
570
606
  const fallbackModelId = await getFailureFallbackModelId(userId, agentId, failedModel, aiSettings.fallback_model_id, error);
571
607
  if (!fallbackModelId || fallbackModelId === failedModel) return false;
@@ -779,7 +815,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
779
815
  historyMessages = (options.priorMessages || []).slice(-historyWindow).filter((pm) => pm.role && pm.content);
780
816
  }
781
817
 
782
- let messages = engine.buildContextMessages(systemPrompt, summaryMessage, historyMessages, recallMsg);
818
+ messages = engine.buildContextMessages(systemPrompt, summaryMessage, historyMessages, recallMsg);
783
819
  if (capabilitySummary) {
784
820
  messages.push({ role: 'system', content: `[Capability health]\n${capabilitySummary}` });
785
821
  }
@@ -809,14 +845,14 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
809
845
  .run(conversationId, 'user', userMessage);
810
846
  }
811
847
 
812
- let iteration = 0;
813
- let totalTokens = 0;
814
- let lastContent = '';
815
- let stepIndex = 0;
816
- let failedStepCount = 0;
848
+ iteration = 0;
849
+ totalTokens = 0;
850
+ lastContent = '';
851
+ stepIndex = 0;
852
+ failedStepCount = 0;
817
853
  let modelFailureRecoveries = 0;
818
854
  let promptMetrics = {};
819
- let toolExecutions = [];
855
+ toolExecutions = [];
820
856
  let compactionMetrics = [];
821
857
  let analysis = null;
822
858
  let plan = null;
@@ -877,15 +913,14 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
877
913
  }
878
914
  }
879
915
  let verification = null;
880
- let deliverableWorkflow = null;
916
+ deliverableWorkflow = null;
881
917
  let deliverablePlan = null;
882
918
  let deliverableArtifacts = [];
883
919
  let deliverableValidation = null;
884
920
  let directAnswerEligible = false;
885
921
  let analysisUsage = 0;
886
922
 
887
- try {
888
- if (options.skipTaskAnalysis === true) {
923
+ if (options.skipTaskAnalysis === true) {
889
924
  analysis = buildSkipTaskAnalysisResult(options.forceMode);
890
925
  } else {
891
926
  const analysisResult = await runWithModelFallback('task analysis', () => engine.analyzeTask({
@@ -1169,17 +1204,163 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1169
1204
  messages = steeringAtLoopStart.messages;
1170
1205
  messages = sanitizeConversationMessages(messages);
1171
1206
 
1172
- // Analysis-paralysis gate: fire at the start of every iteration where
1173
- // the agent has spent N turns only reading/listing/searching without
1174
- // taking any concrete action. Escalates in urgency each turn.
1207
+ // Analysis-paralysis gate: AI self-assesses at churnNudgeThreshold; hard
1208
+ // force-wrap-up fires at maxConsecutiveReadOnlyIterations unconditionally.
1175
1209
  if (analysis.mode === 'execute' || analysis.mode === 'plan_execute') {
1176
1210
  const readOnlyCount = engine.getRunMeta(runId)?.consecutiveReadOnlyIterations || 0;
1177
- if (readOnlyCount >= 3) {
1178
- const alreadyRead = summarizeReadTargets(toolExecutions);
1179
- messages.push({
1180
- role: 'system',
1181
- content: buildReadOnlyChurnGuidance({ readOnlyCount, alreadyRead }),
1182
- });
1211
+ const iterMeta = engine.getRunMeta(runId);
1212
+ const latestFailedExecution = toolExecutions.length > 0
1213
+ ? [...toolExecutions].reverse().find((item) => item && item.ok === false) || null
1214
+ : null;
1215
+
1216
+ if (readOnlyCount >= 2) {
1217
+ const runGoalCtx = resolveRunGoalContext(engine.getRunMeta(runId), analysis, plan);
1218
+ const churnNudgeThreshold = resolveChurnNudgeThreshold(runGoalCtx.goalContract);
1219
+
1220
+ let triggerForceWrapup = false;
1221
+ let forceWrapupSource = 'hard_limit';
1222
+ let alreadyRead = '';
1223
+
1224
+ if (readOnlyCount >= loopPolicy.maxConsecutiveReadOnlyIterations) {
1225
+ if (
1226
+ isRecoverableInternalToolFailure(latestFailedExecution)
1227
+ && iterMeta
1228
+ && iterMeta.recoverableReadOnlyDeferralUsed !== true
1229
+ ) {
1230
+ iterMeta.recoverableReadOnlyDeferralUsed = true;
1231
+ iterMeta.consecutiveReadOnlyIterations = Math.max(0, loopPolicy.maxConsecutiveReadOnlyIterations - 2);
1232
+ messages.push({
1233
+ role: 'system',
1234
+ content: buildRecoverableToolFailureGuidance(toolExecutions),
1235
+ });
1236
+ engine.recordRunEvent(userId, runId, 'read_only_wrapup_deferred_for_recovery', {
1237
+ iteration,
1238
+ readOnlyCount,
1239
+ toolName: latestFailedExecution?.toolName || null,
1240
+ }, { agentId });
1241
+ continue;
1242
+ }
1243
+ alreadyRead = summarizeReadTargets(toolExecutions);
1244
+ triggerForceWrapup = true;
1245
+ } else if (readOnlyCount >= churnNudgeThreshold) {
1246
+ alreadyRead = summarizeReadTargets(toolExecutions);
1247
+ let churnResult;
1248
+ try {
1249
+ churnResult = await engine.assessChurnState({
1250
+ provider,
1251
+ providerName,
1252
+ model,
1253
+ messages,
1254
+ analysis,
1255
+ plan,
1256
+ toolExecutions,
1257
+ readOnlyCount,
1258
+ alreadyRead,
1259
+ iteration,
1260
+ options: { ...options, triggerSource, runId, userId, agentId },
1261
+ });
1262
+ } catch (churnErr) {
1263
+ console.warn(`[Run ${shortenRunId(runId)}] churn_assessment failed: ${summarizeForLog(churnErr?.message || churnErr, 120)}`);
1264
+ churnResult = { assessment: { assessment: 'churn', reason: '' }, usage: 0 };
1265
+ }
1266
+ totalTokens += churnResult.usage || 0;
1267
+ engine.recordRunEvent(userId, runId, 'churn_assessment', {
1268
+ assessment: churnResult.assessment.assessment,
1269
+ reason: churnResult.assessment.reason,
1270
+ readOnlyCount,
1271
+ churnNudgeThreshold,
1272
+ iteration,
1273
+ }, { agentId });
1274
+
1275
+ const churnVerdict = churnResult.assessment.assessment;
1276
+ if (churnVerdict === 'blocked') {
1277
+ triggerForceWrapup = true;
1278
+ forceWrapupSource = 'ai_blocked';
1279
+ } else if (churnVerdict === 'progressing') {
1280
+ // Model is genuinely on track — partially reset so it gets
1281
+ // re-assessed after one more read-only turn rather than immediately.
1282
+ const iterMeta = engine.getRunMeta(runId);
1283
+ if (iterMeta) {
1284
+ iterMeta.consecutiveReadOnlyIterations = Math.max(0, churnNudgeThreshold - 1);
1285
+ }
1286
+ } else {
1287
+ // 'churn' — model acknowledges it is spinning; inject the nudge
1288
+ // so it can course-correct in the next iteration.
1289
+ messages.push({
1290
+ role: 'system',
1291
+ content: buildReadOnlyChurnGuidance({ readOnlyCount, alreadyRead }),
1292
+ });
1293
+ }
1294
+ }
1295
+
1296
+ if (triggerForceWrapup) {
1297
+ console.warn(
1298
+ `[Run ${shortenRunId(runId)}] no_progress_wrapup source=${forceWrapupSource} readOnlyCount=${readOnlyCount}`
1299
+ );
1300
+ engine.updateRunProgress(runId, {
1301
+ currentPhase: 'model',
1302
+ currentStep: 'model:no_progress_wrapup',
1303
+ currentTool: null,
1304
+ currentStepStartedAt: isoNow(),
1305
+ });
1306
+ let wrapTokens = 0;
1307
+ try {
1308
+ const wrapResponse = await withModelCallTimeout(
1309
+ provider.chat(
1310
+ sanitizeConversationMessages([
1311
+ ...messages,
1312
+ {
1313
+ role: 'system',
1314
+ content: buildNoProgressWrapupPrompt({
1315
+ readOnlyCount,
1316
+ alreadyRead,
1317
+ platform: options?.source || null,
1318
+ }),
1319
+ },
1320
+ ]),
1321
+ [],
1322
+ { model, reasoningEffort: engine.getReasoningEffort(providerName, options) },
1323
+ ),
1324
+ options,
1325
+ 'No-progress wrap-up',
1326
+ );
1327
+ wrapTokens = wrapResponse.usage?.totalTokens || 0;
1328
+ lastContent = sanitizeModelOutput(wrapResponse.content || '', { model });
1329
+ } catch (wrapErr) {
1330
+ console.warn(`[Run ${shortenRunId(runId)}] no_progress_wrapup failed: ${summarizeForLog(wrapErr?.message || wrapErr, 180)}`);
1331
+ }
1332
+ totalTokens += wrapTokens;
1333
+ const usableWrap = normalizeOutgoingMessage(lastContent, options?.source || null);
1334
+ if (!usableWrap) {
1335
+ lastContent = buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
1336
+ }
1337
+ messages.push({ role: 'assistant', content: lastContent });
1338
+ if (conversationId) {
1339
+ db.prepare('INSERT INTO conversation_messages (conversation_id, role, content, tokens) VALUES (?, ?, ?, ?)')
1340
+ .run(conversationId, 'assistant', lastContent, usableWrap ? wrapTokens : 0);
1341
+ }
1342
+ engine.recordRunEvent(userId, runId, 'no_progress_wrapup_delivered', {
1343
+ iteration,
1344
+ readOnlyCount,
1345
+ source: usableWrap ? 'model' : 'deterministic',
1346
+ forceWrapupSource,
1347
+ stepIndex,
1348
+ }, { agentId });
1349
+ // This wrap-up is a forced, tool-less terminal answer: the model had no
1350
+ // way to call send_message itself. On automatic background runs the plain
1351
+ // result is normally gated, which would silently drop this. Mark it so the
1352
+ // task runtime delivers it — a stuck/blocked scheduled task must still
1353
+ // surface its result instead of going silent.
1354
+ if (
1355
+ (triggerSource === 'schedule' || triggerSource === 'tasks')
1356
+ && options.deliveryState
1357
+ && !engine.activeRuns.get(runId)?.messagingSent
1358
+ ) {
1359
+ options.deliveryState.terminalWrapup = true;
1360
+ }
1361
+ directAnswerEligible = true;
1362
+ break;
1363
+ }
1183
1364
  }
1184
1365
  }
1185
1366
 
@@ -1410,6 +1591,22 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1410
1591
  lastContent = '';
1411
1592
  continue;
1412
1593
  }
1594
+ if (shouldContinueAfterRecoverableToolFailure({
1595
+ lastContent,
1596
+ remainingIterations: iterationBudget.remaining,
1597
+ toolExecutions,
1598
+ })) {
1599
+ engine.recordRunEvent(userId, runId, 'recoverable_tool_failure_continued', {
1600
+ iteration,
1601
+ remainingIterations: iterationBudget.remaining,
1602
+ }, { agentId });
1603
+ messages.push({
1604
+ role: 'system',
1605
+ content: buildRecoverableToolFailureGuidance(toolExecutions),
1606
+ });
1607
+ lastContent = '';
1608
+ continue;
1609
+ }
1413
1610
  const loopDecision = await engine.decideLoopState({
1414
1611
  provider,
1415
1612
  providerName,
@@ -1908,6 +2105,9 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1908
2105
  if (runMeta?.terminalInterim) {
1909
2106
  break;
1910
2107
  }
2108
+ if (isDeliveryTerminated(runMeta, options.deliveryState)) {
2109
+ break;
2110
+ }
1911
2111
  }
1912
2112
 
1913
2113
  // Update analysis-paralysis counter after each iteration's tool calls.
@@ -1924,6 +2124,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1924
2124
 
1925
2125
  if (engine.isRunStopped(runId)) break;
1926
2126
  if (engine.getRunMeta(runId)?.terminalInterim) break;
2127
+ if (isDeliveryTerminated(engine.getRunMeta(runId), options.deliveryState)) break;
1927
2128
  if (engine.getRunMeta(runId)?.widgetSnapshotSaved) break;
1928
2129
  if (!engine.activeRuns.has(runId)) break;
1929
2130
  }
@@ -1974,6 +2175,12 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
1974
2175
  lastContent = 'Widget snapshot updated.';
1975
2176
  }
1976
2177
  const messagingSent = runMeta?.messagingSent || false;
2178
+ const stagedProactiveReply = normalizeOutgoingMessage(
2179
+ runMeta?.stagedProactiveMessage?.content
2180
+ || options?.deliveryState?.stagedProactiveMessage?.content
2181
+ || '',
2182
+ options?.source || null,
2183
+ );
1977
2184
  const lastToolWasMessaging = runMeta?.lastToolName === 'send_message' || runMeta?.lastToolName === 'make_call';
1978
2185
 
1979
2186
  // Hermes _handle_max_iterations: if the run exhausted its step budget without a
@@ -2079,6 +2286,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2079
2286
  if (
2080
2287
  !normalizeOutgoingMessage(lastContent, options?.source || null)
2081
2288
  && !messagingSent
2289
+ && !stagedProactiveReply
2082
2290
  && runMeta?.widgetSnapshotSaved !== true
2083
2291
  ) {
2084
2292
  const explicitNoResponse = (
@@ -2128,10 +2336,11 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2128
2336
  const normalizedLastContent = normalizeOutgoingMessage(lastContent, options?.source || null);
2129
2337
  let finalResponseText = messagingSent
2130
2338
  ? (sentMessageText || (normalizedLastContent ? lastContent.trim() : ''))
2131
- : (normalizedLastContent ? lastContent.trim() : sentMessageText);
2339
+ : (normalizedLastContent ? lastContent.trim() : (stagedProactiveReply || sentMessageText));
2132
2340
  const lastFinalDeliveryMessage = normalizeOutgoingMessage(
2133
2341
  runMeta?.lastSentMessage
2134
2342
  || (Array.isArray(runMeta?.sentMessages) ? runMeta.sentMessages[runMeta.sentMessages.length - 1] : '')
2343
+ || runMeta?.stagedProactiveMessage?.content
2135
2344
  || '',
2136
2345
  options?.source || null
2137
2346
  );
@@ -2469,7 +2678,12 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
2469
2678
  }
2470
2679
 
2471
2680
  throw err;
2681
+ } finally {
2682
+ releaseReservation();
2472
2683
  }
2473
2684
  }
2474
2685
 
2475
- module.exports = { runConversation };
2686
+ module.exports = {
2687
+ getFailureFallbackModelId,
2688
+ runConversation,
2689
+ };
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const FIRST_UPDATE_MS = 30 * 1000;
4
- const REPEAT_UPDATE_MS = 75 * 1000;
5
- const STALL_MS = 150 * 1000;
4
+ const REPEAT_UPDATE_MS = 120 * 1000;
5
+ const STALL_MS = 240 * 1000;
6
6
  const TICK_MS = 15 * 1000;
7
7
 
8
8
  function isoNow() {
@@ -60,13 +60,13 @@ function evaluateProgressLiveness(runMeta, now = Date.now()) {
60
60
 
61
61
  function buildProgressNudge({ stalled = false } = {}) {
62
62
  return [
63
- 'Mandatory internal progress check for the active messaging run.',
63
+ 'Internal progress check for the active messaging run.',
64
64
  stalled
65
65
  ? 'No verified progress has been recorded for the stall threshold.'
66
66
  : 'The originating chat has not received a user-visible update for the progress threshold.',
67
- 'Before starting more tool work, either send a concise model-authored interim update with send_interim_update, report a real blocker, or finish with the final answer.',
68
- 'Do not continue silently once this check is present unless the immediate next action itself delivers a final answer or explicit no-response decision.',
69
- 'Do not repeat previous status text and do not treat an interim update as final delivery.',
67
+ 'Before starting more tool work, decide whether the user has a materially useful new status, a real blocker, a final answer, or no user-visible update is needed.',
68
+ 'Only send_interim_update when there is new user-relevant progress or a blocker. Continue silently if nothing materially changed.',
69
+ 'Never expose this progress check, internal state, or tool bookkeeping to the user, and do not treat an interim update as final delivery.',
70
70
  ].join(' ');
71
71
  }
72
72
 
@@ -1,59 +1,39 @@
1
- /**
2
- * loopPolicy.js
3
- *
4
- * Single source of truth for every tunable limit in the agent loop.
5
- * No magic numbers live in engine.js everything flows from here.
6
- *
7
- * Values resolve in priority order:
8
- * 1. Per-run option override (options.*)
9
- * 2. Agent AI settings (aiSettings.*)
10
- * 3. Hardcoded sane default
11
- *
12
- * "Open but stable": limits exist as safety nets, not as the primary
13
- * exit signal. The AI signals completion via task_complete; these
14
- * numbers only fire when something goes wrong.
15
- */
16
-
17
- // The iteration count is intentionally NOT a real limit: the agent runs until it
18
- // signals completion (task_complete / judged terminal), an external blocker, or a
19
- // genuine stuck-state guard fires (consecutive tool failures, repetition guard,
20
- // model-failure recoveries). This number only exists so arithmetic/logging have a
21
- // finite sentinel; it is set far above any real task length so it never cuts a run.
22
- const UNLIMITED_ITERATIONS = 1_000_000;
23
-
24
- const DEFAULT_MAX_ITERATIONS = UNLIMITED_ITERATIONS;
25
- const DEFAULT_WIDGET_MAX_ITERATIONS = UNLIMITED_ITERATIONS;
26
- const DEFAULT_PLAN_EXECUTE_MAX_ITERATIONS = UNLIMITED_ITERATIONS;
1
+ // Limits resolve in priority order: per-run options → agent AI settings → hardcoded defaults.
2
+ // They are safety nets only; task_complete / progress guards are the real exit signals.
3
+
4
+ // The iteration ceiling is a pure runaway safety net, NOT the primary stop signal.
5
+ // A run stops when it makes no real progress (consecutiveReadOnlyIterations cap,
6
+ // which resets the moment the agent does anything state-changing), or when the
7
+ // repetition / tool-failure / model-recovery guards fire, or when the model signals
8
+ // task_complete. These ceilings are set high so they only ever catch a genuine
9
+ // runaway and never guillotine a long, legitimately-progressing complex task.
10
+ const DEFAULT_MAX_ITERATIONS = 250;
11
+ const DEFAULT_WIDGET_MAX_ITERATIONS = 150;
12
+ const DEFAULT_PLAN_EXECUTE_MAX_ITERATIONS = 250;
27
13
  // Less aggressive than 0.60 so the model retains file contents it already read for
28
14
  // longer, instead of losing them to compaction and re-reading the same files.
29
15
  const DEFAULT_COMPACTION_THRESHOLD = 0.80;
16
+ // The real "stop when stuck" guard. Counts consecutive turns of pure reading/
17
+ // searching/inspecting with zero state change; resets to 0 on any concrete progress.
18
+ const DEFAULT_MAX_CONSECUTIVE_READ_ONLY_ITERATIONS = 8;
30
19
  const DEFAULT_MAX_CONSECUTIVE_TOOL_FAILURES = 5;
31
20
  const DEFAULT_MAX_MODEL_FAILURE_RECOVERIES = 3;
32
21
 
33
- // Hard ceiling — only guards against absurd/overflow config values, not task length.
34
- const MAX_ALLOWED_ITERATIONS = UNLIMITED_ITERATIONS;
22
+ const MAX_ALLOWED_ITERATIONS = 400;
23
+ const MAX_ALLOWED_READ_ONLY_ITERATIONS = 25;
35
24
  const MAX_ALLOWED_TOOL_FAILURES = 50;
36
25
  const MAX_ALLOWED_MODEL_RECOVERIES = 10;
37
26
  const MAX_ALLOWED_BUDGET_CHARS = 500_000;
38
27
 
39
- /** Return n if finite and positive, otherwise fallback. */
40
28
  function finitePositive(n, fallback) {
41
29
  return Number.isFinite(n) && n > 0 ? n : fallback;
42
30
  }
43
31
 
44
- /** Clamp n to [lo, hi]; return fallback if not finite. */
45
32
  function clampFinite(n, lo, hi, fallback) {
46
33
  if (!Number.isFinite(n)) return fallback;
47
34
  return Math.min(Math.max(n, lo), hi);
48
35
  }
49
36
 
50
- /**
51
- * @param {object} aiSettings - from getAiSettings()
52
- * @param {string} triggerType - 'chat' | 'schedule' | 'subagent' | etc.
53
- * @param {string} analysisMode - 'direct_answer' | 'execute' | 'plan_execute'
54
- * @param {object} options - per-run options (may override anything)
55
- * @returns {LoopPolicy}
56
- */
57
37
  function buildLoopPolicy(aiSettings = {}, triggerType = 'chat', analysisMode = 'execute', options = {}) {
58
38
  const autonomyPolicy = options.autonomyPolicy && typeof options.autonomyPolicy === 'object'
59
39
  ? options.autonomyPolicy
@@ -112,6 +92,16 @@ function buildLoopPolicy(aiSettings = {}, triggerType = 'chat', analysisMode = '
112
92
  DEFAULT_MAX_MODEL_FAILURE_RECOVERIES,
113
93
  );
114
94
 
95
+ const rawReadOnlyIterations = options.maxConsecutiveReadOnlyIterations != null
96
+ ? Number(options.maxConsecutiveReadOnlyIterations)
97
+ : Number(aiSettings.max_consecutive_read_only_iterations);
98
+ const maxConsecutiveReadOnlyIterations = clampFinite(
99
+ Math.floor(rawReadOnlyIterations),
100
+ 3,
101
+ MAX_ALLOWED_READ_ONLY_ITERATIONS,
102
+ DEFAULT_MAX_CONSECUTIVE_READ_ONLY_ITERATIONS,
103
+ );
104
+
115
105
  // compactionThreshold must be in (0, 1]; clamp to [0.1, 1].
116
106
  const compactionThreshold = clampFinite(
117
107
  Number(aiSettings.compaction_threshold),
@@ -122,6 +112,7 @@ function buildLoopPolicy(aiSettings = {}, triggerType = 'chat', analysisMode = '
122
112
 
123
113
  return {
124
114
  maxIterations,
115
+ maxConsecutiveReadOnlyIterations,
125
116
  maxConsecutiveToolFailures,
126
117
  maxModelFailureRecoveries,
127
118
 
@@ -142,9 +133,6 @@ function buildLoopPolicy(aiSettings = {}, triggerType = 'chat', analysisMode = '
142
133
  };
143
134
  }
144
135
 
145
- /**
146
- * Map a tool name to its result-size category.
147
- */
148
136
  function getToolCategory(toolName) {
149
137
  if (!toolName) return 'default';
150
138
  if (/^(read_file|write_file|search_files|list_directory|file_)/.test(toolName)) return 'file';
@@ -153,9 +141,6 @@ function getToolCategory(toolName) {
153
141
  return 'default';
154
142
  }
155
143
 
156
- /**
157
- * Resolve soft + hard limits for a specific tool from the policy.
158
- */
159
144
  function resolveToolResultLimits(toolName, policy) {
160
145
  const category = getToolCategory(toolName);
161
146
  const soft = policy.toolResultBudget[category] ?? policy.toolResultBudget.default;
@@ -163,4 +148,12 @@ function resolveToolResultLimits(toolName, policy) {
163
148
  return { softLimit: soft, hardLimit: hard };
164
149
  }
165
150
 
166
- module.exports = { buildLoopPolicy, getToolCategory, resolveToolResultLimits };
151
+ function resolveChurnNudgeThreshold(goalContract) {
152
+ const complexity = String(goalContract?.complexity || 'standard').toLowerCase();
153
+ const autonomyLevel = String(goalContract?.autonomyLevel || goalContract?.autonomy_level || 'normal').toLowerCase();
154
+ if (complexity === 'simple') return 2;
155
+ if (complexity === 'complex' || autonomyLevel === 'high') return 5;
156
+ return 3;
157
+ }
158
+
159
+ module.exports = { buildLoopPolicy, getToolCategory, resolveToolResultLimits, resolveChurnNudgeThreshold };