neoagent 2.4.1-beta.8 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/.env.example +33 -3
  2. package/LICENSE +111 -56
  3. package/README.md +8 -3
  4. package/docs/configuration.md +8 -0
  5. package/docs/getting-started.md +9 -3
  6. package/docs/index.md +4 -0
  7. package/extensions/chrome-browser/background.mjs +45 -7
  8. package/extensions/chrome-browser/icons/icon128.png +0 -0
  9. package/extensions/chrome-browser/icons/icon16.png +0 -0
  10. package/extensions/chrome-browser/icons/icon48.png +0 -0
  11. package/extensions/chrome-browser/icons/logo.svg +39 -8
  12. package/extensions/chrome-browser/manifest.json +3 -2
  13. package/extensions/chrome-browser/popup.html +5 -1
  14. package/extensions/chrome-browser/popup.js +15 -2
  15. package/flutter_app/android/app/src/main/AndroidManifest.xml +2 -1
  16. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  17. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  18. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  19. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  20. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  21. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  22. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  23. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  24. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  25. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  26. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  27. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  28. package/flutter_app/assets/branding/app_icon_light_1024.png +0 -0
  29. package/flutter_app/assets/branding/app_icon_light_128.png +0 -0
  30. package/flutter_app/assets/branding/app_icon_light_192.png +0 -0
  31. package/flutter_app/assets/branding/app_icon_light_256.png +0 -0
  32. package/flutter_app/assets/branding/app_icon_light_32.png +0 -0
  33. package/flutter_app/assets/branding/app_icon_light_512.png +0 -0
  34. package/flutter_app/assets/branding/app_icon_light_64.png +0 -0
  35. package/flutter_app/assets/branding/onboarding_intro.mp4 +0 -0
  36. package/flutter_app/assets/branding/tray_icon_light_template.png +0 -0
  37. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  38. package/flutter_app/lib/features/location/location_service.dart +3 -0
  39. package/flutter_app/lib/features/onboarding/onboarding_chrome.dart +391 -382
  40. package/flutter_app/lib/features/onboarding/onboarding_companion_step.dart +743 -0
  41. package/flutter_app/lib/features/onboarding/onboarding_messaging_step.dart +18 -16
  42. package/flutter_app/lib/features/onboarding/onboarding_model_step.dart +19 -18
  43. package/flutter_app/lib/features/onboarding/onboarding_shell.dart +8 -1
  44. package/flutter_app/lib/features/onboarding/onboarding_video_step.dart +16 -13
  45. package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +17 -13
  46. package/flutter_app/lib/main.dart +3 -0
  47. package/flutter_app/lib/main_account_settings.dart +10 -34
  48. package/flutter_app/lib/main_admin.dart +14 -1
  49. package/flutter_app/lib/main_app_shell.dart +377 -340
  50. package/flutter_app/lib/main_chat.dart +707 -227
  51. package/flutter_app/lib/main_controller.dart +338 -46
  52. package/flutter_app/lib/main_devices.dart +851 -122
  53. package/flutter_app/lib/main_integrations.dart +255 -6
  54. package/flutter_app/lib/main_launcher.dart +1 -1
  55. package/flutter_app/lib/main_model_picker.dart +685 -0
  56. package/flutter_app/lib/main_models.dart +212 -0
  57. package/flutter_app/lib/main_navigation.dart +1 -9
  58. package/flutter_app/lib/main_operations.dart +1417 -614
  59. package/flutter_app/lib/main_settings.dart +764 -887
  60. package/flutter_app/lib/main_shared.dart +971 -443
  61. package/flutter_app/lib/main_spacing.dart +4 -4
  62. package/flutter_app/lib/main_theme.dart +22 -14
  63. package/flutter_app/lib/main_unified.dart +5 -72
  64. package/flutter_app/lib/src/android_apk_drop_zone.dart +24 -0
  65. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +13 -0
  66. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +219 -0
  67. package/flutter_app/lib/src/backend_client.dart +79 -0
  68. package/flutter_app/lib/src/desktop_companion_actions.dart +56 -7
  69. package/flutter_app/lib/src/desktop_companion_io.dart +77 -1
  70. package/flutter_app/lib/src/desktop_native_bridge.dart +13 -0
  71. package/flutter_app/lib/src/security/password_strength.dart +84 -0
  72. package/flutter_app/lib/src/stream_renderer.dart +205 -35
  73. package/flutter_app/lib/src/theme/palette.dart +76 -34
  74. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  75. package/flutter_app/macos/Runner/AppDelegate.swift +44 -0
  76. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  77. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  78. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  79. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  80. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  81. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  82. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  83. package/flutter_app/pubspec.lock +2 -2
  84. package/flutter_app/pubspec.yaml +7 -1
  85. package/flutter_app/tool/branding_source/neoagent-icon-1024.png +0 -0
  86. package/flutter_app/tool/branding_source/neoagent-icon-128.png +0 -0
  87. package/flutter_app/tool/branding_source/neoagent-icon-16.png +0 -0
  88. package/flutter_app/tool/branding_source/neoagent-icon-180.png +0 -0
  89. package/flutter_app/tool/branding_source/neoagent-icon-192.png +0 -0
  90. package/flutter_app/tool/branding_source/neoagent-icon-256.png +0 -0
  91. package/flutter_app/tool/branding_source/neoagent-icon-32.png +0 -0
  92. package/flutter_app/tool/branding_source/neoagent-icon-48.png +0 -0
  93. package/flutter_app/tool/branding_source/neoagent-icon-512.png +0 -0
  94. package/flutter_app/tool/branding_source/neoagent-icon-64.png +0 -0
  95. package/flutter_app/tool/branding_source/neoagent-icon.svg +43 -0
  96. package/flutter_app/tool/generate_desktop_branding.py +154 -152
  97. package/flutter_app/web/favicon.png +0 -0
  98. package/flutter_app/web/favicon.svg +40 -9
  99. package/flutter_app/web/favicon_light.svg +43 -0
  100. package/flutter_app/web/icons/Icon-192-light.png +0 -0
  101. package/flutter_app/web/icons/Icon-192.png +0 -0
  102. package/flutter_app/web/icons/Icon-512-light.png +0 -0
  103. package/flutter_app/web/icons/Icon-512.png +0 -0
  104. package/flutter_app/web/icons/Icon-maskable-192-light.png +0 -0
  105. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  106. package/flutter_app/web/icons/Icon-maskable-512-light.png +0 -0
  107. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  108. package/flutter_app/windows/runner/main.cpp +7 -1
  109. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  110. package/lib/manager.js +445 -83
  111. package/package.json +17 -3
  112. package/runtime/paths.js +3 -1
  113. package/server/admin/access.js +198 -0
  114. package/server/admin/admin.css +268 -0
  115. package/server/admin/admin.js +348 -0
  116. package/server/admin/analytics.js +128 -0
  117. package/server/admin/index.html +1015 -0
  118. package/server/admin/login.html +290 -0
  119. package/server/admin/logo.svg +43 -0
  120. package/server/admin/sql.js +134 -0
  121. package/server/admin/users.js +147 -0
  122. package/server/config/origins.js +3 -1
  123. package/server/db/database.js +92 -0
  124. package/server/db/ftsQuery.js +27 -0
  125. package/server/http/routes.js +1 -0
  126. package/server/http/static.js +23 -6
  127. package/server/index.js +1 -1
  128. package/server/middleware/adminAuth.js +48 -0
  129. package/server/middleware/auth.js +1 -40
  130. package/server/public/.last_build_id +1 -1
  131. package/server/public/app_icon.png +0 -0
  132. package/server/public/assets/AssetManifest.bin +1 -1
  133. package/server/public/assets/AssetManifest.bin.json +1 -1
  134. package/server/public/assets/assets/branding/app_icon_1024.png +0 -0
  135. package/server/public/assets/assets/branding/app_icon_256.png +0 -0
  136. package/server/public/assets/assets/branding/app_icon_512.png +0 -0
  137. package/server/public/assets/assets/branding/app_icon_light_1024.png +0 -0
  138. package/server/public/assets/assets/branding/app_icon_light_256.png +0 -0
  139. package/server/public/assets/assets/branding/app_icon_light_512.png +0 -0
  140. package/server/public/assets/assets/branding/onboarding_intro.mp4 +0 -0
  141. package/server/public/assets/assets/branding/tray_icon_light_template.png +0 -0
  142. package/server/public/assets/assets/branding/tray_icon_template.png +0 -0
  143. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  144. package/server/public/assets/web/icons/Icon-192.png +0 -0
  145. package/server/public/favicon.png +0 -0
  146. package/server/public/favicon.svg +40 -9
  147. package/server/public/favicon_light.svg +43 -0
  148. package/server/public/flutter_bootstrap.js +2 -2
  149. package/server/public/icons/Icon-192-light.png +0 -0
  150. package/server/public/icons/Icon-192.png +0 -0
  151. package/server/public/icons/Icon-512-light.png +0 -0
  152. package/server/public/icons/Icon-512.png +0 -0
  153. package/server/public/icons/Icon-maskable-192-light.png +0 -0
  154. package/server/public/icons/Icon-maskable-192.png +0 -0
  155. package/server/public/icons/Icon-maskable-512-light.png +0 -0
  156. package/server/public/icons/Icon-maskable-512.png +0 -0
  157. package/server/public/main.dart.js +88944 -85720
  158. package/server/routes/admin.js +632 -0
  159. package/server/routes/agent_profiles.js +3 -0
  160. package/server/routes/agents.js +1 -1
  161. package/server/routes/auth.js +3 -1
  162. package/server/routes/browser.js +14 -0
  163. package/server/routes/browser_extension.js +21 -4
  164. package/server/routes/desktop.js +10 -0
  165. package/server/routes/mcp.js +29 -13
  166. package/server/routes/memory.js +23 -1
  167. package/server/routes/messaging.js +2 -0
  168. package/server/routes/screenHistory.js +14 -8
  169. package/server/routes/settings.js +14 -2
  170. package/server/routes/stream.js +12 -3
  171. package/server/routes/triggers.js +4 -4
  172. package/server/routes/voice_assistant.js +36 -1
  173. package/server/routes/workspace.js +86 -0
  174. package/server/services/account/admin_two_factor.js +132 -0
  175. package/server/services/account/password_policy.js +6 -1
  176. package/server/services/ai/completion.js +44 -0
  177. package/server/services/ai/engine.js +321 -500
  178. package/server/services/ai/imageAnalysis.js +9 -5
  179. package/server/services/ai/logFormat.js +46 -0
  180. package/server/services/ai/loopPolicy.js +11 -0
  181. package/server/services/ai/messagingFallback.js +228 -0
  182. package/server/services/ai/models.js +194 -55
  183. package/server/services/ai/providerRetry.js +169 -0
  184. package/server/services/ai/providers/anthropic.js +15 -4
  185. package/server/services/ai/providers/google.js +38 -11
  186. package/server/services/ai/providers/grok.js +19 -68
  187. package/server/services/ai/providers/grokOauth.js +141 -0
  188. package/server/services/ai/providers/nvidia.js +154 -0
  189. package/server/services/ai/providers/ollama.js +76 -39
  190. package/server/services/ai/providers/openai.js +20 -31
  191. package/server/services/ai/providers/openaiCodex.js +6 -2
  192. package/server/services/ai/providers/openaiCompatible.js +70 -0
  193. package/server/services/ai/providers/openrouter.js +162 -0
  194. package/server/services/ai/settings.js +30 -0
  195. package/server/services/ai/systemPrompt.js +51 -29
  196. package/server/services/ai/taskAnalysis.js +60 -1
  197. package/server/services/ai/toolEvidence.js +207 -0
  198. package/server/services/ai/toolSelector.js +8 -1
  199. package/server/services/ai/tools.js +80 -15
  200. package/server/services/android/controller.js +36 -1
  201. package/server/services/browser/controller.js +18 -0
  202. package/server/services/browser/extension/manifest.js +33 -0
  203. package/server/services/browser/extension/provider.js +12 -6
  204. package/server/services/browser/extension/registry.js +229 -18
  205. package/server/services/desktop/protocol.js +1 -0
  206. package/server/services/desktop/provider.js +4 -0
  207. package/server/services/desktop/registry.js +104 -1
  208. package/server/services/desktop/screenRecorder.js +208 -98
  209. package/server/services/desktop/screen_recorder_support.js +46 -0
  210. package/server/services/integrations/google/provider.js +13 -0
  211. package/server/services/integrations/home_assistant/constants.js +88 -0
  212. package/server/services/integrations/home_assistant/network.js +207 -0
  213. package/server/services/integrations/home_assistant/provider.js +249 -0
  214. package/server/services/integrations/home_assistant/snapshot.js +98 -0
  215. package/server/services/integrations/home_assistant/tools.js +101 -0
  216. package/server/services/integrations/manager.js +5 -0
  217. package/server/services/integrations/registry.js +2 -0
  218. package/server/services/integrations/trello/provider.js +8 -2
  219. package/server/services/manager.js +51 -47
  220. package/server/services/mcp/client.js +208 -268
  221. package/server/services/mcp/client_support.js +172 -0
  222. package/server/services/mcp/recovery.js +116 -0
  223. package/server/services/mcp/tool_operations.js +123 -0
  224. package/server/services/memory/ingestion.js +185 -370
  225. package/server/services/memory/ingestion_coverage.js +129 -0
  226. package/server/services/memory/ingestion_documents.js +123 -0
  227. package/server/services/memory/ingestion_support.js +171 -0
  228. package/server/services/memory/intelligence.js +181 -0
  229. package/server/services/memory/manager.js +476 -32
  230. package/server/services/messaging/automation.js +71 -134
  231. package/server/services/messaging/formatting_guides.js +26 -1
  232. package/server/services/messaging/inbound_queue.js +111 -0
  233. package/server/services/messaging/manager.js +25 -8
  234. package/server/services/messaging/typing_keepalive.js +110 -0
  235. package/server/services/runtime/manager.js +7 -3
  236. package/server/services/runtime/settings.js +17 -0
  237. package/server/services/streaming/android-stream.js +296 -32
  238. package/server/services/streaming/stream-hub.js +1 -1
  239. package/server/services/tasks/integration_runtime.js +4 -1
  240. package/server/services/tasks/runtime.js +415 -56
  241. package/server/services/tasks/task_repository.js +48 -6
  242. package/server/services/websocket.js +8 -2
  243. package/server/services/widgets/service.js +17 -1
  244. package/server/services/workspace/manager.js +116 -0
  245. package/server/utils/logger.js +44 -6
  246. package/server/utils/security.js +3 -0
  247. package/server/utils/version.js +29 -19
  248. package/server/services/memory/openhuman_uplift.test.js +0 -98
@@ -30,7 +30,6 @@ const {
30
30
  const { getCapabilityHealth, summarizeCapabilityHealth } = require('./capabilityHealth');
31
31
  const {
32
32
  buildPlatformFormattingGuide,
33
- normalizeOutgoingMessageForPlatform,
34
33
  splitOutgoingMessageForPlatform,
35
34
  } = require('../messaging/formatting_guides');
36
35
  const {
@@ -49,6 +48,27 @@ const {
49
48
  } = require('./deliverables');
50
49
  const { buildLoopPolicy, resolveToolResultLimits } = require('./loopPolicy');
51
50
  const { globalHooks } = require('./hooks');
51
+ const { withProviderRetry, isTransientError } = require('./providerRetry');
52
+ const { normalizeCompletionConfidence, shouldAcceptTaskComplete } = require('./completion');
53
+ const { shortenRunId, summarizeForLog, parseMaybeJson } = require('./logFormat');
54
+ const {
55
+ normalizeOutgoingMessage,
56
+ clampRunContext,
57
+ joinSentMessages,
58
+ normalizeInterimText,
59
+ buildBlankMessagingReplyPrompt,
60
+ buildDeterministicMessagingFallback,
61
+ buildMessagingFailureScenario,
62
+ buildDeterministicMessagingErrorReply,
63
+ buildModelFailureLoopPrompt,
64
+ } = require('./messagingFallback');
65
+ const {
66
+ classifyToolExecution,
67
+ summarizeToolExecutions,
68
+ summarizeAvailableTools,
69
+ inferToolFailureMessage,
70
+ buildAutonomousRecoveryContext,
71
+ } = require('./toolEvidence');
52
72
 
53
73
  function generateTitle(task) {
54
74
  if (!task || typeof task !== 'string') return 'Untitled';
@@ -61,6 +81,17 @@ function generateTitle(task) {
61
81
  return cleaned.slice(0, 90);
62
82
  }
63
83
 
84
+ function buildInitialRunMetadata(options = {}) {
85
+ const metadata = {};
86
+ if (options.taskId != null && String(options.taskId).trim()) {
87
+ metadata.taskId = options.taskId;
88
+ }
89
+ if (options.widgetId != null && String(options.widgetId).trim()) {
90
+ metadata.widgetId = options.widgetId;
91
+ }
92
+ return metadata;
93
+ }
94
+
64
95
  function planningDepthForForceMode(forceMode) {
65
96
  return forceMode === 'plan_execute' ? 'deep' : 'light';
66
97
  }
@@ -78,6 +109,11 @@ function buildSkipTaskAnalysisResult(forceMode) {
78
109
  draft_reply: '',
79
110
  goal: 'Complete the user request accurately.',
80
111
  success_criteria: [],
112
+ complexity: forceMode === 'plan_execute' ? 'complex' : 'standard',
113
+ autonomy_level: forceMode === 'plan_execute' ? 'high' : 'normal',
114
+ progress_update_policy: 'optional',
115
+ parallel_work: false,
116
+ completion_confidence_required: forceMode === 'plan_execute' ? 'high' : 'medium',
81
117
  };
82
118
  }
83
119
 
@@ -87,6 +123,11 @@ function buildAnalyzeTaskFallback(forceMode, userMessage = '') {
87
123
  verification_need: 'light',
88
124
  planning_depth: planningDepthForForceMode(forceMode),
89
125
  goal: userMessage ? String(userMessage).trim().slice(0, 300) : '',
126
+ complexity: forceMode === 'plan_execute' ? 'complex' : 'standard',
127
+ autonomy_level: forceMode === 'plan_execute' ? 'high' : 'normal',
128
+ progress_update_policy: 'optional',
129
+ parallel_work: false,
130
+ completion_confidence_required: forceMode === 'plan_execute' ? 'high' : 'medium',
90
131
  };
91
132
  }
92
133
 
@@ -97,6 +138,19 @@ function applyForcedAnalysisMode(analysis, forceMode) {
97
138
  ...analysis,
98
139
  mode: 'plan_execute',
99
140
  planning_depth: 'deep',
141
+ complexity: 'complex',
142
+ autonomy_level: 'high',
143
+ completion_confidence_required: analysis.completion_confidence_required || 'high',
144
+ };
145
+ }
146
+
147
+ function buildAutonomyPolicyFromAnalysis(analysis = {}) {
148
+ return {
149
+ complexity: analysis.complexity || 'standard',
150
+ autonomy_level: analysis.autonomy_level || 'normal',
151
+ progress_update_policy: analysis.progress_update_policy || 'optional',
152
+ parallel_work: analysis.parallel_work === true,
153
+ completion_confidence_required: analysis.completion_confidence_required || 'medium',
100
154
  };
101
155
  }
102
156
 
@@ -150,22 +204,19 @@ async function getProviderForUser(userId, task = '', isSubagent = false, modelOv
150
204
  if (userSelectedDefault && userSelectedDefault !== 'auto') {
151
205
  selectedModelDef = models.find((m) => m.id === userSelectedDefault) || fallbackModel;
152
206
  } else {
153
- const taskStr = String(task || '').toLowerCase();
154
-
155
- // Basic detection
156
- let isPlanning = /\b(plan|think|analy[sz]e|complex|step by step)\b/.test(taskStr);
157
- let isCoding = false;
158
-
159
- // Enhanced detection if enabled
160
- if (smarterSelection) {
161
- isPlanning = isPlanning || /\b(reason|strategy|logical|math|complex)\b/.test(taskStr);
162
- isCoding = /\b(code|program|script|debug|refactor|function|implementation|logic)\b/.test(taskStr);
163
- }
164
-
165
- if (isPlanning) {
207
+ const selectionHint = providerConfig.selectionHint && typeof providerConfig.selectionHint === 'object'
208
+ ? providerConfig.selectionHint
209
+ : {};
210
+ const preferredPurpose = String(selectionHint.purpose || '').trim().toLowerCase();
211
+ const highAutonomy = selectionHint.autonomyLevel === 'high' || selectionHint.complexity === 'complex';
212
+ const requestedPurpose = ['planning', 'coding', 'general', 'fast'].includes(preferredPurpose)
213
+ ? preferredPurpose
214
+ : '';
215
+
216
+ if (smarterSelection && requestedPurpose) {
217
+ selectedModelDef = availableModels.find((m) => m.purpose === requestedPurpose) || fallbackModel;
218
+ } else if (smarterSelection && highAutonomy) {
166
219
  selectedModelDef = availableModels.find((m) => m.purpose === 'planning') || fallbackModel;
167
- } else if (isCoding) {
168
- selectedModelDef = availableModels.find((m) => m.purpose === 'coding') || availableModels.find((m) => m.purpose === 'planning') || fallbackModel;
169
220
  } else if (isSubagent) {
170
221
  selectedModelDef = availableModels.find((m) => m.purpose === 'fast') || fallbackModel;
171
222
  } else {
@@ -180,7 +231,7 @@ async function getProviderForUser(userId, task = '', isSubagent = false, modelOv
180
231
  };
181
232
  }
182
233
 
183
- async function getFailureFallbackModelId(userId, agentId, currentModelId, preferredFallbackId = null) {
234
+ async function getFailureFallbackModelId(userId, agentId, currentModelId, preferredFallbackId = null, failureError = null) {
184
235
  const { getSupportedModels } = require('./models');
185
236
  const aiSettings = getAiSettings(userId, agentId);
186
237
  const models = await getSupportedModels(userId, agentId);
@@ -196,7 +247,12 @@ async function getFailureFallbackModelId(userId, agentId, currentModelId, prefer
196
247
  || availableModels.find((model) => model.id === currentModelId)
197
248
  || null;
198
249
 
199
- if (preferredFallbackId && preferredFallbackId !== currentModelId) {
250
+ // When the failure is a provider-level rate limit, the preferred fallback is
251
+ // likely on the same provider and will hit the same limit. Skip it and prefer
252
+ // a fallback from a different provider instead.
253
+ const isProviderRateLimit = /429|rate.?limit|free-models-per/i.test(String(failureError?.message || ''));
254
+
255
+ if (preferredFallbackId && preferredFallbackId !== currentModelId && !isProviderRateLimit) {
200
256
  const preferred = pool.find((model) => model.id === preferredFallbackId)
201
257
  || availableModels.find((model) => model.id === preferredFallbackId);
202
258
  if (preferred) return preferred.id;
@@ -208,6 +264,14 @@ async function getFailureFallbackModelId(userId, agentId, currentModelId, prefer
208
264
  if (differentProvider) return differentProvider.id;
209
265
  }
210
266
 
267
+ // If no different-provider model exists, still try the preferred fallback
268
+ // even on rate limits (it's better than nothing).
269
+ if (preferredFallbackId && preferredFallbackId !== currentModelId) {
270
+ const preferred = pool.find((model) => model.id === preferredFallbackId)
271
+ || availableModels.find((model) => model.id === preferredFallbackId);
272
+ if (preferred) return preferred.id;
273
+ }
274
+
211
275
  const differentModel = pool.find((model) => model.id !== currentModelId)
212
276
  || availableModels.find((model) => model.id !== currentModelId);
213
277
  return differentModel?.id || null;
@@ -219,434 +283,9 @@ function estimateTokenValue(value) {
219
283
  return Math.ceil(JSON.stringify(value).length / 4);
220
284
  }
221
285
 
222
- function normalizeOutgoingMessage(content, platform = null, options = {}) {
223
- const normalized = normalizeOutgoingMessageForPlatform(platform, content);
224
- if (options.collapseWhitespace === false) {
225
- return normalized;
226
- }
227
- return normalized.replace(/\s+/g, ' ').trim();
228
- }
229
-
230
- function joinSentMessages(messages = []) {
231
- if (!Array.isArray(messages)) return '';
232
- return messages
233
- .map((message) => String(message || '').trim())
234
- .filter(Boolean)
235
- .join('\n\n');
236
- }
237
-
238
- function normalizeInterimText(content, platform = null) {
239
- return normalizeOutgoingMessageForPlatform(platform, content, {
240
- stripNoResponseMarker: false,
241
- }).trim();
242
- }
243
-
244
- function buildBlankMessagingReplyPrompt(attempt, platform = null) {
245
- const formattingGuide = buildPlatformFormattingGuide(platform);
246
- if (attempt <= 1) {
247
- return `You must send one non-empty reply for the external messaging user right now. Do not call tools. Give either: (a) the concrete outcome, or (b) a clear blocker. If tool work already happened, summarize what you actually tried and where it got blocked. Do not ask the user to repeat the original request. Do not promise future work unless that work already happened in this run or will happen automatically before this reply is sent.\n\n${formattingGuide}`;
248
- }
249
-
250
- return `Your previous reply was empty. Return one non-empty message now. Do not call tools. If needed, apologize briefly and explain the blocker in one sentence. Use the run evidence already in the conversation instead of asking the user to restate the task. Do not promise future work unless that work already happened in this run or will happen automatically before this reply is sent.\n\n${formattingGuide}`;
251
- }
252
-
253
- function parseToolExecutionSummary(item) {
254
- if (!item?.summary) return null;
255
- try {
256
- const parsed = JSON.parse(item.summary);
257
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
258
- } catch {
259
- return null;
260
- }
261
- }
262
-
263
- function toolWorkDescription(toolName) {
264
- const name = String(toolName || '');
265
- if (name === 'execute_command') return 'ran shell commands';
266
- if (name === 'read_file' || name === 'search_files' || name === 'list_directory') return 'checked files';
267
- if (name === 'web_search' || name === 'http_request') return 'looked up supporting information';
268
- if (name.startsWith('browser_')) return 'checked the browser state';
269
- if (name.startsWith('android_')) return 'checked the Android state';
270
- if (name === 'read_health_data' || name.startsWith('recordings_')) return 'checked stored data';
271
- return '';
272
- }
273
-
274
- function summarizeRecentWork(toolExecutions = []) {
275
- const descriptions = [];
276
- for (const item of toolExecutions.slice(-6)) {
277
- const description = toolWorkDescription(item?.toolName);
278
- if (!description || descriptions.includes(description)) continue;
279
- descriptions.push(description);
280
- if (descriptions.length >= 2) break;
281
- }
282
-
283
- if (descriptions.length === 0) return '';
284
- if (descriptions.length === 1) return `I ${descriptions[0]}`;
285
- return `I ${descriptions[0]} and ${descriptions[1]}`;
286
- }
287
-
288
- function hasFailureSignal(text) {
289
- const normalized = normalizeOutgoingMessage(text);
290
- if (!normalized) return false;
291
- return /\b(error|failed|failure|traceback|exception|timed out|timeout|not found|no such file|permission denied|unable to|cannot|could not|module not found)\b/i.test(normalized);
292
- }
293
-
294
- function extractToolFailureMessage(item) {
295
- const directError = normalizeOutgoingMessage(item?.error || '');
296
- if (directError) return directError;
297
-
298
- const summary = parseToolExecutionSummary(item);
299
- if (!summary) return '';
300
-
301
- const candidates = [
302
- summary.message,
303
- summary.note,
304
- summary.stderr,
305
- summary.stdout,
306
- summary.content,
307
- summary.excerpt,
308
- summary.result,
309
- summary.summary,
310
- ];
311
-
312
- if (summary.status === 'error') {
313
- for (const candidate of candidates) {
314
- const normalized = normalizeOutgoingMessage(candidate || '');
315
- if (normalized) return normalized;
316
- }
317
- if (summary.exitCode != null) {
318
- return `The last shell command exited with code ${summary.exitCode}`;
319
- }
320
- }
321
-
322
- for (const candidate of candidates) {
323
- const normalized = normalizeOutgoingMessage(candidate || '');
324
- if (hasFailureSignal(normalized)) return normalized;
325
- }
326
-
327
- return '';
328
- }
329
-
330
- function buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions = [] }) {
331
- const workSummary = summarizeRecentWork(toolExecutions);
332
- const blocker = [...toolExecutions].reverse()
333
- .map((item) => extractToolFailureMessage(item))
334
- .find(Boolean);
335
-
336
- if (workSummary && blocker) {
337
- return `${workSummary}, but I got blocked: ${blocker}. I do not have a confirmed finished result yet.`;
338
- }
339
- if (blocker) {
340
- return `I got blocked while working on this: ${blocker}. I do not have a confirmed finished result yet.`;
341
- }
342
- if (workSummary && stepIndex > 0) {
343
- return `${workSummary}, but I do not have a confirmed finished result yet.`;
344
- }
345
- if (failedStepCount > 0) {
346
- return 'I ran into a tool problem while working on your request, so I do not have a confirmed finished result yet.';
347
- }
348
- if (stepIndex > 0) {
349
- return 'I completed part of the work, but I do not have a confirmed finished result yet.';
350
- }
351
- return 'I could not produce a reliable final reply just now.';
352
- }
353
-
354
- function buildMessagingFailureScenario({ err, failedStepCount, stepIndex, toolExecutions = [] }) {
355
- const parts = [];
356
- const runtimeError = normalizeOutgoingMessage(err?.message || '');
357
- const workSummary = summarizeRecentWork(toolExecutions);
358
- const blocker = [...toolExecutions].reverse()
359
- .map((item) => extractToolFailureMessage(item))
360
- .find(Boolean);
361
-
362
- if (runtimeError) {
363
- parts.push(`Runtime error: ${summarizeForLog(runtimeError, 260)}.`);
364
- }
365
- if (workSummary) {
366
- parts.push(`Observed work before failure: ${workSummary}.`);
367
- }
368
- if (blocker) {
369
- parts.push(`Most specific blocker from run evidence: ${summarizeForLog(blocker, 260)}.`);
370
- }
371
- if (stepIndex > 0) {
372
- parts.push(`Completed steps before failure: ${stepIndex}.`);
373
- }
374
- if (failedStepCount > 0) {
375
- parts.push(`Failed tool steps: ${failedStepCount}.`);
376
- }
377
-
378
- return parts.join(' ');
379
- }
380
-
381
- function buildDeterministicMessagingErrorReply({ err, failedStepCount, stepIndex, toolExecutions = [] }) {
382
- const message = normalizeOutgoingMessage(err?.message || '');
383
- if (/no ai providers? are currently available/i.test(message)) {
384
- return 'I cannot continue right now because no AI provider is available for this account. Please check the provider settings.';
385
- }
386
-
387
- if (/(timeout|timed out)/i.test(message)) {
388
- return 'I hit a timeout while processing your request and could not finish it reliably.';
389
- }
390
-
391
- const blocker = [...toolExecutions].reverse()
392
- .map((item) => extractToolFailureMessage(item))
393
- .find(Boolean);
394
- if (blocker) {
395
- return `I got blocked while checking this: ${blocker}.`;
396
- }
397
-
398
- if (message) {
399
- return `I got blocked while working on this: ${message}.`;
400
- }
401
-
402
- return buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
403
- }
404
-
405
- function buildModelFailureLoopPrompt({ failedModel, nextModel, errorMessage }) {
406
- return [
407
- `The previous model call on "${failedModel}" failed with: ${summarizeForLog(errorMessage, 220)}.`,
408
- `Continue on "${nextModel}" and recover autonomously.`,
409
- 'If a previous plan depended on that failed call, adjust your approach and proceed end-to-end.',
410
- 'Only ask the user for help if no safe path remains.'
411
- ].join(' ');
412
- }
413
-
414
- function clampRunContext(text, maxChars) {
415
- const value = normalizeOutgoingMessage(text);
416
- if (!value) return '';
417
- if (value.length <= maxChars) return value;
418
- return `${value.slice(0, maxChars)}...`;
419
- }
420
-
421
- function shortenRunId(runId) {
422
- const value = String(runId || '').trim();
423
- if (!value) return 'unknown';
424
- return value.length <= 8 ? value : value.slice(0, 8);
425
- }
426
-
427
- function summarizeForLog(value, maxChars = 220) {
428
- if (value == null) return '';
429
-
430
- let text = '';
431
- if (typeof value === 'string') {
432
- text = value;
433
- } else {
434
- try {
435
- text = JSON.stringify(value);
436
- } catch {
437
- text = String(value);
438
- }
439
- }
440
-
441
- const normalized = text.replace(/\s+/g, ' ').trim();
442
- if (normalized.length <= maxChars) return normalized;
443
- return `${normalized.slice(0, maxChars)}...`;
444
- }
445
-
446
- function parseMaybeJson(value, fallback = null) {
447
- if (!value) return fallback;
448
- if (typeof value === 'object') return value;
449
- try {
450
- return JSON.parse(value);
451
- } catch {
452
- return fallback;
453
- }
454
- }
455
-
456
- function classifyToolExecution(toolName, toolArgs = {}, result, errorMessage = '') {
457
- const name = String(toolName || '');
458
- const evidenceRelevantPrefixes = ['browser_', 'android_'];
459
- const evidenceRelevantExact = new Set([
460
- 'web_search',
461
- 'http_request',
462
- 'read_file',
463
- 'search_files',
464
- 'list_directory',
465
- 'session_search',
466
- 'memory_recall',
467
- 'analyze_image',
468
- 'read_health_data',
469
- 'recordings_list',
470
- 'recordings_get',
471
- 'recordings_search',
472
- 'list_tasks',
473
- 'wait_subagent',
474
- ]);
475
- const stateChangingExact = new Set([
476
- 'execute_command',
477
- 'write_file',
478
- 'edit_file',
479
- 'send_interim_update',
480
- 'send_message',
481
- 'make_call',
482
- 'create_skill',
483
- 'update_skill',
484
- 'delete_skill',
485
- 'create_task',
486
- 'update_task',
487
- 'delete_task',
488
- 'create_ai_widget',
489
- 'update_ai_widget',
490
- 'delete_ai_widget',
491
- 'save_widget_snapshot',
492
- 'mcp_add_server',
493
- 'mcp_remove_server',
494
- 'spawn_subagent',
495
- 'cancel_subagent',
496
- ]);
497
-
498
- const evidenceSource = name.startsWith('browser_')
499
- ? 'browser'
500
- : name.startsWith('android_')
501
- ? 'android'
502
- : name.startsWith('mcp_')
503
- ? 'mcp'
504
- : name.startsWith('memory_') || name === 'session_search'
505
- ? 'memory'
506
- : name === 'web_search'
507
- ? 'search'
508
- : name === 'http_request'
509
- ? 'http'
510
- : ['read_file', 'search_files', 'list_directory', 'write_file', 'edit_file'].includes(name)
511
- ? 'files'
512
- : name === 'execute_command'
513
- ? 'command'
514
- : name.includes('skill')
515
- ? 'skills'
516
- : (name === 'create_task' || name === 'update_task' || name === 'delete_task' || name === 'list_tasks' || name.includes('widget'))
517
- ? 'tasks'
518
- : name === 'send_message' || name === 'make_call'
519
- ? 'messaging'
520
- : name.startsWith('recordings_') || name === 'read_health_data'
521
- ? 'data'
522
- : name === 'analyze_image'
523
- ? 'vision'
524
- : name.includes('subagent')
525
- ? 'subagent'
526
- : 'tool';
527
-
528
- const evidenceRelevant = evidenceRelevantExact.has(name)
529
- || evidenceRelevantPrefixes.some((prefix) => name.startsWith(prefix));
530
- const stateChanged = stateChangingExact.has(name)
531
- || name.startsWith('android_')
532
- || ['browser_click', 'browser_type', 'browser_evaluate'].includes(name);
533
-
534
- let normalizedError = String(errorMessage || result?.error || '').trim();
535
- if (!normalizedError && name === 'execute_command' && result && typeof result === 'object') {
536
- if (result.timedOut) {
537
- normalizedError = `Command timed out after ${result.durationMs || 'unknown'} ms`;
538
- } else if (result.killed || result.signal) {
539
- normalizedError = 'Command was killed before it finished';
540
- } else if (typeof result.exitCode === 'number' && result.exitCode !== 0) {
541
- normalizedError = summarizeForLog(result.stderr || result.stdout || `Command exited with code ${result.exitCode}`, 220);
542
- }
543
- }
544
-
545
- if (!normalizedError && result && typeof result === 'object') {
546
- const nestedResult = result.result && typeof result.result === 'object' && !Array.isArray(result.result)
547
- ? result.result
548
- : null;
549
- const detail = normalizeOutgoingMessage(
550
- result.reason
551
- || result.message
552
- || nestedResult?.reason
553
- || nestedResult?.message
554
- || ''
555
- );
556
-
557
- if (result.skipped === true || nestedResult?.skipped === true) {
558
- normalizedError = detail || 'Tool reported skipped outcome.';
559
- } else if (result.success === false || nestedResult?.success === false) {
560
- normalizedError = detail || 'Tool reported success=false.';
561
- } else if (result.sent === false || nestedResult?.sent === false) {
562
- normalizedError = detail || 'Tool reported sent=false.';
563
- }
564
- }
565
-
566
- return {
567
- toolName: name,
568
- ok: !normalizedError,
569
- error: normalizedError,
570
- evidenceSource,
571
- evidenceRelevant,
572
- stateChanged,
573
- dependsOnOutput: true,
574
- summary: compactToolResult(name, toolArgs, result || { error: errorMessage || 'Tool failed' }, {
575
- softLimit: 500,
576
- hardLimit: 900,
577
- }),
578
- };
579
- }
580
-
581
- function summarizeToolExecutions(toolExecutions = [], maxItems = 10) {
582
- return toolExecutions.slice(-maxItems).map((item, index) => {
583
- const status = item.ok ? 'ok' : `error=${item.error}`;
584
- return `${index + 1}. ${item.toolName} [${item.evidenceSource}] ${status} :: ${clampRunContext(item.summary || '', 220)}`;
585
- }).join('\n');
586
- }
587
-
588
- function summarizeAvailableTools(tools = [], { exclude = [] } = {}) {
589
- const excluded = new Set((Array.isArray(exclude) ? exclude : [exclude]).filter(Boolean));
590
- return tools
591
- .map((tool) => String(tool?.name || '').trim())
592
- .filter((name) => name && !excluded.has(name))
593
- .slice(0, 24)
594
- .join(', ');
595
- }
596
-
597
- function inferToolFailureMessage(toolName, result) {
598
- const explicitError = normalizeOutgoingMessage(result?.error || '');
599
- if (explicitError) return explicitError;
600
-
601
- if (!result || typeof result !== 'object') return '';
602
-
603
- if (toolName === 'execute_command') {
604
- if (result.timedOut) {
605
- return `Command timed out after ${result.durationMs || 'unknown'} ms`;
606
- }
607
- if (result.killed || result.signal) {
608
- return 'Command was killed before it finished';
609
- }
610
- if (typeof result.exitCode === 'number' && result.exitCode !== 0) {
611
- return summarizeForLog(result.stderr || result.stdout || `Command exited with code ${result.exitCode}`, 220);
612
- }
613
- }
614
-
615
- if (toolName === 'http_request' && typeof result.status === 'number' && result.status >= 400) {
616
- const bodySnippet = normalizeOutgoingMessage(result.body || '');
617
- return summarizeForLog(
618
- bodySnippet
619
- ? `HTTP request returned status ${result.status}: ${bodySnippet}`
620
- : `HTTP request returned status ${result.status}`,
621
- 240
622
- );
623
- }
624
-
625
- return '';
626
- }
627
-
628
- function buildAutonomousRecoveryContext({ err, toolExecutions = [], tools = [], userMessage, visibleMessageSent = false }) {
629
- const lastFailure = [...toolExecutions].reverse().find((item) => !item.ok);
630
- const alternativeTools = summarizeAvailableTools(tools, { exclude: lastFailure?.toolName || null });
631
- const parts = [
632
- 'This is an internal recovery retry for the same user task. Continue the task instead of stopping.',
633
- userMessage ? `Original task: ${clampRunContext(userMessage, 260)}` : '',
634
- lastFailure?.toolName ? `Previous attempt failed on tool: ${lastFailure.toolName}.` : '',
635
- lastFailure?.error ? `Concrete failure: ${summarizeForLog(lastFailure.error, 260)}.` : '',
636
- err?.message ? `Run-level error after that failure: ${summarizeForLog(err.message, 220)}.` : '',
637
- 'Do not send a blocker message just because one tool path failed.',
638
- 'Use a different safe approach if available: alternate tool, different query, browser path, HTTP fetch, file/code inspection, or command verification.',
639
- visibleMessageSent ? 'A user-facing message was already sent in a previous internal attempt. Continue silently unless you have a materially new finished result or a real external blocker.' : '',
640
- alternativeTools ? `Other available tools in this run: ${alternativeTools}.` : '',
641
- 'Only stop if the remaining problem truly requires an external dependency or user action outside this run.'
642
- ];
643
- return parts.filter(Boolean).join(' ');
644
- }
645
-
646
286
  class AgentEngine {
647
287
  constructor(io, services = {}) {
648
288
  this.io = io;
649
- this.maxIterations = 12;
650
289
  this.activeRuns = new Map();
651
290
  this.subagents = new Map();
652
291
  this.app = services.app || null;
@@ -878,17 +517,20 @@ class AgentEngine {
878
517
  fallback = {},
879
518
  reasoningEffort,
880
519
  }) {
881
- const response = await provider.chat(
882
- sanitizeConversationMessages([
883
- ...messages,
884
- { role: 'system', content: prompt },
885
- ]),
886
- [],
887
- {
888
- model,
889
- maxTokens,
890
- reasoningEffort: reasoningEffort || this.getReasoningEffort(providerName, {}),
891
- }
520
+ const response = await withProviderRetry(
521
+ () => provider.chat(
522
+ sanitizeConversationMessages([
523
+ ...messages,
524
+ { role: 'system', content: prompt },
525
+ ]),
526
+ [],
527
+ {
528
+ model,
529
+ maxTokens,
530
+ reasoningEffort: reasoningEffort || this.getReasoningEffort(providerName, {}),
531
+ }
532
+ ),
533
+ { label: `Engine ${model} (structured)` }
892
534
  );
893
535
 
894
536
  const parsed = parseJsonObject(response.content || '');
@@ -914,44 +556,80 @@ class AgentEngine {
914
556
  model,
915
557
  reasoningEffort: this.getReasoningEffort(providerName, options),
916
558
  };
917
- let response = null;
918
- let streamContent = '';
919
-
920
- if (options.stream !== false) {
921
- const stream = provider.stream(requestMessages, tools, callOptions);
922
- for await (const chunk of stream) {
923
- if (chunk.type === 'content') {
924
- streamContent += chunk.content;
925
- this.emit(options.userId, 'run:stream', {
926
- runId,
927
- content: sanitizeModelOutput(streamContent, { model }),
928
- iteration,
929
- });
930
- }
931
- if (chunk.type === 'done') {
932
- response = chunk;
933
- }
934
- if (chunk.type === 'tool_calls') {
935
- response = {
936
- content: chunk.content || streamContent,
937
- toolCalls: chunk.toolCalls,
938
- providerContentBlocks: chunk.providerContentBlocks || null,
939
- finishReason: 'tool_calls',
940
- usage: chunk.usage || null,
941
- };
559
+
560
+ const attemptModelCall = async () => {
561
+ let response = null;
562
+ let streamContent = '';
563
+
564
+ if (options.stream !== false) {
565
+ let emittedContent = false;
566
+ const stream = provider.stream(requestMessages, tools, callOptions);
567
+ try {
568
+ for await (const chunk of stream) {
569
+ if (chunk.type === 'content') {
570
+ emittedContent = true;
571
+ streamContent += chunk.content;
572
+ this.emit(options.userId, 'run:stream', {
573
+ runId,
574
+ content: sanitizeModelOutput(streamContent, { model }),
575
+ iteration,
576
+ });
577
+ }
578
+ if (chunk.type === 'done') {
579
+ response = chunk;
580
+ }
581
+ if (chunk.type === 'tool_calls') {
582
+ response = {
583
+ content: chunk.content || streamContent,
584
+ toolCalls: chunk.toolCalls,
585
+ providerContentBlocks: chunk.providerContentBlocks || null,
586
+ finishReason: 'tool_calls',
587
+ usage: chunk.usage || null,
588
+ };
589
+ }
590
+ }
591
+ } catch (err) {
592
+ // Once tokens have streamed to the client a retry would duplicate
593
+ // output, so only the pre-stream window is safe to replay.
594
+ if (emittedContent) err.__providerRetryUnsafe = true;
595
+ throw err;
942
596
  }
597
+ } else {
598
+ response = await provider.chat(requestMessages, tools, callOptions);
943
599
  }
944
- } else {
945
- response = await provider.chat(requestMessages, tools, callOptions);
600
+
601
+ return { response, streamContent };
602
+ };
603
+
604
+ const { response, streamContent } = await withProviderRetry(attemptModelCall, {
605
+ ...(options.retry || {}),
606
+ label: `Engine ${model}`,
607
+ isRetryable: (err) => !err?.__providerRetryUnsafe && isTransientError(err),
608
+ onRetry: ({ attempt, delayMs }) => {
609
+ this.emit(options.userId, 'run:interim', {
610
+ runId,
611
+ message: `Model service busy; retrying (attempt ${attempt}) in ${Math.max(1, Math.round(delayMs / 1000))}s.`,
612
+ phase: 'recovering',
613
+ });
614
+ },
615
+ });
616
+
617
+ const resolvedResponse = response || {
618
+ content: streamContent,
619
+ toolCalls: [],
620
+ finishReason: 'stop',
621
+ usage: null,
622
+ };
623
+ const hasContent = Boolean(String(resolvedResponse.content || streamContent || '').trim());
624
+ const hasToolCalls = Array.isArray(resolvedResponse.toolCalls) && resolvedResponse.toolCalls.length > 0;
625
+ if (!hasContent && !hasToolCalls) {
626
+ const error = new Error(`Model ${model} returned an empty response.`);
627
+ error.code = 'MODEL_EMPTY_RESPONSE';
628
+ throw error;
946
629
  }
947
630
 
948
631
  return {
949
- response: response || {
950
- content: streamContent,
951
- toolCalls: [],
952
- finishReason: 'stop',
953
- usage: null,
954
- },
632
+ response: resolvedResponse,
955
633
  responseModel: model,
956
634
  streamContent,
957
635
  };
@@ -1064,12 +742,14 @@ class AgentEngine {
1064
742
  '- A progress update is not complete.',
1065
743
  '- A single failed tool attempt is not blocked if another safe retry, verification step, or alternative path remains.',
1066
744
  '- A tool-specific API error, timeout, rate limit, or missing result inside this run is usually "continue", not "blocked", if any other available tool could still make progress.',
745
+ '- If completion_confidence_required is high and the latest draft depends on unverified assumptions, use "continue" so the run can gather evidence, inspect state, or narrow the reply.',
1067
746
  triggerSource === 'messaging' && messagingSent
1068
747
  ? '- A reply was already delivered to the user via send_message. Use "complete" unless there is concrete remaining work (e.g., a tool call you still need to make) before the task is truly done. Do not send follow-up elaborations or re-introductions.'
1069
748
  : triggerSource === 'messaging'
1070
749
  ? '- For messaging, do not stop on a partial status message. Continue unless the task is actually complete or externally blocked. If you already asked for missing user input, choose "blocked" and wait.'
1071
750
  : '- Do not stop just because you wrote a status update. Continue unless the task is actually complete or externally blocked.',
1072
751
  analysis?.goal ? `Goal: ${analysis.goal}` : '',
752
+ `Autonomy contract: complexity=${analysis?.complexity || 'standard'}; autonomy_level=${analysis?.autonomy_level || 'normal'}; progress_update_policy=${analysis?.progress_update_policy || 'optional'}; parallel_work=${analysis?.parallel_work === true}; completion_confidence_required=${analysis?.completion_confidence_required || 'medium'}.`,
1073
753
  successCriteria.length > 0 ? `Success criteria:\n${successCriteria.map((item, index) => `${index + 1}. ${item}`).join('\n')}` : '',
1074
754
  `Current iteration: ${iteration} of ${maxIterations}.`,
1075
755
  `Available tools in this run: ${summarizeAvailableTools(tools) || 'none'}`,
@@ -1101,6 +781,7 @@ class AgentEngine {
1101
781
  model,
1102
782
  messages,
1103
783
  analysis,
784
+ tools,
1104
785
  toolExecutions,
1105
786
  finalReply,
1106
787
  options,
@@ -1117,6 +798,7 @@ class AgentEngine {
1117
798
  messages,
1118
799
  prompt: buildVerifierPrompt({
1119
800
  analysis,
801
+ tools,
1120
802
  toolExecutionSummary: summarizeToolExecutions(toolExecutions),
1121
803
  evidenceSources,
1122
804
  finalReply,
@@ -1424,7 +1106,9 @@ class AgentEngine {
1424
1106
  }
1425
1107
 
1426
1108
  getMessagingRetryLimit(maxIterations) {
1427
- return Math.max(1, maxIterations);
1109
+ // Cap at 3: more than 3 autonomous messaging retries indicates a structural
1110
+ // problem (model unavailable, bad config) that more retries won't solve.
1111
+ return Math.min(3, Math.max(1, maxIterations));
1428
1112
  }
1429
1113
 
1430
1114
  buildContextMessages(systemPrompt, summaryMessage, historyMessages, recallMsg) {
@@ -1565,7 +1249,7 @@ class AgentEngine {
1565
1249
  let model = selectedProvider.model;
1566
1250
  let providerName = selectedProvider.providerName;
1567
1251
  const switchToFallbackModel = async (failedModel, error, phase) => {
1568
- const fallbackModelId = await getFailureFallbackModelId(userId, agentId, failedModel, aiSettings.fallback_model_id);
1252
+ const fallbackModelId = await getFailureFallbackModelId(userId, agentId, failedModel, aiSettings.fallback_model_id, error);
1569
1253
  if (!fallbackModelId || fallbackModelId === failedModel) return false;
1570
1254
  console.log(`[Engine] ${phase} failed on ${failedModel}; attempting fallback to: ${fallbackModelId}`);
1571
1255
  this.emit(userId, 'run:interim', {
@@ -1597,8 +1281,19 @@ class AgentEngine {
1597
1281
  };
1598
1282
 
1599
1283
  const runTitle = generateTitle(userMessage);
1600
- db.prepare(`INSERT OR REPLACE INTO agent_runs(id, user_id, agent_id, title, status, trigger_type, trigger_source, model)
1601
- VALUES(?, ?, ?, ?, 'running', ?, ?, ?)`).run(runId, userId, agentId, runTitle, triggerType, triggerSource, model);
1284
+ const initialRunMetadata = buildInitialRunMetadata(options);
1285
+ db.prepare(`INSERT OR REPLACE INTO agent_runs(
1286
+ id, user_id, agent_id, title, status, trigger_type, trigger_source, model, metadata_json
1287
+ ) VALUES(?, ?, ?, ?, 'running', ?, ?, ?, ?)`).run(
1288
+ runId,
1289
+ userId,
1290
+ agentId,
1291
+ runTitle,
1292
+ triggerType,
1293
+ triggerSource,
1294
+ model,
1295
+ Object.keys(initialRunMetadata).length ? JSON.stringify(initialRunMetadata) : null,
1296
+ );
1602
1297
 
1603
1298
  const retryMessagingState = options.messagingRetryState || {};
1604
1299
  const carriedVisibleMessage = String(retryMessagingState.lastVisibleMessage || '').trim();
@@ -1610,6 +1305,7 @@ class AgentEngine {
1610
1305
  status: 'running',
1611
1306
  aborted: false,
1612
1307
  messagingSent: false,
1308
+ noResponse: false,
1613
1309
  explicitMessageSent: carriedExplicitMessageSent,
1614
1310
  lastSentMessage: carriedExplicitMessageSent ? carriedVisibleMessage : '',
1615
1311
  sentMessages: [],
@@ -1658,6 +1354,23 @@ class AgentEngine {
1658
1354
  const integrationManager = app?.locals?.integrationManager || null;
1659
1355
  const mcpTools = mcpManager ? mcpManager.getAllTools(userId, { agentId }) : [];
1660
1356
  const tools = selectToolsForTask(userMessage, builtInTools, mcpTools, options);
1357
+ const toolNames = tools.map((tool) => tool.name).filter(Boolean);
1358
+ const coreToolStatus = {
1359
+ send_message: toolNames.includes('send_message'),
1360
+ create_task: toolNames.includes('create_task'),
1361
+ list_tasks: toolNames.includes('list_tasks'),
1362
+ update_task: toolNames.includes('update_task'),
1363
+ delete_task: toolNames.includes('delete_task'),
1364
+ };
1365
+ this.recordRunEvent(userId, runId, 'tool_inventory', {
1366
+ total: toolNames.length,
1367
+ builtInTotal: builtInTools.length,
1368
+ mcpTotal: mcpTools.length,
1369
+ core: coreToolStatus,
1370
+ }, { agentId });
1371
+ console.info(
1372
+ `[Run ${shortenRunId(runId)}] tools total=${toolNames.length} builtIns=${builtInTools.length} mcp=${mcpTools.length} core=${JSON.stringify(coreToolStatus)}`
1373
+ );
1661
1374
  const capabilityHealth = await getCapabilityHealth({ userId, agentId, app, engine: this });
1662
1375
  const capabilitySummary = summarizeCapabilityHealth(capabilityHealth);
1663
1376
  const integrationSummary = integrationManager?.summarizeConnectedProviders?.(userId, agentId) || '';
@@ -1780,11 +1493,53 @@ class AgentEngine {
1780
1493
 
1781
1494
  }
1782
1495
 
1496
+ const activeDefaultModelSetting = triggerType === 'subagent'
1497
+ ? aiSettings.default_subagent_model
1498
+ : aiSettings.default_chat_model;
1499
+ if (!_modelOverride && activeDefaultModelSetting === 'auto' && aiSettings.smarter_model_selector !== false) {
1500
+ const requestedPurpose = analysis?.mode === 'plan_execute' || analysis?.complexity === 'complex' || analysis?.autonomy_level === 'high'
1501
+ ? 'planning'
1502
+ : triggerType === 'subagent'
1503
+ ? 'fast'
1504
+ : '';
1505
+ if (requestedPurpose) {
1506
+ const selectedAfterAnalysis = await getProviderForUser(
1507
+ userId,
1508
+ userMessage,
1509
+ triggerType === 'subagent',
1510
+ null,
1511
+ {
1512
+ ...providerStatusConfig,
1513
+ selectionHint: {
1514
+ purpose: requestedPurpose,
1515
+ complexity: analysis?.complexity,
1516
+ autonomyLevel: analysis?.autonomy_level,
1517
+ },
1518
+ }
1519
+ );
1520
+ if (selectedAfterAnalysis.model !== model) {
1521
+ provider = selectedAfterAnalysis.provider;
1522
+ model = selectedAfterAnalysis.model;
1523
+ providerName = selectedAfterAnalysis.providerName;
1524
+ db.prepare('UPDATE agent_runs SET model = ?, updated_at = datetime(\'now\') WHERE id = ?')
1525
+ .run(model, runId);
1526
+ this.emit(userId, 'run:interim', {
1527
+ runId,
1528
+ message: `Switched to ${model} for this run after task analysis.`,
1529
+ phase: 'model_selection'
1530
+ });
1531
+ }
1532
+ }
1533
+ }
1534
+
1783
1535
  // Rebuild loop policy with the resolved analysis mode. Runs in both the
1784
1536
  // normal path and the skipTaskAnalysis path so that forceMode='plan_execute'
1785
1537
  // (or any mode set by buildSkipTaskAnalysisResult) raises the iteration
1786
1538
  // ceiling correctly.
1787
- loopPolicy = buildLoopPolicy(aiSettings, triggerType, analysis.mode || 'execute', options);
1539
+ loopPolicy = buildLoopPolicy(aiSettings, triggerType, analysis.mode || 'execute', {
1540
+ ...options,
1541
+ autonomyPolicy: buildAutonomyPolicyFromAnalysis(analysis),
1542
+ });
1788
1543
  maxIterations = loopPolicy.maxIterations;
1789
1544
 
1790
1545
  if (options.skipDeliverableWorkflow !== true) {
@@ -1960,7 +1715,7 @@ class AgentEngine {
1960
1715
  } catch (err) {
1961
1716
  console.error(`[Engine] Model call failed (${model}):`, err.message);
1962
1717
  const fallbackModelId = retryForFallback
1963
- ? await getFailureFallbackModelId(userId, agentId, model, aiSettings.fallback_model_id)
1718
+ ? await getFailureFallbackModelId(userId, agentId, model, aiSettings.fallback_model_id, err)
1964
1719
  : null;
1965
1720
  if (fallbackModelId) {
1966
1721
  const failedModel = model;
@@ -2101,7 +1856,17 @@ class AgentEngine {
2101
1856
  break;
2102
1857
  }
2103
1858
  if (iteration < maxIterations) {
2104
- const fallbackStatus = (toolExecutions.length > 0 || failedStepCount > 0 || messagingSent) ? 'continue' : 'complete';
1859
+ const proactiveRunNeedsDecision = (
1860
+ (triggerSource === 'schedule' || triggerSource === 'tasks')
1861
+ && this.activeRuns.get(runId)?.noResponse !== true
1862
+ && options.deliveryState?.noResponse !== true
1863
+ );
1864
+ const fallbackStatus = (
1865
+ proactiveRunNeedsDecision
1866
+ || toolExecutions.length > 0
1867
+ || failedStepCount > 0
1868
+ || messagingSent
1869
+ ) ? 'continue' : 'complete';
2105
1870
  const loopState = await runWithModelFallback('loop decision', () => this.decideLoopState({
2106
1871
  provider,
2107
1872
  providerName,
@@ -2155,15 +1920,46 @@ class AgentEngine {
2155
1920
  // regular tool execution, it is a loop-exit signal.
2156
1921
  if (toolName === 'task_complete') {
2157
1922
  const finalMessage = String(toolArgs.message || '').trim();
1923
+ const confidence = normalizeCompletionConfidence(toolArgs.confidence || 'medium');
1924
+ const completionDecision = shouldAcceptTaskComplete({
1925
+ confidence,
1926
+ requiredConfidence: analysis?.completion_confidence_required || 'medium',
1927
+ iteration,
1928
+ maxIterations,
1929
+ });
2158
1930
  this.recordRunEvent(userId, runId, 'task_complete_signaled', {
2159
- confidence: toolArgs.confidence || 'high',
1931
+ confidence,
1932
+ requiredConfidence: analysis?.completion_confidence_required || 'medium',
1933
+ accepted: completionDecision.accept,
2160
1934
  iteration,
2161
1935
  messageLength: finalMessage.length,
2162
1936
  }, { agentId });
2163
1937
  console.info(
2164
- `[Run ${shortenRunId(runId)}] task_complete signaled at iteration=${iteration} confidence=${toolArgs.confidence || 'high'}`
1938
+ `[Run ${shortenRunId(runId)}] task_complete signaled at iteration=${iteration} confidence=${confidence} accepted=${completionDecision.accept}`
2165
1939
  );
2166
- // Always honor task_complete as a stop signal, even with no message.
1940
+ if (!completionDecision.accept) {
1941
+ messages.push({
1942
+ role: 'tool',
1943
+ name: toolName,
1944
+ tool_call_id: toolCall.id,
1945
+ content: JSON.stringify({
1946
+ status: 'continue',
1947
+ reason: completionDecision.reason,
1948
+ required_confidence: analysis?.completion_confidence_required || 'medium',
1949
+ }),
1950
+ });
1951
+ messages.push({
1952
+ role: 'system',
1953
+ content: `${completionDecision.reason} Do not ask the user to decide the next step unless external input is truly required.`
1954
+ });
1955
+ continue;
1956
+ }
1957
+ if (completionDecision.reason) {
1958
+ messages.push({
1959
+ role: 'system',
1960
+ content: completionDecision.reason,
1961
+ });
1962
+ }
2167
1963
  lastContent = finalMessage; // empty string is valid; downstream handles it
2168
1964
  directAnswerEligible = true;
2169
1965
  break; // exit the for-loop; the while condition will also exit
@@ -2467,6 +2263,18 @@ class AgentEngine {
2467
2263
  && !messagingSent
2468
2264
  && runMeta?.widgetSnapshotSaved !== true
2469
2265
  ) {
2266
+ const explicitNoResponse = (
2267
+ runMeta?.noResponse === true
2268
+ || options.deliveryState?.noResponse === true
2269
+ );
2270
+ if (
2271
+ (triggerSource === 'schedule' || triggerSource === 'tasks')
2272
+ && !explicitNoResponse
2273
+ ) {
2274
+ throw new Error(
2275
+ 'Background run ended without producing a result or an explicit no-response decision.',
2276
+ );
2277
+ }
2470
2278
  if (iteration >= maxIterations) {
2471
2279
  throw new Error(`Iteration limit reached before explicit completion after ${maxIterations} iterations.`);
2472
2280
  }
@@ -2501,6 +2309,7 @@ class AgentEngine {
2501
2309
  model,
2502
2310
  messages,
2503
2311
  analysis,
2312
+ tools,
2504
2313
  toolExecutions,
2505
2314
  finalReply: finalResponseText,
2506
2315
  options,
@@ -2709,11 +2518,16 @@ class AgentEngine {
2709
2518
 
2710
2519
  const runMeta = this.activeRuns.get(runId);
2711
2520
  const retryCount = Number(options.messagingAutonomousRetryCount || 0);
2521
+ // Rate-limit errors (429) must not trigger messaging retries: the model
2522
+ // won't be available in the milliseconds between retries, so spawning new
2523
+ // runs just compounds the rate-limit pressure with no benefit.
2524
+ const isRateLimitError = /429|rate.?limit|free-models-per/i.test(String(err?.message || ''));
2712
2525
  const canRetryMessagingRun = (
2713
2526
  triggerSource === 'messaging'
2714
2527
  && options.source
2715
2528
  && options.chatId
2716
2529
  && err?.disableAutonomousRetry !== true
2530
+ && !isRateLimitError
2717
2531
  && retryCount < this.getMessagingRetryLimit(maxIterations)
2718
2532
  );
2719
2533
 
@@ -3197,8 +3011,15 @@ class AgentEngine {
3197
3011
  db.prepare("UPDATE agent_runs SET status = 'stopped', updated_at = datetime('now') WHERE id = ?").run(runId);
3198
3012
  }
3199
3013
 
3200
- abort(runId) {
3201
- if (runId) this.stopRun(runId);
3014
+ abort(runId, { userId } = {}) {
3015
+ if (!runId) return false;
3016
+ if (userId != null) {
3017
+ // Ownership gate: never let one user abort another user's active run.
3018
+ const runMeta = this.activeRuns.get(runId);
3019
+ if (runMeta && Number(runMeta.userId) !== Number(userId)) return false;
3020
+ }
3021
+ this.stopRun(runId);
3022
+ return true;
3202
3023
  }
3203
3024
 
3204
3025
  abortAll(userId) {
@@ -3229,4 +3050,4 @@ class AgentEngine {
3229
3050
  }
3230
3051
  }
3231
3052
 
3232
- module.exports = { AgentEngine, getProviderForUser };
3053
+ module.exports = { AgentEngine, buildInitialRunMetadata, getProviderForUser };